mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-03-29 11:29:51 +00:00
- Updated `.gitignore` to include local configuration and plugin directories, allowing users to add custom commands and services without modifying core code. - Enhanced `config.ini.example` with instructions for using local plugins and added sections for local service configurations. - Refactored `PluginLoader` and `ServicePluginLoader` to support loading local commands and services from specified directories, improving extensibility. - Updated `mkdocs.yml` to include documentation for local plugins and the check-in API. - Added tests to verify the discovery and loading of local plugins, ensuring functionality and preventing name collisions with built-in plugins.
201 lines
6.0 KiB
Python
201 lines
6.0 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Check-in API receiver — stdlib-only HTTP server for the meshcore-bot Check-in API.
|
|
|
|
Implements the contract in docs/checkin-api.md: POST JSON with Bearer auth,
|
|
upsert into SQLite by packet_hash. Run behind nginx with TLS.
|
|
|
|
Environment:
|
|
CHECKIN_API_SECRET Required. Bearer token; must match bot [CheckIn] api_key.
|
|
CHECKIN_PORT Port to bind (default 9999).
|
|
CHECKIN_DB_PATH SQLite file path (default ./checkins.db). Parent dir created if missing.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import secrets
|
|
import sqlite3
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
from typing import Optional
|
|
from urllib.parse import urlparse
|
|
|
|
REQUIRED_FIELDS = ("packet_hash", "username", "message", "channel", "timestamp")
|
|
DEFAULT_PORT = 9999
|
|
DEFAULT_DB = "checkins.db"
|
|
|
|
SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS checkins (
|
|
packet_hash TEXT PRIMARY KEY,
|
|
username TEXT NOT NULL,
|
|
message TEXT NOT NULL,
|
|
channel TEXT NOT NULL,
|
|
timestamp TEXT NOT NULL,
|
|
source_bot TEXT,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
"""
|
|
|
|
|
|
def get_env(key: str, default: str = "") -> str:
|
|
return os.environ.get(key, default).strip()
|
|
|
|
|
|
def init_db(db_path: str) -> None:
|
|
parent = os.path.dirname(db_path)
|
|
if parent:
|
|
os.makedirs(parent, exist_ok=True)
|
|
with sqlite3.connect(db_path) as conn:
|
|
conn.executescript(SCHEMA)
|
|
conn.commit()
|
|
|
|
|
|
def upsert_checkin(db_path: str, data: dict) -> None:
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
with sqlite3.connect(db_path) as conn:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO checkins (
|
|
packet_hash, username, message, channel, timestamp, source_bot, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(packet_hash) DO UPDATE SET
|
|
username = excluded.username,
|
|
message = excluded.message,
|
|
channel = excluded.channel,
|
|
timestamp = excluded.timestamp,
|
|
source_bot = excluded.source_bot,
|
|
updated_at = excluded.updated_at
|
|
""",
|
|
(
|
|
data["packet_hash"],
|
|
data["username"],
|
|
data["message"],
|
|
data["channel"],
|
|
data["timestamp"],
|
|
data.get("source_bot") or "",
|
|
now,
|
|
),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
class CheckinHandler(BaseHTTPRequestHandler):
|
|
protocol_version = "HTTP/1.1"
|
|
|
|
def _secret(self) -> str:
|
|
return get_env("CHECKIN_API_SECRET")
|
|
|
|
def _db_path(self) -> str:
|
|
return get_env("CHECKIN_DB_PATH") or DEFAULT_DB
|
|
|
|
def _send(self, code: int, body: str = "", content_type: str = "application/json") -> None:
|
|
self.send_response(code)
|
|
if body:
|
|
self.send_header("Content-Type", content_type)
|
|
self.send_header("Content-Length", str(len(body.encode("utf-8"))))
|
|
self.end_headers()
|
|
if body:
|
|
self.wfile.write(body.encode("utf-8"))
|
|
|
|
def _bearer_token(self) -> Optional[str]:
|
|
auth = self.headers.get("Authorization") or ""
|
|
if auth.startswith("Bearer "):
|
|
return auth[7:].strip()
|
|
return None
|
|
|
|
def _read_body(self) -> bytes:
|
|
length = self.headers.get("Content-Length")
|
|
if length is None:
|
|
return b""
|
|
try:
|
|
n = int(length, 10)
|
|
except ValueError:
|
|
return b""
|
|
if n <= 0 or n > 4096:
|
|
return b""
|
|
return self.rfile.read(n)
|
|
|
|
def do_GET(self) -> None:
|
|
parsed = urlparse(self.path)
|
|
if parsed.path in ("/", "/checkins"):
|
|
self._send(200, '{"status":"ok"}')
|
|
else:
|
|
self._send(404, '{"error":"not found"}')
|
|
|
|
def do_POST(self) -> None:
|
|
parsed = urlparse(self.path)
|
|
if parsed.path != "/" and parsed.path != "/checkins":
|
|
self._send(404, '{"error":"not found"}')
|
|
return
|
|
|
|
secret = self._secret()
|
|
if not secret:
|
|
self._send(500, '{"error":"server misconfiguration: CHECKIN_API_SECRET not set"}')
|
|
return
|
|
|
|
token = self._bearer_token()
|
|
if token is None or not secrets.compare_digest(secret, token):
|
|
self._send(401, '{"error":"unauthorized"}')
|
|
return
|
|
|
|
raw = self._read_body()
|
|
try:
|
|
data = json.loads(raw.decode("utf-8"))
|
|
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
logging.warning("CheckinReceiver: invalid JSON: %s", e)
|
|
self._send(400, '{"error":"invalid json"}')
|
|
return
|
|
|
|
if not isinstance(data, dict):
|
|
self._send(400, '{"error":"body must be a json object"}')
|
|
return
|
|
|
|
missing = [f for f in REQUIRED_FIELDS if not data.get(f)]
|
|
if missing:
|
|
self._send(400, json.dumps({"error": "missing fields", "fields": missing}))
|
|
return
|
|
|
|
db_path = self._db_path()
|
|
try:
|
|
init_db(db_path)
|
|
upsert_checkin(db_path, data)
|
|
except Exception as e:
|
|
logging.exception("CheckinReceiver: db error: %s", e)
|
|
self._send(500, '{"error":"internal error"}')
|
|
return
|
|
|
|
self._send(201, "{}")
|
|
|
|
def log_message(self, format: str, *args: object) -> None:
|
|
logging.info("%s - %s", self.address_string(), format % args)
|
|
|
|
|
|
def main() -> int:
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(message)s",
|
|
stream=sys.stderr,
|
|
)
|
|
port = DEFAULT_PORT
|
|
try:
|
|
p = get_env("CHECKIN_PORT")
|
|
if p:
|
|
port = int(p, 10)
|
|
except ValueError:
|
|
pass
|
|
|
|
server_address = ("127.0.0.1", port)
|
|
httpd = HTTPServer(server_address, CheckinHandler)
|
|
logging.info("CheckinReceiver listening on http://%s:%s", server_address[0], server_address[1])
|
|
try:
|
|
httpd.serve_forever()
|
|
except KeyboardInterrupt:
|
|
pass
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|