diff --git a/.env-example b/.env-example index 16b8df1..3da2865 100644 --- a/.env-example +++ b/.env-example @@ -1,4 +1,4 @@ -# For information about this variables check butterrobot/config.py +# For information about this variables check config.py SLACK_TOKEN=xxx TELEGRAM_TOKEN=xxx diff --git a/.github/workflows/docker-build-latest.yaml b/.github/workflows/docker-build-latest.yaml new file mode 100644 index 0000000..de0b1e9 --- /dev/null +++ b/.github/workflows/docker-build-latest.yaml @@ -0,0 +1,21 @@ +name: Build latest tag docker image + +on: + push: + branches: + - master + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Build the Docker image + run: docker build --tag butterrobot:$(git rev-parse --short HEAD) -f Dockerfile.dev . + + - name: Push into Github packages (latest) + run: | + echo "${{ secrets.GITHUB_TOKEN }}" | docker login docker.pkg.github.com -u fmartingr --password-stdin + docker tag butterrobot:$(git rev-parse --short HEAD) docker.pkg.github.com/fmartingr/butterrobot/butterrobot:latest + docker push docker.pkg.github.com/fmartingr/butterrobot/butterrobot:latest diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..f9c0e31 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,43 @@ +name: Release + +on: + push: + branches: + - stable + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - 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: + - publish + steps: + - uses: actions/checkout@v2 + + - name: Build the Docker image + run: docker build --tag butterrobot:$(git rev-parse --short HEAD) docker + + - name: Push into Github packages (stable) + run: | + echo "${{ secrets.GITHUB_TOKEN }}" | docker login docker.pkg.github.com -u fmartingr --password-stdin + docker tag butterrobot:$(git rev-parse --short HEAD) docker.pkg.github.com/fmartingr/butterrobot/butterrobot:stable + docker tag butterrobot:$(git rev-parse --short HEAD) docker.pkg.github.com/fmartingr/butterrobot/butterrobot:$(cat pyproject.toml | grep version | cut -d "\"" -f 2) + docker push docker.pkg.github.com/fmartingr/butterrobot/butterrobot:stable + docker push docker.pkg.github.com/fmartingr/butterrobot/butterrobot:$(cat pyproject.toml | grep version | cut -d "\"" -f 2) diff --git a/.gitignore b/.gitignore index d964ffb..6054505 100644 --- a/.gitignore +++ b/.gitignore @@ -4,10 +4,9 @@ __pycache__ *~ *.cert .env-local -.coverage +test.py +# Distribution dist -bin -# Butterrobot -*.sqlite* -butterrobot.db +*.egg-info +pip-wheel-metadata diff --git a/.goreleaser.yml b/.goreleaser.yml deleted file mode 100644 index a3836e9..0000000 --- a/.goreleaser.yml +++ /dev/null @@ -1,150 +0,0 @@ -version: 2 - -gitea_urls: - api: https://git.nakama.town/api/v1 - download: https://git.nakama.town - -before: - hooks: - - go mod tidy - -git: - ignore_tags: - - "{{ if not .IsNightly }}*-rc*{{ end }}" - -builds: - - binary: butterrobot - main: ./cmd/butterrobot - env: - - CGO_ENABLED=0 - - GIN_MODE=release - tags: - - netgo - - osusergo - goos: - - linux - - windows - - darwin - goarch: - - amd64 - - arm - - arm64 - goarm: - - "7" - ignore: - - goos: darwin - goarch: arm - - goos: windows - goarch: arm - - goos: windows - goarch: arm64 - -archives: - - id: butterrobot - name_template: >- - {{ .ProjectName }}_ - {{- if eq .Os "darwin" }}Darwin{{- else if eq .Os "linux" }}Linux{{- else if eq .Os "windows" }}Windows{{- else }}{{ .Os }}{{ end }}_ - {{- if eq .Arch "amd64" }}x86_64{{- else if eq .Arch "arm64" }}aarch64{{- else }}{{ .Arch }}{{ end }}_{{ .Version }} - format_overrides: - - goos: windows - formats: ['zip'] - -dockers: -- image_templates: - - &amd64_image "git.nakama.town/fmartingr/butterrobot:{{ .Version }}-amd64" - use: buildx - dockerfile: &dockerfile Containerfile - goos: linux - goarch: amd64 - build_flag_templates: - - "--pull" - - "--platform=linux/amd64" -- image_templates: - - &arm64_image "git.nakama.town/fmartingr/butterrobot:{{ .Version }}-arm64" - use: buildx - dockerfile: *dockerfile - goos: linux - goarch: arm64 - build_flag_templates: - - "--pull" - - "--platform=linux/arm64" -- image_templates: - - &armv7_image "git.nakama.town/fmartingr/butterrobot:{{ .Version }}-armv7" - use: buildx - dockerfile: *dockerfile - goos: linux - goarch: arm - goarm: "7" - build_flag_templates: - - "--pull" - - "--platform=linux/arm/v7" - -docker_manifests: - - name_template: "git.nakama.town/fmartingr/butterrobot:{{ .Version }}" - image_templates: - - *amd64_image - - *arm64_image - - *armv7_image - # - name_template: "git.nakama.town/fmartingr/butterrobot:latest" - # image_templates: - # - *amd64_image - # - *arm64_image - # - *armv7_image - -nfpms: - - maintainer: Felipe Martin - description: A chatbot server with customizable commands and triggers - homepage: https://git.nakama.town/fmartingr/butterrobot - license: AGPL-3.0 - formats: - - deb - - rpm - - apk - -upx: - - enabled: true - ids: - - butterrobot - goos: [linux, darwin] - goarch: [amd64, arm, arm64] - goarm: ["7"] - -checksum: - name_template: 'checksums.txt' - -snapshot: - version_template: "{{ incpatch .Version }}-next" - -changelog: - sort: asc - groups: - - title: Features - regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' - order: 0 - - title: "Fixes" - regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$' - order: 1 - - title: "Performance" - regexp: '^.*?perf(\([[:word:]]+\))??!?:.+$' - order: 2 - - title: API - regexp: '^.*?api(\([[:word:]]+\))??!?:.+$' - order: 3 - - title: Documentation - regexp: '^.*?docs(\([[:word:]]+\))??!?:.+$' - order: 4 - - title: "Tests" - regexp: '^.*?test(\([[:word:]]+\))??!?:.+$' - order: 5 - - title: CI and Delivery - regexp: '^.*?ci(\([[:word:]]+\))??!?:.+$' - order: 6 - - title: Others - order: 999 - filters: - exclude: - - "^deps:" - - "^chore\\(deps\\):" - -release: - prerelease: auto diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml deleted file mode 100644 index 5b32d48..0000000 --- a/.woodpecker/ci.yml +++ /dev/null @@ -1,23 +0,0 @@ -when: - event: - - push - - pull_request - branch: - - master - -steps: - format: - image: golang:1.24 - commands: - - make format - - git diff --exit-code # Fail if files were changed - - lint: - image: golang:1.24 - commands: - - make ci-lint - - test: - image: golang:1.24 - commands: - - make test diff --git a/.woodpecker/release.yml b/.woodpecker/release.yml deleted file mode 100644 index 39dbf65..0000000 --- a/.woodpecker/release.yml +++ /dev/null @@ -1,16 +0,0 @@ -when: - - event: tag - branch: master - -steps: - - name: Release - image: goreleaser/goreleaser:latest - environment: - GITEA_TOKEN: - from_secret: GITEA_TOKEN - DOCKER_HOST: unix:///var/run/docker.sock - volumes: - - "/var/run/docker.sock:/var/run/docker.sock" - commands: - - docker login -u fmartingr -p $GITEA_TOKEN git.nakama.town - - goreleaser release --clean --parallelism=2 diff --git a/Containerfile b/Containerfile deleted file mode 100644 index 6a9ff7d..0000000 --- a/Containerfile +++ /dev/null @@ -1,6 +0,0 @@ -# This file is used directly by the goreleaser build -# It is used to build the final container image -FROM scratch -WORKDIR / -COPY /butterrobot /usr/bin/butterrobot -ENTRYPOINT ["/usr/bin/butterrobot"] diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..3e1a8fb --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,20 @@ +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"] diff --git a/Makefile b/Makefile index ff08163..cf6f0a8 100644 --- a/Makefile +++ b/Makefile @@ -1,100 +1,28 @@ -PROJECT_NAME := butterrobot +# Local development +setup: + poetry install -SOURCE_FILES ?=./... +docker@build: + docker build -t fmartingr/butterrobot -f docker/Dockerfile docker -TEST_OPTIONS ?= -v -failfast -race -bench=. -benchtime=100000x -cover -coverprofile=coverage.out -TEST_TIMEOUT ?=1m +docker@build-dev: + docker build -t fmartingr/butterrobot:dev -f Dockerfile.dev . -GOLANGCI_LINT_VERSION ?= v1.64.5 +docker@tag-dev: + docker tag fmartingr/butterrobot:dev registry.int.fmartingr.network/fmartingr/butterrobot:dev -CLEAN_OPTIONS ?=-modcache -testcache +docker@push-dev: + docker push registry.int.fmartingr.network/fmartingr/butterrobot:dev -CGO_ENABLED := 0 +docker@dev: + make docker@build-dev + make docker@tag-dev + make docker@push-dev -BUILDS_PATH := ./dist -FROM_MAKEFILE := y +docker@save: + make docker@build + docker image save fmartingr/butterrobot -o fmartingr-butterrobot-docker-image.tar -CONTAINERFILE_NAME := Containerfile -CONTAINER_ALPINE_VERSION := 3.21 -CONTAINER_SOURCE_URL := "https://git.nakama.town/fmartingr/${PROJECT_NAME}" -CONTAINER_MAINTAINER := "Felipe Martin " -CONTAINER_BIN_NAME := ${PROJECT_NAME} - -BUILDX_PLATFORMS := linux/amd64,linux/arm64,linux/arm/v7 - -export PROJECT_NAME -export FROM_MAKEFILE - -export CGO_ENABLED - -export SOURCE_FILES -export TEST_OPTIONS -export TEST_TIMEOUT -export BUILDS_PATH - -export CONTAINERFILE_NAME -export CONTAINER_ALPINE_VERSION -export CONTAINER_SOURCE_URL -export CONTAINER_MAINTAINER -export CONTAINER_BIN_NAME - -export BUILDX_PLATFORMS - -.PHONY: all -all: help - -# this is godly -# https://news.ycombinator.com/item?id=11939200 -.PHONY: help -help: ### this screen. Keep it first target to be default -ifeq ($(UNAME), Linux) - @grep -P '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \ - awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' -else - @# this is not tested, but prepared in advance for you, Mac drivers - @awk -F ':.*###' '$$0 ~ FS {printf "%15s%s\n", $$1 ":", $$2}' \ - $(MAKEFILE_LIST) | grep -v '@awk' | sort -endif - -.PHONY: clean -clean: ### clean test cache, build files - $(info: Make: Clean) - @rm -rf ${BUILDS_PATH} - @go clean ${CLEAN_OPTIONS} - @-docker buildx rm ${PROJECT_NAME}_builder - -.PHONY: build -build: clean ### builds the project for the setup os/arch combinations - $(info: Make: Build) - @goreleaser --clean --snapshot - -.PHONY: quick-run -quick-run: ### Executes the project using golang - CGO_ENABLED=${CGO_ENABLED} go run ./cmd/${PROJECT_NAME}/*.go - -.PHONY: run -run: ### Executes the project build locally - @make build - ${BUILDS_PATH}/${PROJECT_NAME} - -.PHONY: format -format: ### Executes the formatting pipeline on the project - $(info: Make: Format) - @go fmt ./... - @go mod tidy - -.PHONY: ci-lint -ci-lint: ### Check the project for errors - $(info: Make: Lint) - @go install github.com/golangci/golangci-lint/cmd/golangci-lint@${GOLANGCI_LINT_VERSION} - @golangci-lint run ./... - -.PHONY: lint -lint: ### Check the project for errors - $(info: Make: Lint) - @golangci-lint run ./... - -.PHONY: test -test: ### Runs the test suite - $(info: Make: Test) - CGO_ENABLED=1 go test ${TEST_OPTIONS} -timeout=${TEST_TIMEOUT} ${SOURCE_FILES} +clean: + rm -rf dist + rm -rf butterrobot.egg-info diff --git a/README.md b/README.md index 920d087..9e4a687 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,71 @@ # Butter Robot -![Status badge](https://woodpecker.local.fmartingr.dev/api/badges/5/status.svg) +![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) -Go framework to create bots for several platforms. +Python framework to create bots for several platforms. ![Butter Robot](./assets/icon@120.png) > What is my purpose? -## Features +## Supported platforms -- 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 +| Name | Receive messages | Send messages | +| --------------- | ---------------- | ------------- | +| Slack (app) | Yes | Yes | +| Telegram | Yes | Yes | -## Documentation +## Provided plugins -[Go to documentation](./docs) -### Database Management +### Development -ButterRobot includes an automatic database migration system. Migrations are applied automatically when the application starts, ensuring your database schema is always up to date. +#### Ping + + Say `!ping` to get response with time elapsed. -[Learn more about migrations](./docs/migrations.md) +### Fun and entertainment + +#### Loquito + + What happens when you say _"lo quito"_...? (Spanish pun) ## Installation -### From Source +### PyPi -```bash -# Clone the repository -git clone https://git.nakama.town/fmartingr/butterrobot.git -cd butterrobot +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. -# Build the application -go build -o butterrobot ./cmd/butterrobot +``` +$ pip install --user butterrobot +$ python -m butterrobot ``` ### Containers -The `fmartingr/butterrobot/butterrobot` container image is published on Github packages: +The `fmartingr/butterrobot/butterrobot` container image is published on Github packages to +use with your favourite tool: -```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 ``` - -## 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 +docker pull docker.pkg.github.com/fmartingr/butterrobot/butterrobot:latest +podman run -d --name fmartingr/butterrobot/butterrobot -p 8080:8080 +``` ## Contributing -```bash +To run the project locally you will need [poetry](https://python-poetry.org/). + +``` git clone git@github.com:fmartingr/butterrobot.git cd butterrobot -go mod download +poetry install ``` -Create a `.env-local` file with the required environment variables: +Create a `.env-local` file with the required environment variables, +you have [an example file](.env-example). ``` SLACK_TOKEN=xxx @@ -84,12 +73,8 @@ TELEGRAM_TOKEN=xxx ... ``` -And then you can run it directly: +And then you can run it directly with poetry -```bash -go run ./cmd/butterrobot/main.go ``` - -## License - -GPL-2.0 +docker run -it --rm --env-file .env-local -p 5000:5000 -v $PWD/butterrobot:/etc/app/butterrobot local/butterrobot python -m butterrobot +``` diff --git a/butterrobot/__init__.py b/butterrobot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/butterrobot/__main__.py b/butterrobot/__main__.py new file mode 100644 index 0000000..2f0182a --- /dev/null +++ b/butterrobot/__main__.py @@ -0,0 +1,6 @@ +from butterrobot.app import app +from butterrobot.config import DEBUG + +# Only used for local development! +# python -m butterrobot +app.run(debug=DEBUG, host="0.0.0.0") diff --git a/butterrobot/app.py b/butterrobot/app.py new file mode 100644 index 0000000..677d978 --- /dev/null +++ b/butterrobot/app.py @@ -0,0 +1,60 @@ +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("//incoming", methods=["POST"]) +@app.route("//incoming/", 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 {} diff --git a/butterrobot/config.py b/butterrobot/config.py new file mode 100644 index 0000000..cf428ed --- /dev/null +++ b/butterrobot/config.py @@ -0,0 +1,27 @@ +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") diff --git a/butterrobot/lib/__init__.py b/butterrobot/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/butterrobot/lib/slack.py b/butterrobot/lib/slack.py new file mode 100644 index 0000000..48eb8dd --- /dev/null +++ b/butterrobot/lib/slack.py @@ -0,0 +1,39 @@ +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) diff --git a/butterrobot/lib/telegram.py b/butterrobot/lib/telegram.py new file mode 100644 index 0000000..cd082b9 --- /dev/null +++ b/butterrobot/lib/telegram.py @@ -0,0 +1,59 @@ +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) diff --git a/butterrobot/logging.py b/butterrobot/logging.py new file mode 100644 index 0000000..4b30bd4 --- /dev/null +++ b/butterrobot/logging.py @@ -0,0 +1,23 @@ +import logging + +import structlog + +from butterrobot.config import LOG_LEVEL, DEBUG + + +logging.basicConfig(format="%(message)s", level=LOG_LEVEL) +structlog.configure( + processors=[ + structlog.stdlib.add_log_level, + structlog.stdlib.add_logger_name, + structlog.dev.set_exc_info, + structlog.processors.StackInfoRenderer(), + structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M.%S"), + structlog.processors.format_exc_info, + structlog.dev.ConsoleRenderer() if DEBUG else structlog.processors.JSONRenderer(), + ], + context_class=dict, + logger_factory=structlog.stdlib.LoggerFactory(), + wrapper_class=structlog.BoundLogger, + cache_logger_on_first_use=True, +) diff --git a/butterrobot/objects.py b/butterrobot/objects.py new file mode 100644 index 0000000..0c734ee --- /dev/null +++ b/butterrobot/objects.py @@ -0,0 +1,13 @@ +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) diff --git a/butterrobot/platforms/__init__.py b/butterrobot/platforms/__init__.py new file mode 100644 index 0000000..ff08cef --- /dev/null +++ b/butterrobot/platforms/__init__.py @@ -0,0 +1,5 @@ +from butterrobot.platforms.slack import SlackPlatform +from butterrobot.platforms.telegram import TelegramPlatform + + +PLATFORMS = {platform.ID: platform for platform in (SlackPlatform, TelegramPlatform,)} diff --git a/butterrobot/platforms/base.py b/butterrobot/platforms/base.py new file mode 100644 index 0000000..8fa2198 --- /dev/null +++ b/butterrobot/platforms/base.py @@ -0,0 +1,35 @@ +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 diff --git a/butterrobot/platforms/slack.py b/butterrobot/platforms/slack.py new file mode 100644 index 0000000..71d9d7c --- /dev/null +++ b/butterrobot/platforms/slack.py @@ -0,0 +1,70 @@ +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, + ) diff --git a/butterrobot/platforms/telegram.py b/butterrobot/platforms/telegram.py new file mode 100644 index 0000000..7506861 --- /dev/null +++ b/butterrobot/platforms/telegram.py @@ -0,0 +1,66 @@ +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, + ) diff --git a/butterrobot/plugins.py b/butterrobot/plugins.py new file mode 100644 index 0000000..b9dc5a0 --- /dev/null +++ b/butterrobot/plugins.py @@ -0,0 +1,37 @@ +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 diff --git a/butterrobot_plugins_contrib/__init__.py b/butterrobot_plugins_contrib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/butterrobot_plugins_contrib/dev.py b/butterrobot_plugins_contrib/dev.py new file mode 100644 index 0000000..18b9a8c --- /dev/null +++ b/butterrobot_plugins_contrib/dev.py @@ -0,0 +1,17 @@ +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)", + ) diff --git a/butterrobot_plugins_contrib/fun.py b/butterrobot_plugins_contrib/fun.py new file mode 100644 index 0000000..78b4755 --- /dev/null +++ b/butterrobot_plugins_contrib/fun.py @@ -0,0 +1,11 @@ +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.",) diff --git a/cmd/butterrobot/main.go b/cmd/butterrobot/main.go deleted file mode 100644 index 3bc56cb..0000000 --- a/cmd/butterrobot/main.go +++ /dev/null @@ -1,48 +0,0 @@ -package main - -import ( - "fmt" - "log/slog" - "os" - "runtime/debug" - - "git.nakama.town/fmartingr/butterrobot/internal/app" - "git.nakama.town/fmartingr/butterrobot/internal/config" - - _ "golang.org/x/crypto/x509roots/fallback" -) - -func main() { - // Initialize logger - logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) - - // Load configuration - cfg, err := config.Load() - if err != nil { - logger.Error("Failed to load configuration", "error", err) - os.Exit(1) - } - - // Handle version command - if len(os.Args) > 1 && os.Args[1] == "version" { - info, ok := debug.ReadBuildInfo() - if ok { - fmt.Printf("ButterRobot version %s\n", info.Main.Version) - } else { - fmt.Println("ButterRobot. Can't determine build information.") - } - return - } - - // Initialize and run application - application, err := app.New(cfg, logger) - if err != nil { - logger.Error("Failed to initialize application", "error", err) - os.Exit(1) - } - - if err := application.Run(); err != nil { - logger.Error("Application error", "error", err) - os.Exit(1) - } -} diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..398c43c --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,14 @@ +FROM alpine:3.11 + +ENV PYTHON_VERSION=3.8.2-r1 +ENV APP_PORT 8080 +ENV BUTTERROBOT_VERSION 0.0.2a3 +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"] diff --git a/docker/bin/start-server.sh b/docker/bin/start-server.sh new file mode 100755 index 0000000..d6be1ca --- /dev/null +++ b/docker/bin/start-server.sh @@ -0,0 +1,3 @@ +#!/bin/sh -xe + +hypercorn butterrobot.app -b "0.0.0.0:${APP_PORT}" diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index e1e0ef4..0000000 --- a/docs/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Butterrobot Documentation - -## Index -- [Contributing](./contributing.md) -- [Platforms](./platforms.md) -- Plugins - - [Creating a Plugin](./creating-a-plugin.md) - - [Provided plugins](./plugins.md) diff --git a/docs/contributing.md b/docs/contributing.md deleted file mode 100644 index 8c7ff76..0000000 --- a/docs/contributing.md +++ /dev/null @@ -1,29 +0,0 @@ -## 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 -``` diff --git a/docs/creating-a-plugin.md b/docs/creating-a-plugin.md deleted file mode 100644 index 469491a..0000000 --- a/docs/creating-a-plugin.md +++ /dev/null @@ -1,163 +0,0 @@ -# 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 - - // ... -} -``` diff --git a/docs/migrations.md b/docs/migrations.md deleted file mode 100644 index 65fcd99..0000000 --- a/docs/migrations.md +++ /dev/null @@ -1,99 +0,0 @@ -# Database Migrations - -ButterRobot uses a simple database migration system to manage database schema changes. This document explains how the migration system works and how to extend it. - -## Automatic Migrations - -Migrations in ButterRobot are applied automatically when the application starts. This ensures your database schema is always up to date without requiring manual intervention. - -The migration system: -1. Checks which migrations have been applied -2. Applies any pending migrations in sequential order -3. Records each successful migration in the `schema_migrations` table - -## Initial State - -The initial migration (version 1) sets up the database with the following: - -- `channels` table for chat platforms -- `channel_plugin` table for plugins associated with channels -- `users` table for admin users with bcrypt password hashing -- Default admin user with username "admin" and password "admin" - -This migration represents the current state of the database schema. It is not backwards compatible with previous versions of ButterRobot. - -## Creating New Migrations - -To add a new migration, follow these steps: - -1. Open `/internal/migration/migrations.go` -2. Add a new migration version in the `init()` function: - -```go -Register(2, "Add example table", migrateAddExampleTableUp, migrateAddExampleTableDown) -``` - -3. Implement the up and down functions for your migration: - -```go -// Migration to add example table - version 2 -func migrateAddExampleTableUp(db *sql.DB) error { - _, err := db.Exec(` - CREATE TABLE IF NOT EXISTS example ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP - ) - `) - return err -} - -func migrateAddExampleTableDown(db *sql.DB) error { - _, err := db.Exec(`DROP TABLE IF EXISTS example`) - return err -} -``` - -## Migration Guidelines - -1. **Incremental Changes**: Each migration should make a small, focused change to the database schema. -2. **Backward Compatibility**: Ensure migrations are backward compatible with existing code when possible. -3. **Test Thoroughly**: Test both up and down migrations before deploying. -4. **Document Changes**: Add comments explaining the purpose of each migration. -5. **Version Numbers**: Use sequential version numbers for migrations. - -## How Migrations Work - -The migration system tracks applied migrations in a `schema_migrations` table. When you run migrations, the system: - -1. Checks which migrations have been applied -2. Applies any pending migrations in order -3. Records each successful migration in the `schema_migrations` table - -When rolling back, it performs the down migrations in reverse order. - -## In Code Usage - -The application automatically runs pending migrations when starting up. This is done in the `initDatabase` function. - -You can also programmatically work with migrations: - -```go -// Get database instance -database, err := db.New(cfg.DatabasePath) -if err != nil { - // Handle error -} -defer database.Close() - -// Run migrations -if err := database.MigrateUp(); err != nil { - // Handle error -} - -// Check migration status -applied, pending, err := database.MigrationStatus() -if err != nil { - // Handle error -} -``` \ No newline at end of file diff --git a/docs/platforms.md b/docs/platforms.md deleted file mode 100644 index 0acbbe9..0000000 --- a/docs/platforms.md +++ /dev/null @@ -1,8 +0,0 @@ -## Supported platforms - -TODO: Create better actions matrix - -| Name | Receive messages | Send messages | -| --------------- | ---------------- | ------------- | -| Slack (app) | Yes | Yes | -| Telegram | Yes | Yes | diff --git a/docs/plugins.md b/docs/plugins.md deleted file mode 100644 index 84578e5..0000000 --- a/docs/plugins.md +++ /dev/null @@ -1,20 +0,0 @@ -## 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 ` to set a reminder. Supported duration units: y (years), mo (months), d (days), h (hours), m (minutes), s (seconds). Examples: `!remindme 1y` for 1 year, `!remindme 3mo` for 3 months, `!remindme 2d` for 2 days, `!remindme 3h` for 3 hours. The bot will mention you with a reminder after the specified time. - -### Social Media - -- Twitter Link Expander: Automatically converts twitter.com and x.com links to fxtwitter.com links and removes tracking parameters. This allows for better media embedding in chat platforms. -- Instagram Link Expander: Automatically converts instagram.com links to ddinstagram.com links and removes tracking parameters. This allows for better media embedding in chat platforms. diff --git a/go.mod b/go.mod deleted file mode 100644 index cd1bee5..0000000 --- a/go.mod +++ /dev/null @@ -1,24 +0,0 @@ -module git.nakama.town/fmartingr/butterrobot - -go 1.24 - -require ( - github.com/gorilla/sessions v1.4.0 - golang.org/x/crypto v0.37.0 - golang.org/x/crypto/x509roots/fallback v0.0.0-20250418111936-9c1aa6af88df - modernc.org/sqlite v1.37.0 -) - -require ( - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/securecookie v1.1.2 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/ncruces/go-strftime v0.1.9 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect - golang.org/x/sys v0.32.0 // indirect - modernc.org/libc v1.63.0 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.10.0 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 00c4a3c..0000000 --- a/go.sum +++ /dev/null @@ -1,57 +0,0 @@ -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= -github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= -github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= -github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= -golang.org/x/crypto/x509roots/fallback v0.0.0-20250418111936-9c1aa6af88df h1:SwgTucX8ajPE0La2ELpYOIs8jVMoCMpAvYB6mDqP9vk= -golang.org/x/crypto/x509roots/fallback v0.0.0-20250418111936-9c1aa6af88df/go.mod h1:lxN5T34bK4Z/i6cMaU7frUU57VkDXFD4Kamfl/cp9oU= -golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= -golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= -golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= -golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= -modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA= -modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc= -modernc.org/ccgo/v4 v4.26.0/go.mod h1:Sem8f7TFUtVXkG2fiaChQtyyfkqhJBg/zjEJBkmuAVY= -modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= -modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= -modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= -modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/libc v1.63.0 h1:wKzb61wOGCzgahQBORb1b0dZonh8Ufzl/7r4Yf1D5YA= -modernc.org/libc v1.63.0/go.mod h1:wDzH1mgz1wUIEwottFt++POjGRO9sgyQKrpXaz3x89E= -modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= -modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4= -modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= -modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= -modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= -modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= -modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/admin/admin.go b/internal/admin/admin.go deleted file mode 100644 index 69c769b..0000000 --- a/internal/admin/admin.go +++ /dev/null @@ -1,710 +0,0 @@ -package admin - -import ( - "embed" - "encoding/gob" - "fmt" - "html/template" - "net/http" - "strconv" - "strings" - - "git.nakama.town/fmartingr/butterrobot/internal/config" - "git.nakama.town/fmartingr/butterrobot/internal/db" - "git.nakama.town/fmartingr/butterrobot/internal/model" - "git.nakama.town/fmartingr/butterrobot/internal/plugin" - "github.com/gorilla/sessions" -) - -//go:embed templates/*.html -var templateFS embed.FS - -const ( - // Session store key - sessionKey = "butterrobot-session" -) - -// FlashMessage represents a flash message -type FlashMessage struct { - Category string - Message string -} - -func init() { - // Register the FlashMessage type with gob package for session serialization - gob.Register(FlashMessage{}) -} - -// TemplateData holds data for rendering templates -type TemplateData struct { - User *model.User - LoggedIn bool - Title string - Path string - Flash []FlashMessage - Plugins map[string]model.Plugin - Channels []*model.Channel - Channel *model.Channel - ChannelPlugin *model.ChannelPlugin - Version string -} - -// Admin represents the admin interface -type Admin struct { - config *config.Config - db *db.Database - store *sessions.CookieStore - templates map[string]*template.Template - baseTemplate *template.Template - version string -} - -// New creates a new Admin instance -func New(cfg *config.Config, database *db.Database, version string) *Admin { - // Create session store with appropriate options - store := sessions.NewCookieStore([]byte(cfg.SecretKey)) - store.Options = &sessions.Options{ - Path: "/admin", - MaxAge: 3600 * 24 * 7, // 1 week - HttpOnly: true, - } - - // Load templates - templates := make(map[string]*template.Template) - - // Create a template function map with helper functions - funcMap := template.FuncMap{ - "contains": strings.Contains, - } - - // Read base template from embedded filesystem - baseContent, err := templateFS.ReadFile("templates/_base.html") - if err != nil { - panic(err) - } - - // Create a custom template with functions - baseTemplate, err := template.New("_base.html").Funcs(funcMap).Parse(string(baseContent)) - if err != nil { - panic(err) - } - - // Parse and register all templates - templateFiles := []string{ - "index.html", - "login.html", - "change_password.html", - "channel_list.html", - "channel_detail.html", - "plugin_list.html", - "channel_plugins_list.html", - } - - for _, tf := range templateFiles { - // Read template content from embedded filesystem - content, err := templateFS.ReadFile("templates/" + tf) - if err != nil { - panic(err) - } - - // Create a clone of the base template - t, err := baseTemplate.Clone() - if err != nil { - panic(err) - } - - // Parse the template content - t, err = t.Parse(string(content)) - if err != nil { - panic(err) - } - - templates[tf] = t - } - - return &Admin{ - config: cfg, - db: database, - store: store, - templates: templates, - baseTemplate: baseTemplate, - version: version, - } -} - -// RegisterRoutes registers admin routes on the given router -func (a *Admin) RegisterRoutes(mux *http.ServeMux) { - // Register admin routes - mux.HandleFunc("/admin/", a.handleIndex) - mux.HandleFunc("/admin/login", a.handleLogin) - mux.HandleFunc("/admin/logout", a.handleLogout) - mux.HandleFunc("/admin/change-password", a.handleChangePassword) - mux.HandleFunc("/admin/plugins", a.handlePluginList) - mux.HandleFunc("/admin/channels", a.handleChannelList) - mux.HandleFunc("/admin/channels/", a.handleChannelDetail) - mux.HandleFunc("/admin/channelplugins", a.handleChannelPluginList) - mux.HandleFunc("/admin/channelplugins/", a.handleChannelPluginDetailOrDelete) -} - -// getCurrentUser gets the current user from the session -func (a *Admin) getCurrentUser(r *http.Request) *model.User { - session, err := a.store.Get(r, sessionKey) - if err != nil { - fmt.Printf("Error getting session for user retrieval: %v\n", err) - return nil - } - - // Check if user is logged in - userID, ok := session.Values["user_id"].(int64) - if !ok { - return nil - } - - // Get user from database - user, err := a.db.GetUserByID(userID) - if err != nil { - fmt.Printf("Error retrieving user from database: %v\n", err) - return nil - } - - return user -} - -// isLoggedIn checks if the user is logged in -func (a *Admin) isLoggedIn(r *http.Request) bool { - session, err := a.store.Get(r, sessionKey) - if err != nil { - fmt.Printf("Error getting session for login check: %v\n", err) - return false - } - return session.Values["logged_in"] == true -} - -// addFlash adds a flash message to the session -func (a *Admin) addFlash(w http.ResponseWriter, r *http.Request, message string, category string) { - session, err := a.store.Get(r, sessionKey) - if err != nil { - // If there's an error getting the session, create a new one - session = sessions.NewSession(a.store, sessionKey) - session.Options = &sessions.Options{ - Path: "/admin", - MaxAge: 3600 * 24 * 7, // 1 week - HttpOnly: true, - } - } - - // Map internal categories to Bootstrap alert classes - var alertClass string - switch category { - case "success": - alertClass = "success" - case "danger": - alertClass = "danger" - case "warning": - alertClass = "warning" - case "info": - alertClass = "info" - default: - alertClass = "info" - } - - flash := FlashMessage{ - Category: alertClass, - Message: message, - } - - session.AddFlash(flash) - err = session.Save(r, w) - if err != nil { - // Log the error or handle it appropriately - fmt.Printf("Error saving session: %v\n", err) - } -} - -// getFlashes gets all flash messages from the session -func (a *Admin) getFlashes(w http.ResponseWriter, r *http.Request) []FlashMessage { - session, err := a.store.Get(r, sessionKey) - if err != nil { - // If there's an error getting the session, return an empty slice - fmt.Printf("Error getting session for flashes: %v\n", err) - return []FlashMessage{} - } - - // Get flash messages - flashes := session.Flashes() - messages := make([]FlashMessage, 0, len(flashes)) - - for _, f := range flashes { - if flash, ok := f.(FlashMessage); ok { - messages = append(messages, flash) - } - } - - // Save session to clear flashes - err = session.Save(r, w) - if err != nil { - fmt.Printf("Error saving session after getting flashes: %v\n", err) - } - - return messages -} - -// render renders a template with the given data -func (a *Admin) render(w http.ResponseWriter, r *http.Request, templateName string, data TemplateData) { - // Add current user data - data.User = a.getCurrentUser(r) - data.LoggedIn = a.isLoggedIn(r) - data.Path = r.URL.Path - data.Flash = a.getFlashes(w, r) - data.Version = a.version - - // Get template - tmpl, ok := a.templates[templateName] - if !ok { - http.Error(w, "Template not found", http.StatusInternalServerError) - return - } - - // Render template - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := tmpl.Execute(w, data); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } -} - -// handleIndex handles the admin index route -func (a *Admin) handleIndex(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/admin/" { - http.NotFound(w, r) - return - } - - // Redirect to login if not logged in - if !a.isLoggedIn(r) { - http.Redirect(w, r, "/admin/login", http.StatusSeeOther) - return - } - - // Redirect to channel list - http.Redirect(w, r, "/admin/channels", http.StatusSeeOther) -} - -// handleLogin handles the login route -func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) { - // If already logged in, redirect to index - if a.isLoggedIn(r) { - http.Redirect(w, r, "/admin/", http.StatusSeeOther) - return - } - - // Handle login form submission - if r.Method == http.MethodPost { - // Parse form - if err := r.ParseForm(); err != nil { - http.Error(w, "Bad request", http.StatusBadRequest) - return - } - - // Check credentials - username := r.FormValue("username") - password := r.FormValue("password") - - user, err := a.db.CheckCredentials(username, password) - if err != nil || user == nil { - a.addFlash(w, r, "Incorrect credentials", "danger") - http.Redirect(w, r, "/admin/login", http.StatusSeeOther) - return - } - - // Set session - session, _ := a.store.Get(r, sessionKey) - session.Values["logged_in"] = true - session.Values["user_id"] = user.ID - - // Set session expiration - session.Options.MaxAge = 3600 * 24 * 7 // 1 week - err = session.Save(r, w) - if err != nil { - fmt.Printf("Error saving session: %v\n", err) - } - - a.addFlash(w, r, "You were logged in", "success") - - // Redirect to index - next := r.URL.Query().Get("next") - if next == "" { - next = "/admin/" - } - http.Redirect(w, r, next, http.StatusSeeOther) - return - } - - // Render login template - a.render(w, r, "login.html", TemplateData{ - Title: "Login", - }) -} - -// handleLogout handles the logout route -func (a *Admin) handleLogout(w http.ResponseWriter, r *http.Request) { - // Clear session - session, err := a.store.Get(r, sessionKey) - if err != nil { - fmt.Printf("Error getting session for logout: %v\n", err) - http.Redirect(w, r, "/admin/login", http.StatusSeeOther) - return - } - - session.Values = make(map[interface{}]interface{}) - session.Options.MaxAge = -1 // Delete session - err = session.Save(r, w) - if err != nil { - fmt.Printf("Error saving session for logout: %v\n", err) - } - - a.addFlash(w, r, "You were logged out", "success") - - // Redirect to login - http.Redirect(w, r, "/admin/login", http.StatusSeeOther) -} - -// handleChangePassword handles the change password route -func (a *Admin) handleChangePassword(w http.ResponseWriter, r *http.Request) { - // Check if user is logged in - if !a.isLoggedIn(r) { - http.Redirect(w, r, "/admin/login", http.StatusSeeOther) - return - } - - // Get current user - user := a.getCurrentUser(r) - if user == nil { - http.Redirect(w, r, "/admin/login", http.StatusSeeOther) - return - } - - // Handle form submission - if r.Method == http.MethodPost { - // Parse form - if err := r.ParseForm(); err != nil { - http.Error(w, "Bad request", http.StatusBadRequest) - return - } - - // Get form values - currentPassword := r.FormValue("current_password") - newPassword := r.FormValue("new_password") - confirmPassword := r.FormValue("confirm_password") - - // Validate current password - _, err := a.db.CheckCredentials(user.Username, currentPassword) - if err != nil { - a.addFlash(w, r, "Current password is incorrect", "danger") - http.Redirect(w, r, "/admin/change-password", http.StatusSeeOther) - return - } - - // Validate new password and confirmation - if newPassword == "" { - a.addFlash(w, r, "New password cannot be empty", "danger") - http.Redirect(w, r, "/admin/change-password", http.StatusSeeOther) - return - } - - if newPassword != confirmPassword { - a.addFlash(w, r, "New passwords do not match", "danger") - http.Redirect(w, r, "/admin/change-password", http.StatusSeeOther) - return - } - - // Update password - if err := a.db.UpdateUserPassword(user.ID, newPassword); err != nil { - a.addFlash(w, r, "Failed to update password: "+err.Error(), "danger") - http.Redirect(w, r, "/admin/change-password", http.StatusSeeOther) - return - } - - // Success - a.addFlash(w, r, "Password changed successfully", "success") - http.Redirect(w, r, "/admin/", http.StatusSeeOther) - return - } - - // Render change password template - a.render(w, r, "change_password.html", TemplateData{ - Title: "Change Password", - }) -} - -// handlePluginList handles the plugin list route -func (a *Admin) handlePluginList(w http.ResponseWriter, r *http.Request) { - // Check if user is logged in - if !a.isLoggedIn(r) { - http.Redirect(w, r, "/admin/login", http.StatusSeeOther) - return - } - - // Get available plugins - plugins := plugin.GetAvailablePlugins() - - // Render template - a.render(w, r, "plugin_list.html", TemplateData{ - Title: "Plugins", - Plugins: plugins, - }) -} - -// handleChannelList handles the channel list route -func (a *Admin) handleChannelList(w http.ResponseWriter, r *http.Request) { - // Check if user is logged in - if !a.isLoggedIn(r) { - http.Redirect(w, r, "/admin/login", http.StatusSeeOther) - return - } - - // Get all channels - channels, err := a.db.GetAllChannels() - if err != nil { - http.Error(w, "Failed to get channels", http.StatusInternalServerError) - return - } - - // Render template - a.render(w, r, "channel_list.html", TemplateData{ - Title: "Channels", - Channels: channels, - }) -} - -// handleChannelDetail handles the channel detail route -func (a *Admin) handleChannelDetail(w http.ResponseWriter, r *http.Request) { - // Check if user is logged in - if !a.isLoggedIn(r) { - http.Redirect(w, r, "/admin/login", http.StatusSeeOther) - return - } - - // Extract channel ID from path - path := r.URL.Path - if path == "/admin/channels/" { - http.Redirect(w, r, "/admin/channels", http.StatusSeeOther) - return - } - - channelID := strings.TrimPrefix(path, "/admin/channels/") - if strings.Contains(channelID, "/") { - // Handle delete request - if strings.HasSuffix(path, "/delete") && r.Method == http.MethodPost { - channelID = strings.TrimSuffix(channelID, "/delete") - - // Delete channel - id, err := strconv.ParseInt(channelID, 10, 64) - if err != nil { - http.Error(w, "Invalid channel ID", http.StatusBadRequest) - return - } - - if err := a.db.DeleteChannel(id); err != nil { - http.Error(w, "Failed to delete channel", http.StatusInternalServerError) - return - } - - a.addFlash(w, r, "Channel removed", "success") - http.Redirect(w, r, "/admin/channels", http.StatusSeeOther) - return - } - - http.NotFound(w, r) - return - } - - // Convert channel ID to int64 - id, err := strconv.ParseInt(channelID, 10, 64) - if err != nil { - http.Error(w, "Invalid channel ID", http.StatusBadRequest) - return - } - - // Handle form submission - if r.Method == http.MethodPost { - // Parse form - if err := r.ParseForm(); err != nil { - http.Error(w, "Bad request", http.StatusBadRequest) - return - } - - // Check if the form was submitted - if r.FormValue("form_submitted") == "true" { - // Update channel - enabled := r.FormValue("enabled") == "true" - if err := a.db.UpdateChannel(id, enabled); err != nil { - http.Error(w, "Failed to update channel", http.StatusInternalServerError) - return - } - - a.addFlash(w, r, "Channel updated", "success") - http.Redirect(w, r, "/admin/channels/"+channelID, http.StatusSeeOther) - return - } - } - - // Get channel - channel, err := a.db.GetChannelByID(id) - if err != nil { - http.Error(w, "Channel not found", http.StatusNotFound) - return - } - - // Get available plugins - plugins := plugin.GetAvailablePlugins() - - // Render template - a.render(w, r, "channel_detail.html", TemplateData{ - Title: "Channel: " + channel.PlatformChannelID, - Channel: channel, - Plugins: plugins, - }) -} - -// handleChannelPluginList handles the channel plugin list route -func (a *Admin) handleChannelPluginList(w http.ResponseWriter, r *http.Request) { - // Check if user is logged in - if !a.isLoggedIn(r) { - http.Redirect(w, r, "/admin/login", http.StatusSeeOther) - return - } - - // Handle form submission - if r.Method == http.MethodPost { - // Parse form - if err := r.ParseForm(); err != nil { - http.Error(w, "Bad request", http.StatusBadRequest) - return - } - - // Extract form data - channelID, err := strconv.ParseInt(r.FormValue("channel_id"), 10, 64) - if err != nil { - http.Error(w, "Invalid channel ID", http.StatusBadRequest) - return - } - - pluginID := r.FormValue("plugin_id") - enabled := r.FormValue("enabled") == "y" - - // Create channel plugin - config := make(map[string]interface{}) - _, err = a.db.CreateChannelPlugin(channelID, pluginID, enabled, config) - if err == db.ErrDuplicated { - a.addFlash(w, r, "Plugin "+pluginID+" is already present on the channel", "danger") - } else if err != nil { - http.Error(w, "Failed to create channel plugin", http.StatusInternalServerError) - return - } else { - a.addFlash(w, r, "Plugin "+pluginID+" added to the channel", "success") - } - - // Redirect back - referer := r.Header.Get("Referer") - if referer == "" { - referer = "/admin/channelplugins" - } - http.Redirect(w, r, referer, http.StatusSeeOther) - return - } - - // Get all channels - channels, err := a.db.GetAllChannels() - if err != nil { - http.Error(w, "Failed to get channels", http.StatusInternalServerError) - return - } - - // Render template - a.render(w, r, "channel_plugins_list.html", TemplateData{ - Title: "Channel Plugins", - Channels: channels, - Plugins: plugin.GetAvailablePlugins(), - }) -} - -// handleChannelPluginDetailOrDelete handles the channel plugin detail or delete route -func (a *Admin) handleChannelPluginDetailOrDelete(w http.ResponseWriter, r *http.Request) { - // Check if user is logged in - if !a.isLoggedIn(r) { - http.Redirect(w, r, "/admin/login", http.StatusSeeOther) - return - } - - // Extract channel plugin ID from path - path := r.URL.Path - if path == "/admin/channelplugins/" { - http.Redirect(w, r, "/admin/channelplugins", http.StatusSeeOther) - return - } - - channelPluginID := strings.TrimPrefix(path, "/admin/channelplugins/") - - // Handle delete request - if strings.HasSuffix(channelPluginID, "/delete") && r.Method == http.MethodPost { - channelPluginID = strings.TrimSuffix(channelPluginID, "/delete") - - // Delete channel plugin - id, err := strconv.ParseInt(channelPluginID, 10, 64) - if err != nil { - http.Error(w, "Invalid channel plugin ID", http.StatusBadRequest) - return - } - - if err := a.db.DeleteChannelPlugin(id); err != nil { - http.Error(w, "Failed to delete channel plugin", http.StatusInternalServerError) - return - } - - a.addFlash(w, r, "Plugin removed", "success") - - // Redirect back - referer := r.Header.Get("Referer") - if referer == "" { - referer = "/admin/channelplugins" - } - http.Redirect(w, r, referer, http.StatusSeeOther) - return - } - - // Handle update request - if r.Method == http.MethodPost { - // Parse form - if err := r.ParseForm(); err != nil { - http.Error(w, "Bad request", http.StatusBadRequest) - return - } - - // Convert channel plugin ID to int64 - id, err := strconv.ParseInt(channelPluginID, 10, 64) - if err != nil { - http.Error(w, "Invalid channel plugin ID", http.StatusBadRequest) - return - } - - // Update channel plugin - enabled := r.FormValue("enabled") == "true" - if err := a.db.UpdateChannelPlugin(id, enabled); err != nil { - http.Error(w, "Failed to update channel plugin", http.StatusInternalServerError) - return - } - - a.addFlash(w, r, "Plugin updated", "success") - - // Redirect back - referer := r.Header.Get("Referer") - if referer == "" { - referer = "/admin/channelplugins" - } - http.Redirect(w, r, referer, http.StatusSeeOther) - return - } - - // Redirect to channel plugins list - http.Redirect(w, r, "/admin/channelplugins", http.StatusSeeOther) -} diff --git a/internal/admin/templates/_base.html b/internal/admin/templates/_base.html deleted file mode 100644 index 3ebdf85..0000000 --- a/internal/admin/templates/_base.html +++ /dev/null @@ -1,138 +0,0 @@ - - - - - - - {{.Title}} - ButterRobot Admin - - - - -
-
- - {{if .LoggedIn}} - - {{end}} -
- -
- {{range .Flash}} - - {{end}} -
- -
-
- {{template "content" .}} -
-
- -
-
-
-
-
    -
  • - ButterRobot {{if .Version}}v{{.Version}}{{else}}(development){{end}} -
  • -
-
-
-
-
-
- - - - - \ No newline at end of file diff --git a/internal/admin/templates/change_password.html b/internal/admin/templates/change_password.html deleted file mode 100644 index eed3dc5..0000000 --- a/internal/admin/templates/change_password.html +++ /dev/null @@ -1,30 +0,0 @@ -{{define "content"}} -
-
-
-
-

Change Password

-
-
-
-
- - -
-
- - -
-
- - -
- -
-
-
-
-
-{{end}} \ No newline at end of file diff --git a/internal/admin/templates/channel_detail.html b/internal/admin/templates/channel_detail.html deleted file mode 100644 index 764d7b1..0000000 --- a/internal/admin/templates/channel_detail.html +++ /dev/null @@ -1,114 +0,0 @@ -{{define "content"}} -
-
-
-
-

Channel #{{.Channel.ID}}

-
-
-
-
- - -
-
- - -
-
- - -
-
- - - -
- -
-
-
-
-
-
-
-

Channel Plugins

-
-
-
- - - - - - - - - - {{range $pluginID, $channelPlugin := .Channel.Plugins}} - - - - - - {{else}} - - - - {{end}} - -
PluginEnabledActions
{{$pluginID}} - {{if $channelPlugin.Enabled}} - Enabled - {{else}} - Disabled - {{end}} - -
- - -
-
- -
-
No plugins for this channel
-
- -
- -

Add Plugin

-
- -
- - -
-
- -
- -
-
-
-
-
-{{end}} \ No newline at end of file diff --git a/internal/admin/templates/channel_list.html b/internal/admin/templates/channel_list.html deleted file mode 100644 index c13f2fc..0000000 --- a/internal/admin/templates/channel_list.html +++ /dev/null @@ -1,64 +0,0 @@ -{{define "content"}} -
-
-
-
-

Channels

-
-
-
- - - - - - - - - - - - - - {{range .Channels}} - - - - - - - - - - {{else}} - - - - {{end}} - -
IDPlatformChannel IDNameEnabledPluginsActions
{{.ID}}{{.Platform}}{{.PlatformChannelID}}{{.ChannelName}} - {{if .Enabled}} - Enabled - {{else}} - Disabled - {{end}} - - {{$count := len .Plugins}} - {{if eq $count 0}} - No plugins - {{else}} - {{$count}} plugins - {{end}} - - Edit -
- -
-
No channels found
-
-
-
-
-
-{{end}} \ No newline at end of file diff --git a/internal/admin/templates/channel_plugins_list.html b/internal/admin/templates/channel_plugins_list.html deleted file mode 100644 index b57c60e..0000000 --- a/internal/admin/templates/channel_plugins_list.html +++ /dev/null @@ -1,93 +0,0 @@ -{{define "content"}} -
-
-
-
-

Channel Plugins

-
-
-
- - - - - - - - - - - - {{range .Channels}} - {{range $pluginID, $channelPlugin := .Plugins}} - - - - - - - - {{end}} - {{else}} - - - - {{end}} - -
IDChannelPluginEnabledActions
{{$channelPlugin.ID}}{{.ChannelName}}{{$pluginID}} - {{if $channelPlugin.Enabled}} - Enabled - {{else}} - Disabled - {{end}} - -
- - -
-
- -
-
No channel plugins found
-
- -
- -

Add Plugin to Channel

-
-
- - -
-
- - -
-
- -
- -
-
-
-
-
-{{end}} \ No newline at end of file diff --git a/internal/admin/templates/index.html b/internal/admin/templates/index.html deleted file mode 100644 index c352721..0000000 --- a/internal/admin/templates/index.html +++ /dev/null @@ -1,15 +0,0 @@ -{{define "content"}} -
-
-
-
-

ButterRobot Admin

-
-
-

Welcome to the ButterRobot admin interface.

-

Use the navigation above to manage channels and plugins.

-
-
-
-
-{{end}} \ No newline at end of file diff --git a/internal/admin/templates/login.html b/internal/admin/templates/login.html deleted file mode 100644 index 2484a5c..0000000 --- a/internal/admin/templates/login.html +++ /dev/null @@ -1,26 +0,0 @@ -{{define "content"}} -
-
-
-
-

Login

-
-
-
-
- - -
-
- - -
- -
-
-
-
-
-{{end}} \ No newline at end of file diff --git a/internal/admin/templates/plugin_list.html b/internal/admin/templates/plugin_list.html deleted file mode 100644 index bbb860c..0000000 --- a/internal/admin/templates/plugin_list.html +++ /dev/null @@ -1,45 +0,0 @@ -{{define "content"}} -
-
-
-
-

Available Plugins

-
-
-
- - - - - - - - - - - {{range $id, $plugin := .Plugins}} - - - - - - - {{else}} - - - - {{end}} - -
IDNameHelpRequires Config
{{$id}}{{$plugin.GetName}}{{$plugin.GetHelp}} - {{if $plugin.RequiresConfig}} - Yes - {{else}} - No - {{end}} -
No plugins found
-
-
-
-
-
-{{end}} \ No newline at end of file diff --git a/internal/app/app.go b/internal/app/app.go deleted file mode 100644 index 7403396..0000000 --- a/internal/app/app.go +++ /dev/null @@ -1,393 +0,0 @@ -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) - } -} diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index b10f653..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,59 +0,0 @@ -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 -} diff --git a/internal/db/db.go b/internal/db/db.go deleted file mode 100644 index bdf9eaf..0000000 --- a/internal/db/db.go +++ /dev/null @@ -1,777 +0,0 @@ -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 -} diff --git a/internal/migration/migration.go b/internal/migration/migration.go deleted file mode 100644 index 63da5d8..0000000 --- a/internal/migration/migration.go +++ /dev/null @@ -1,223 +0,0 @@ -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 -} diff --git a/internal/migration/migrations.go b/internal/migration/migrations.go deleted file mode 100644 index 8db229b..0000000 --- a/internal/migration/migrations.go +++ /dev/null @@ -1,128 +0,0 @@ -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 -} diff --git a/internal/model/message.go b/internal/model/message.go deleted file mode 100644 index e6f86f6..0000000 --- a/internal/model/message.go +++ /dev/null @@ -1,101 +0,0 @@ -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 -} diff --git a/internal/model/platform.go b/internal/model/platform.go deleted file mode 100644 index 01318eb..0000000 --- a/internal/model/platform.go +++ /dev/null @@ -1,46 +0,0 @@ -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 -} diff --git a/internal/model/plugin.go b/internal/model/plugin.go deleted file mode 100644 index 9f2b34a..0000000 --- a/internal/model/plugin.go +++ /dev/null @@ -1,28 +0,0 @@ -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 -} diff --git a/internal/platform/init.go b/internal/platform/init.go deleted file mode 100644 index 63625ca..0000000 --- a/internal/platform/init.go +++ /dev/null @@ -1,32 +0,0 @@ -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 -} diff --git a/internal/platform/registry.go b/internal/platform/registry.go deleted file mode 100644 index b6ce05c..0000000 --- a/internal/platform/registry.go +++ /dev/null @@ -1,49 +0,0 @@ -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 -} diff --git a/internal/platform/slack/slack.go b/internal/platform/slack/slack.go deleted file mode 100644 index 9c12b1f..0000000 --- a/internal/platform/slack/slack.go +++ /dev/null @@ -1,220 +0,0 @@ -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 -} diff --git a/internal/platform/telegram/telegram.go b/internal/platform/telegram/telegram.go deleted file mode 100644 index 0edb729..0000000 --- a/internal/platform/telegram/telegram.go +++ /dev/null @@ -1,278 +0,0 @@ -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 -} diff --git a/internal/plugin/fun/coin.go b/internal/plugin/fun/coin.go deleted file mode 100644 index 8e12a8d..0000000 --- a/internal/plugin/fun/coin.go +++ /dev/null @@ -1,50 +0,0 @@ -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} -} diff --git a/internal/plugin/fun/dice.go b/internal/plugin/fun/dice.go deleted file mode 100644 index 2d5533b..0000000 --- a/internal/plugin/fun/dice.go +++ /dev/null @@ -1,119 +0,0 @@ -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 -} diff --git a/internal/plugin/fun/loquito.go b/internal/plugin/fun/loquito.go deleted file mode 100644 index 7b0ea43..0000000 --- a/internal/plugin/fun/loquito.go +++ /dev/null @@ -1,40 +0,0 @@ -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} -} diff --git a/internal/plugin/ping/ping.go b/internal/plugin/ping/ping.go deleted file mode 100644 index b09caaf..0000000 --- a/internal/plugin/ping/ping.go +++ /dev/null @@ -1,40 +0,0 @@ -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} -} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go deleted file mode 100644 index 69da2c2..0000000 --- a/internal/plugin/plugin.go +++ /dev/null @@ -1,82 +0,0 @@ -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 -} diff --git a/internal/plugin/reminder/reminder.go b/internal/plugin/reminder/reminder.go deleted file mode 100644 index 5eb47f9..0000000 --- a/internal/plugin/reminder/reminder.go +++ /dev/null @@ -1,171 +0,0 @@ -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 ` 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, - }, - } -} diff --git a/internal/plugin/reminder/reminder_test.go b/internal/plugin/reminder/reminder_test.go deleted file mode 100644 index 3070918..0000000 --- a/internal/plugin/reminder/reminder_test.go +++ /dev/null @@ -1,164 +0,0 @@ -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) - } - }) - } -} diff --git a/internal/plugin/social/instagram.go b/internal/plugin/social/instagram.go deleted file mode 100644 index 7ff74a5..0000000 --- a/internal/plugin/social/instagram.go +++ /dev/null @@ -1,74 +0,0 @@ -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} -} diff --git a/internal/plugin/social/twitter.go b/internal/plugin/social/twitter.go deleted file mode 100644 index 837b6c9..0000000 --- a/internal/plugin/social/twitter.go +++ /dev/null @@ -1,79 +0,0 @@ -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} -} diff --git a/internal/queue/queue.go b/internal/queue/queue.go deleted file mode 100644 index 692816e..0000000 --- a/internal/queue/queue.go +++ /dev/null @@ -1,161 +0,0 @@ -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 - } - } -} diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..85c3ddf --- /dev/null +++ b/poetry.lock @@ -0,0 +1,958 @@ +[[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"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4db62fb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,35 @@ +[tool.poetry] +name = "butterrobot" +version = "0.0.2a3" +description = "What is my purpose?" +authors = ["Felipe Martin "] +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" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..007f4ac --- /dev/null +++ b/setup.cfg @@ -0,0 +1,16 @@ +[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