feat: remove kivy frontend, add litestar

This commit is contained in:
cătălin 2024-12-12 12:22:34 +01:00
commit 6b873348c7
No known key found for this signature in database
48 changed files with 3092 additions and 800 deletions

62
src/huesoporro/chatbot.py Normal file
View file

@ -0,0 +1,62 @@
import asyncio
from asyncio import sleep as asleep
from queue import Queue
from time import sleep
import nltk
from litestar import WebSocket
from loguru import logger
from src.huesoporro.libs.markov_chain_bot import MarkovChain
from src.huesoporro.libs.settings import Settings as MarkovChainSettings
from src.huesoporro.value_objects import WebsocketCommands, WebsocketMessage
nltk.download("punkt_tab")
class ChatbotManager:
def __init__(self):
self.bot: MarkovChain | None = None
self.clients: set[WebSocket] = set()
self.log_queue: Queue = Queue()
self.tasks: set = set()
def start_bot(
self,
channel_name: str,
nickname: str,
authentication: str,
):
task = asyncio.create_task(self.send_bot_status())
self.tasks.add(task)
if self.bot:
return
self.bot = MarkovChain(
settings=MarkovChainSettings(
Channel=channel_name,
Nickname=nickname,
Authentication=authentication,
AutomaticGenerationTimer=300,
),
)
self.bot.run_bot()
sleep(2)
def stop_bot(self):
self.bot.stop_bot()
self.bot = None
async def send_bot_status(self):
while True:
for client in self.clients:
message = WebsocketMessage(
command=WebsocketCommands.CHATBOT_STATUS,
data={"status": "ok" if self.bot else "ko"},
)
await client.send_text(message.model_dump_json())
logger.info(
f"Sending bot status {message} to {client.client.host}:{client.client.port}"
)
await asleep(2)

View file

@ -88,7 +88,7 @@ class Database:
def __init__(self, channel: str):
self.user_data_path = platformdirs.user_data_path(
"markovbot_gui",
"huesoporro",
ensure_exists=True,
)
self.db_path = (
@ -191,10 +191,11 @@ class Database:
fetch=True,
):
logger.info("Creating backup before updating Database...")
# Connect to both the new and backup, backup, and close both
def progress(status, remaining, total):
logging.debug(f"Copied {total-remaining} of {total} pages...")
logging.debug(f"Copied {total - remaining} of {total} pages...")
conn = sqlite3.connect(f"MarkovChain_{channel.replace('#', '').lower()}.db")
back_conn = sqlite3.connect(
@ -356,7 +357,7 @@ class Database:
from nltk import ngrams
from src.markovbot_gui.libs.tokenizer import tokenize
from src.huesoporro.libs.tokenizer import tokenize
channel = channel.replace("#", "").lower()
copyfile(

View file

@ -6,10 +6,10 @@ from loguru import logger
from nltk.tokenize import sent_tokenize
from TwitchWebsocket import Message, TwitchWebsocket
from src.markovbot_gui.libs.db import Database
from src.markovbot_gui.libs.settings import Settings
from src.markovbot_gui.libs.timer import LoopingTimer
from src.markovbot_gui.libs.tokenizer import detokenize, tokenize
from src.huesoporro.libs.db import Database
from src.huesoporro.libs.settings import Settings
from src.huesoporro.libs.timer import LoopingTimer
from src.huesoporro.libs.tokenizer import detokenize, tokenize
class Commands(StrEnum):
@ -68,11 +68,12 @@ class MarkovChain:
)
def run_bot(self):
self.ws.start_bot()
self.ws.start_nonblocking()
def stop_bot(self):
self.ws.leave_channel(self.s.channel_name)
self.ws.stop()
logger.info("Stopped bot")
def _command_help(self) -> None:
"""Send a Help message to the connected chat, as long as the bot wasn't disabled."""
@ -249,6 +250,28 @@ class MarkovChain:
def message_handler(self, message: Message): # noqa: C901, PLR0911, PLR0912
try:
"""
tts_message = {
"badge-info": "subscriber/4",
"badges": "vip/1,subscriber/3,sub-gifter/5",
"color": "#F79AC6",
"custom-reward-id": "8c454446-73b0-480f-946e-d6b5f5c5e331",
"display-name": "robosap1ens__",
"emotes": "",
"first-msg": "0",
"flags": "",
"id": "6cbd37eb-49ae-41f5-b073-345275c91a07",
"mod": "0",
"returning-chatter": "0",
"room-id": "600944302",
"subscriber": "1",
"tmi-sent-ts": "1733252657689",
"turbo": "0",
"user-id": "713968248",
"user-type": "",
"vip": "1",
}
"""
if not message.user or message.user in self.s.denied_users:
logger.debug(f"User {message.user} can't send messages")
return

224
src/huesoporro/main.py Normal file
View file

@ -0,0 +1,224 @@
import json
import secrets
from json import JSONDecodeError
import httpx
import uvicorn
from litestar import Litestar, MediaType, Request, Response, WebSocket, get
from litestar.connection import ASGIConnection
from litestar.contrib.jinja import JinjaTemplateEngine
from litestar.datastructures.state import State
from litestar.di import Provide
from litestar.exceptions import HTTPException
from litestar.handlers import BaseRouteHandler, WebsocketListener
from litestar.response import Redirect, Template
from litestar.static_files import StaticFilesConfig
from litestar.status_codes import HTTP_500_INTERNAL_SERVER_ERROR
from litestar.template import TemplateConfig
from loguru import logger
from src.huesoporro.chatbot import ChatbotManager
from src.huesoporro.settings import Settings
from src.huesoporro.tts import TTSManager
from src.huesoporro.value_objects import WebsocketCommands, WebsocketMessage
async def _authenticate(access_token: str):
s = Settings.get()
client = httpx.AsyncClient(
base_url="https://id.twitch.tv",
)
resp = await client.get(
"/oauth2/validate", headers={"Authorization": f"OAuth {access_token}"}
)
user_data = resp.json()
if user_data.get("status"):
raise HTTPException(status_code=401, detail="Unauthorized")
if (user := user_data["login"]) not in s.allowed_users:
raise HTTPException(status_code=403, detail="Forbidden")
return user
async def authenticate(
connection: ASGIConnection, route_handler: BaseRouteHandler
) -> None:
"""Extract cookie from connection and try to authenticate"""
try:
login_data = json.loads(connection.cookies.get("twitchLoginData"))
except (JSONDecodeError, TypeError) as exc:
logger.warning(f"Error parsing twitch login data: {exc}")
raise HTTPException(status_code=401, detail="Unauthorized") from exc
access_token = login_data.get("access_token")
if not login_data or not access_token:
raise HTTPException(status_code=401, detail="Unauthorized")
user = await _authenticate(access_token)
connection.state["user"] = user
connection.state["access_token"] = access_token
class WebsocketHandler(WebsocketListener):
path = "/ws"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.tts_manager = TTSManager()
self.chatbot_manager = ChatbotManager()
self.user = None
self.access_token = None
async def on_accept(self, socket: WebSocket, state: State) -> None:
"""If the authentication is correct, add the manager's clients list"""
cookies = socket.cookies.get("twitchLoginData")
try:
access_token = json.loads(cookies).get("access_token")
except (JSONDecodeError, TypeError) as exc:
logger.warning(f"Error parsing twitch login data {exc}")
return
if not access_token:
return
user = await _authenticate(access_token)
self.user = user
self.access_token = access_token
self.chatbot_manager.clients.add(socket)
self.tts_manager.clients.append(socket)
logger.info(
f"Connection accepted from {socket.client.host}:{socket.client.port}" # type: ignore[union-attr]
)
async def on_disconnect(self, socket: WebSocket) -> None:
# Remove client from the list
if socket in self.tts_manager.clients:
self.tts_manager.clients.remove(socket)
self.chatbot_manager.clients.remove(socket)
logger.info(f"Connection closed by {socket.client.host}:{socket.client.port}") # type: ignore[union-attr]
async def on_receive(self, data: str, state: State) -> None:
message = WebsocketMessage(**json.loads(data))
logger.info(f"Received {message.command.value} command")
match message.command:
case WebsocketCommands.TTS_SEND:
await self.tts_manager.add_to_queue(**message.data)
case WebsocketCommands.CHATBOT_START:
self.chatbot_manager.start_bot(
**message.data
| {
"nickname": self.user,
"authentication": f"oauth:{self.access_token}",
},
)
case WebsocketCommands.CHATBOT_STOP:
self.chatbot_manager.stop_bot()
@get(
"/tts",
media_type=MediaType.HTML,
guards=[authenticate],
)
async def get_tts_overlay() -> Template:
return Template(template_name="tts.html")
@get(
"/",
media_type=MediaType.HTML,
guards=[authenticate],
)
async def get_index() -> Template:
return Template(
template_name="index.html",
)
@get("/login", media_type=MediaType.HTML, dependencies={"s": Provide(Settings.get)})
async def login(s: Settings) -> Template:
scopes = "+".join(s.twitch_scopes)
return Template(
"login.html",
context={
"twitch_login_url": "https://id.twitch.tv/oauth2/authorize?response_type=token"
f"&client_id={s.twitch_client_id}"
f"&redirect_uri={s.server_hostname}login"
f"&scope={scopes}"
f"&state={secrets.token_urlsafe(32)}"
},
)
@get("/healthz")
def get_health() -> dict:
return {"status": "ok"}
def exception_handler(_: Request, exc: Exception) -> Response:
status_code = getattr(exc, "status_code", HTTP_500_INTERNAL_SERVER_ERROR)
detail = getattr(exc, "detail", "")
if isinstance(exc, HTTPException) and (exc.status_code in [401, 403]):
logger.warning("User could not authenticate. Redirecting to /login page")
return Redirect("/login")
return Response(
media_type=MediaType.TEXT,
content=detail,
status_code=status_code,
)
async def after_exception_handler(exc: Exception, scope: "Scope") -> None:
"""Hook function that will be invoked after each exception."""
state = scope["app"].state
if not hasattr(state, "error_count"):
state.error_count = 1
else:
state.error_count += 1
logger.error(
f"an exception of type {type(exc).__name__} has occurred for requested path {scope['path']} and the application error count is {state.error_count}.",
)
def create_app():
return Litestar(
route_handlers=[
get_health,
login,
get_index,
get_tts_overlay,
WebsocketHandler,
],
static_files_config=(
StaticFilesConfig(
path="/tts_files",
directories=[Settings.get().tts_cache_path],
),
StaticFilesConfig(
path="static",
directories=[Settings.get().static_files_path],
),
),
template_config=TemplateConfig(
directory=Settings.get().templates_files_path,
engine=JinjaTemplateEngine,
),
exception_handlers={HTTPException: exception_handler},
after_exception=[after_exception_handler],
)
if __name__ == "__main__":
settings = Settings.get()
app = create_app()
uvicorn.run(app, host=settings.host, port=settings.port)

View file

@ -0,0 +1,48 @@
from functools import lru_cache
from pathlib import Path
from pydantic import Field, HttpUrl, field_validator
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
port: int = 8000
host: str = "0.0.0.0" # noqa: S104
static_files_path: Path = Field(
default_factory=lambda: Path(__file__).parent / "static"
)
templates_files_path: Path = Field(
default_factory=lambda: Path(__file__).parent / "templates"
)
tts_cache_path: Path = Field(
default_factory=lambda: Path(__file__).parent / "tts_files"
)
db_filepath: Path = Field(
default_factory=lambda: Path(__file__).parent / "huesoporro.db"
)
twitch_client_id: str
twitch_scopes: list[str] = Field(
default_factory=lambda: ["channel:bot", "chat:edit", "chat:read"]
)
allowed_users: list[str] | str = Field(default_factory=lambda: ["huesoporro"])
server_hostname: HttpUrl = "http://localhost:8000"
@staticmethod
@lru_cache(maxsize=1)
def get():
return Settings() # type: ignore[call-arg] # pydantic-setting magic
@field_validator("allowed_users")
@classmethod
def validate_allowed_users(cls, value: list[str] | str):
# Convert string to list if necessary
if isinstance(value, str):
value = value.split(",")
return value
@field_validator("tts_cache_path")
@classmethod
def validate_tts_cache_path(cls, value: Path):
# create path if it doesn't exist
value.mkdir(parents=True, exist_ok=True)
return value

View file

@ -0,0 +1,594 @@
/* MVP.css v1.17 - https://github.com/andybrewer/mvp */
:root {
--active-brightness: 0.85;
--border-radius: 5px;
--box-shadow: 2px 2px 10px;
--color-accent: #118bee15;
--color-bg: #fff;
--color-bg-secondary: #e9e9e9;
--color-link: #118bee;
--color-secondary: #920de9;
--color-secondary-accent: #920de90b;
--color-shadow: #f4f4f4;
--color-table: #118bee;
--color-text: #000;
--color-text-secondary: #999;
--color-scrollbar: #cacae8;
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
--hover-brightness: 1.2;
--justify-important: center;
--justify-normal: left;
--line-height: 1.5;
--width-card: 285px;
--width-card-medium: 460px;
--width-card-wide: 800px;
--width-content: 1080px;
}
@media (prefers-color-scheme: dark) {
:root[color-mode="user"] {
--color-accent: #0097fc4f;
--color-bg: #333;
--color-bg-secondary: #555;
--color-link: #0097fc;
--color-secondary: #e20de9;
--color-secondary-accent: #e20de94f;
--color-shadow: #bbbbbb20;
--color-table: #0097fc;
--color-text: #f7f7f7;
--color-text-secondary: #aaa;
}
}
html {
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
/* Layout */
article aside {
background: var(--color-secondary-accent);
border-left: 4px solid var(--color-secondary);
padding: 0.01rem 0.8rem;
}
body {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-family);
line-height: var(--line-height);
margin: 0;
overflow-x: hidden;
padding: 0;
}
footer,
header,
main {
margin: 0 auto;
max-width: var(--width-content);
padding: 3rem 1rem;
}
hr {
background-color: var(--color-bg-secondary);
border: none;
height: 1px;
margin: 4rem 0;
width: 100%;
}
section {
display: flex;
flex-wrap: wrap;
justify-content: var(--justify-important);
}
section img,
article img {
max-width: 100%;
}
section pre {
overflow: auto;
}
section aside {
border: 1px solid var(--color-bg-secondary);
border-radius: var(--border-radius);
box-shadow: var(--box-shadow) var(--color-shadow);
margin: 1rem;
padding: 1.25rem;
width: var(--width-card);
}
section aside:hover {
box-shadow: var(--box-shadow) var(--color-bg-secondary);
}
[hidden] {
display: none;
}
/* Headers */
article header,
div header,
main header {
padding-top: 0;
}
header {
text-align: var(--justify-important);
}
header a b,
header a em,
header a i,
header a strong {
margin-left: 0.5rem;
margin-right: 0.5rem;
}
header nav img {
margin: 1rem 0;
}
section header {
padding-top: 0;
width: 100%;
}
/* Nav */
nav {
align-items: center;
display: flex;
font-weight: bold;
justify-content: space-between;
margin-bottom: 7rem;
}
nav ul {
list-style: none;
padding: 0;
}
nav ul li {
display: inline-block;
margin: 0 0.5rem;
position: relative;
text-align: left;
}
/* Nav Dropdown */
nav ul li:hover ul {
display: block;
}
nav ul li ul {
background: var(--color-bg);
border: 1px solid var(--color-bg-secondary);
border-radius: var(--border-radius);
box-shadow: var(--box-shadow) var(--color-shadow);
display: none;
height: auto;
left: -2px;
padding: 0.5rem 1rem;
position: absolute;
top: 1.7rem;
white-space: nowrap;
width: auto;
z-index: 1;
}
nav ul li ul::before {
/* fill gap above to make mousing over them easier */
content: "";
position: absolute;
left: 0;
right: 0;
top: -0.5rem;
height: 0.5rem;
}
nav ul li ul li,
nav ul li ul li a {
display: block;
}
/* Nav for Mobile */
@media (max-width: 768px) {
nav {
flex-wrap: wrap;
}
nav ul li {
width: calc(100% - 1em);
}
nav ul li ul {
border: none;
box-shadow: none;
display: block;
position: static;
}
}
/* Typography */
code,
samp {
background-color: var(--color-accent);
border-radius: var(--border-radius);
color: var(--color-text);
display: inline-block;
margin: 0 0.1rem;
padding: 0 0.5rem;
}
details {
margin: 1.3rem 0;
}
details summary {
font-weight: bold;
cursor: pointer;
}
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: var(--line-height);
text-wrap: balance;
}
mark {
padding: 0.1rem;
}
ol li,
ul li {
padding: 0.2rem 0;
}
p {
margin: 0.75rem 0;
padding: 0;
width: 100%;
}
pre {
margin: 1rem 0;
max-width: var(--width-card-wide);
padding: 1rem 0;
}
pre code,
pre samp {
display: block;
max-width: var(--width-card-wide);
padding: 0.5rem 2rem;
white-space: pre-wrap;
}
small {
color: var(--color-text-secondary);
}
sup {
background-color: var(--color-secondary);
border-radius: var(--border-radius);
color: var(--color-bg);
font-size: xx-small;
font-weight: bold;
margin: 0.2rem;
padding: 0.2rem 0.3rem;
position: relative;
top: -2px;
}
/* Links */
a {
color: var(--color-link);
display: inline-block;
font-weight: bold;
text-decoration: underline;
}
a:hover {
filter: brightness(var(--hover-brightness));
}
a:active {
filter: brightness(var(--active-brightness));
}
a b,
a em,
a i,
a strong,
button,
input[type="submit"] {
border-radius: var(--border-radius);
display: inline-block;
font-size: medium;
font-weight: bold;
line-height: var(--line-height);
margin: 0.5rem 0;
padding: 1rem 2rem;
}
button,
input[type="submit"] {
font-family: var(--font-family);
}
button:hover,
input[type="submit"]:hover {
cursor: pointer;
filter: brightness(var(--hover-brightness));
}
button:active,
input[type="submit"]:active {
filter: brightness(var(--active-brightness));
}
a b,
a strong,
button,
input[type="submit"] {
background-color: var(--color-link);
border: 2px solid var(--color-link);
color: var(--color-bg);
}
a em,
a i {
border: 2px solid var(--color-link);
border-radius: var(--border-radius);
color: var(--color-link);
display: inline-block;
padding: 1rem 2rem;
}
article aside a {
color: var(--color-secondary);
}
/* Images */
figure {
margin: 0;
padding: 0;
}
figure img {
max-width: 100%;
}
figure figcaption {
color: var(--color-text-secondary);
}
/* Forms */
button:disabled,
input:disabled {
background: var(--color-bg-secondary);
border-color: var(--color-bg-secondary);
color: var(--color-text-secondary);
cursor: not-allowed;
}
button[disabled]:hover,
input[type="submit"][disabled]:hover {
filter: none;
}
form {
border: 1px solid var(--color-bg-secondary);
border-radius: var(--border-radius);
box-shadow: var(--box-shadow) var(--color-shadow);
display: block;
max-width: var(--width-card-wide);
min-width: var(--width-card);
padding: 1.5rem;
text-align: var(--justify-normal);
}
form header {
margin: 1.5rem 0;
padding: 1.5rem 0;
}
input,
label,
select,
textarea {
display: block;
font-size: inherit;
max-width: var(--width-card-wide);
}
input[type="checkbox"],
input[type="radio"] {
display: inline-block;
}
input[type="checkbox"] + label,
input[type="radio"] + label {
display: inline-block;
font-weight: normal;
position: relative;
top: 1px;
}
input[type="range"] {
padding: 0.4rem 0;
}
input,
select,
textarea {
border: 1px solid var(--color-bg-secondary);
border-radius: var(--border-radius);
margin-bottom: 1rem;
padding: 0.4rem 0.8rem;
}
input[type="text"],
input[type="password"],
textarea {
width: calc(100% - 1.6rem);
}
input[readonly],
textarea[readonly] {
background-color: var(--color-bg-secondary);
}
label {
font-weight: bold;
margin-bottom: 0.2rem;
}
/* Popups */
dialog {
max-width: 90%;
max-height: 85dvh;
margin: auto;
padding: 0;
border: 1px solid var(--color-bg-secondary);
border-radius: 0.5rem;
overscroll-behavior: contain;
scroll-behavior: smooth;
scrollbar-width: none; /* Hide scrollbar for Firefox */
-ms-overflow-style: none; /* Hide scrollbar for IE and Edge */
scrollbar-color: transparent transparent;
animation: bottom-to-top 0.25s ease-in-out forwards;
}
dialog::-webkit-scrollbar {
width: 0;
display: none;
}
dialog::-webkit-scrollbar-track {
background: transparent;
}
dialog::-webkit-scrollbar-thumb {
background-color: transparent;
}
@media (min-width: 650px) {
dialog {
max-width: 39rem;
}
}
dialog::backdrop {
background-color: rgba(0, 0, 0, 0.5);
}
@keyframes bottom-to-top {
0% {
opacity: 0;
transform: translateY(10%);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
/* Tables */
table {
border: 1px solid var(--color-bg-secondary);
border-radius: var(--border-radius);
border-spacing: 0;
display: inline-block;
max-width: 100%;
overflow-x: auto;
padding: 0;
white-space: nowrap;
}
table td,
table th,
table tr {
padding: 0.4rem 0.8rem;
text-align: var(--justify-important);
}
table thead {
background-color: var(--color-table);
border-collapse: collapse;
border-radius: var(--border-radius);
color: var(--color-bg);
margin: 0;
padding: 0;
}
table thead tr:first-child th:first-child {
border-top-left-radius: var(--border-radius);
}
table thead tr:first-child th:last-child {
border-top-right-radius: var(--border-radius);
}
table thead th:first-child,
table tr td:first-child {
text-align: var(--justify-normal);
}
table tr:nth-child(even) {
background-color: var(--color-accent);
}
/* Quotes */
blockquote {
display: block;
font-size: x-large;
line-height: var(--line-height);
margin: 1rem auto;
max-width: var(--width-card-medium);
padding: 1.5rem 1rem;
text-align: var(--justify-important);
}
blockquote footer {
color: var(--color-text-secondary);
display: block;
font-size: small;
line-height: var(--line-height);
padding: 1.5rem 0;
}
/* Scrollbars */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-scrollbar) transparent;
}
*::-webkit-scrollbar {
width: 5px;
height: 5px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: var(--color-scrollbar);
border-radius: 10px;
}

View file

@ -0,0 +1,18 @@
function getWebsocketProtocol() {
// return "ws://" when localhost or "wss://"
const hostname = window.location.hostname;
if (hostname === "localhost" || hostname === "127.0.0.1") {
return "ws://";
} else {
return "wss://";
}
}
function addLogoutEvent() {
const logoutButton = document.getElementById("logoutButton");
logoutButton.addEventListener("click", () => {
document.cookie = "twitchLoginData=; expires=Thu, 01 Jan 1970 00:00:00 UTC";
window.location.href = "/";
});
}

View file

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/html">
<head>
<link rel="stylesheet" href="/static/css/mvp.css">
<link rel="icon"
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🦴</text></svg>">
<meta charset="utf-8">
<meta name="description" content="Huesoporro Twitch bot">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="static/js/utils.js"></script>
<title>Huesoporro</title>
</head>

View file

@ -0,0 +1,148 @@
{% include 'header.html' %}
<body>
<header>
<nav>
<ul>
<li>Chatbot</li>
<li><a href="/tts">TTS</a></li>
</ul>
<ul>
<li><a id="logoutButton" href="#" style="color: #aa0000;">Logout</a></li>
</ul>
</nav>
<h1>Huesoporro🦴🍃</h1>
</header>
<main>
<section>
<form>
<label for="channelName">Enter channel name:</label>
<input type="text" id="channelName" placeholder="#huesoperro">
<button id="startButton" type="button">Start chatbot</button>
<button id="stopButton" type="button" disabled style="background-color: #aa0000; border-color: #aa0000">Stop
chatbot
</button>
<br/>
</form>
</section>
<details open="open">
<summary>Log</summary>
<div><samp id="log"></samp></div>
</details>
</main>
<script>
document.addEventListener("DOMContentLoaded", () => {
class ChatbotManager {
constructor() {
this.url = getWebsocketProtocol() + window.location.host + "/ws";
this.logElement = document.getElementById('log');
this.socket = null;
}
log(message) {
console.log(message);
this.logElement.innerHTML += message + '<br>';
}
async open() {
return new Promise((resolve, reject) => {
this.socket = new WebSocket(this.url);
this.socket.withCredentials = true;
this.socket.onopen = () => {
this.log("Connected to WebSocket " + this.url);
}
this.socket.onmessage = async (event) => {
try {
const message = JSON.parse(event.data);
if (message.command === "chatbot_message") {
this.log(`[${message.data.username}]: ${message.data.message}`);
} else if (message.command === "chatbot_status") {
startButton.disabled = message.data.status === "ok";
stopButton.disabled = message.data.status === "ko";
this.log("Bot status is " + message.data.status)
} else if (message.command === "chatbot_start") {
this.log(message.data.log)
}
} catch (error) {
this.log(`Error parsing message: ${error.message}`);
}
}
this.socket.onerror = (error) => {
this.log(`WebSocket Error: ${error}`);
reject(error);
}
this.socket.onclose = () => {
this.log(`WebSocket connection closed: ${event.code} ${event.reason}`);
resolve();
}
});
}
async startBot() {
const channelNameInput = document.getElementById('channelName');
const channelName = channelNameInput ? channelNameInput.value : '';
const startCommand = {
command: "chatbot_start",
data: {
channel_name: channelName
}
};
this.socket.send(JSON.stringify(startCommand));
}
async stopBot() {
const stopCommand = {
command: "chatbot_stop",
data: {}
};
this.socket.send(JSON.stringify(stopCommand));
}
}
const chatbotManager = new ChatbotManager();
chatbotManager.open()
const startButton = document.getElementById('startButton');
const stopButton = document.getElementById('stopButton');
if (startButton) {
startButton.addEventListener('click', () => {
chatbotManager.startBot()
.then(() => {
console.log('Chatbot started successfully');
})
.catch((error) => {
console.error('Failed to start chatbot', error);
});
});
}
if (stopButton) {
stopButton.addEventListener('click', () => {
chatbotManager.stopBot()
.then(() => {
console.log('Chatbot stopped successfully');
})
.catch((error) => {
console.error('Failed to stop chatbot', error);
});
});
}
addLogoutEvent()
});
</script>
</body>
</html>

View file

@ -0,0 +1,131 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/html">
<head>
<link rel="stylesheet" href="/static/css/mvp.css">
<link rel="icon"
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🦴</text></svg>">
<meta charset="utf-8">
<meta name="description" content="Huesoporro Twitch bot">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Huesoporro login</title>
</head>
<body>
<header>
<h1>Huesoporro🦴🚬</h1>
</header>
<main>
<section>
<a href="{{ twitch_login_url }}" id="loginButton" type="button" style="color: #9c36b5; border-color: #9c36b5">Login
with
Twitch
</a>
</section>
</main>
<script>
document.addEventListener("DOMContentLoaded", () => {
class LoginManager {
constructor() {
this.loginData = null;
}
// Helper method to set a cookie
setCookie(name, value, days) {
const date = new Date();
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
const expires = `expires=${date.toUTCString()}`;
document.cookie = `${name}=${value};${expires};path=/;SameSite=Strict`;
}
// Helper method to get a cookie
getCookie(name) {
const cookieName = `${name}=`;
const decodedCookie = decodeURIComponent(document.cookie);
const cookieArray = decodedCookie.split(';');
for (let i = 0; i < cookieArray.length; i++) {
let cookie = cookieArray[i];
while (cookie.charAt(0) === ' ') {
cookie = cookie.substring(1);
}
if (cookie.indexOf(cookieName) === 0) {
return cookie.substring(cookieName.length, cookie.length);
}
}
return null;
}
async readLoginData() {
// Try to get existing login data from cookies
const loginData = this.getCookie("twitchLoginData");
try {
// Parse the stored login data if it exists
this.loginData = loginData ? JSON.parse(loginData) : null;
return this.loginData;
} catch (error) {
console.error("Error reading login data:", error);
return null;
}
}
async saveSettings() {
// Check if access_token is present in the URL hash
const hashParams = new URLSearchParams(window.location.hash.substring(1));
const accessToken = hashParams.get('access_token');
if (accessToken) {
// Create login data object
const loginData = {
access_token: accessToken,
timestamp: new Date().toISOString()
};
try {
// Save login data to cookie (expires in 30 days)
this.setCookie("twitchLoginData", JSON.stringify(loginData), 30);
this.loginData = loginData;
// Hide the login button
const loginButton = document.getElementById("loginButton");
if (loginButton) {
loginButton.style.display = 'none';
}
// Clear the hash from the URL
history.replaceState(null, document.title, window.location.pathname);
// Redirect to home page or perform any other necessary actions
window.location.href = '/';
} catch (error) {
console.error("Error saving login data:", error);
}
}
}
}
const loginManager = new LoginManager();
// Read existing login data
loginManager.readLoginData().then(loginData => {
const loginButton = document.getElementById("loginButton");
if (loginData) {
// If login data exists, redirect to home page
window.location.href = '/';
} else {
// If no login data, try to save settings (handle Twitch OAuth callback)
loginManager.saveSettings();
}
});
});
</script>
</body>
</html>

View file

@ -0,0 +1,184 @@
{% include 'header.html' %}
<body>
<header>
<nav id="navbar">
<ul>
<li><a href="/">Chatbot</a></li>
<li>TTS</li>
</ul>
<ul>
<li><a id="logoutButton" href="#" style="color: #aa0000;">Logout</a></li>
</ul>
</nav>
<h1>Huesoporro🦴🍃</h1>
</header>
<main>
<section>
<form>
<label for="textInput">Enter text:</label>
<input type="text" id="textInput" placeholder="Hi huesoporro">
<button id="sendButton" type="button">Send text</button>
</form>
</section>
<audio id="audioPlayer" hidden="hidden" controls></audio>
<details open="open">
<summary>Log</summary>
<div><samp id="log"></samp></div>
</details>
</main>
<script>
document.addEventListener('DOMContentLoaded', () => {
class AudioStreamer {
constructor() {
this.url = getWebsocketProtocol() + window.location.host + "/ws";
this.audioPlayer = document.getElementById('audioPlayer');
this.logElement = document.getElementById('log');
this.audioBuffer = [];
this.expectedFileSize = 0;
this.receivedFileSize = 0;
}
log(message) {
console.log(message);
this.logElement.innerHTML += message + '<br>';
}
async start() {
// Establish WebSocket connection
this.audioBuffer = [];
this.expectedFileSize = 0;
this.receivedFileSize = 0;
this.log("Connecting to WebSocket: " + this.url);
this.websocket = new WebSocket(this.url);
this.websocket.onopen = () => {
this.log('WebSocket connection established');
};
this.websocket.onmessage = async (event) => {
try {
if (typeof event.data === 'string') {
if (event.data.startsWith('FILE_HEADER:')) {
this.expectedFileSize = parseInt(event.data.split(':')[1]);
this.log(`Expecting file of size: ${this.expectedFileSize} bytes`);
this.audioBuffer = [];
this.receivedFileSize = 0;
} else if (event.data === 'FILE_FOOTER') {
// Only play after file footer is received and all chunks are in
this.log(`Received complete file. Total size: ${this.receivedFileSize} bytes`);
if (this.receivedFileSize > 0) {
await this.playAudioBuffer();
} else {
this.log('No audio data received');
}
}
} else {
// Accumulate chunks
const audioData = await event.data.arrayBuffer();
this.audioBuffer.push(audioData);
this.receivedFileSize += audioData.byteLength;
this.log(`Received chunk. Total received: ${this.receivedFileSize} / ${this.expectedFileSize}`);
if (this.receivedFileSize >= this.expectedFileSize) {
// Play audio when complete
await this.playAudioBuffer();
}
}
} catch (error) {
this.log(`Error processing audio: ${error}`);
}
};
this.websocket.onerror = (error) => {
this.log(`WebSocket error: ${error}`);
};
}
async combineBuffers(buffers) {
console.log(`Combining ${buffers.length} buffers`);
buffers.forEach((buffer, index) => {
console.log(`Buffer ${index} size: ${buffer.byteLength} bytes`);
});
// Validate buffers
if (buffers.length === 0) {
console.error('No buffers to combine');
return new ArrayBuffer(0);
}
// Calculate total length
const totalLength = buffers.reduce((acc, buffer) => acc + buffer.byteLength, 0);
console.log(`Total combined length: ${totalLength} bytes`);
// Create a new buffer and copy data
const combinedBuffer = new Uint8Array(totalLength);
let offset = 0;
for (const buffer of buffers) {
combinedBuffer.set(new Uint8Array(buffer), offset);
offset += buffer.byteLength;
}
return combinedBuffer.buffer;
}
async playAudioBuffer() {
try {
const combinedBuffer = await this.combineBuffers(this.audioBuffer);
// Verify combined buffer
console.log(`Combined buffer size: ${combinedBuffer.byteLength} bytes`);
const blob = new Blob([combinedBuffer], {type: 'audio/mpeg'});
console.log(`Blob size: ${blob.size} bytes`);
// Only proceed if blob has content
if (blob.size > 0) {
this.audioPlayer.src = URL.createObjectURL(blob);
await this.audioPlayer.play();
} else {
console.error('Blob is empty');
}
} catch (error) {
console.error('Audio buffer processing error:', error);
}
}
sendText(text) {
// build a Websocket message and send it
const message = {
"command": "tts_send",
"data": {
"text": text
}
}
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
this.websocket.send(JSON.stringify(message));
}
}
}
const audioStreamer = new AudioStreamer();
const sendButton = document.getElementById('sendButton');
const textInput = document.getElementById('textInput');
// Automatically connect on page load
audioStreamer.start();
sendButton.addEventListener('click', () => {
const text = textInput.value.trim();
if (text) {
audioStreamer.sendText(text);
textInput.value = ''; // Clear input
}
});
addLogoutEvent()
});
</script>
</body>
</html>

131
src/huesoporro/tts.py Normal file
View file

@ -0,0 +1,131 @@
import asyncio
from collections import deque
from hashlib import sha512
from pathlib import Path
from gtts import gTTS
from litestar import WebSocket
from loguru import logger
from src.huesoporro.settings import Settings
class TTSManager:
TEXT_MAX_LENGTH: int = 400
def __init__(self, max_queue_size=10):
self.queue: deque = deque(maxlen=max_queue_size)
# Connected WebSocket clients
self.clients: list[WebSocket] = []
# Currently playing audio
self.current_audio = None
# Lock to prevent race conditions
self._lock = asyncio.Lock()
self._tasks = []
self.s = Settings.get()
def generate_tts(self, text, language="pt", tld="com.br"):
# Generate unique filename
text = text[0 : self.TEXT_MAX_LENGTH]
filename = (
self.s.tts_cache_path / f"{sha512(text.lower().encode()).hexdigest()}.mp3"
)
if filename.exists():
logger.info(
f"TTS already exists for '{text[:50]}' at {filename}. Returning it"
)
return {
"filename": filename.name,
"text": text,
"filepath": str(filename),
"language": language,
"tld": tld,
}
logger.info(f"Generating TTS for '{text[:50]}'")
# Generate TTS
tts = gTTS(text=text, lang=language, tld=tld)
tts.save(str(filename))
return {
"filename": filename.name,
"text": text,
"filepath": filename,
"language": language,
"tld": tld,
}
async def add_to_queue(self, text, language="pt", tld="com.br"):
"""Add TTS request to queue and start processing if not already running"""
async with self._lock:
# Generate TTS file
audio_info = self.generate_tts(text, language, tld)
# Add to queue
self.queue.append(audio_info)
# If this is the only item, start processing
if len(self.queue) == 1:
self._tasks.append(asyncio.create_task(self.process_queue()))
return audio_info
async def process_queue(self):
"""Process queue and stream audio to connected clients"""
while True:
async with self._lock:
# Check if queue is empty
if not self.queue:
return
# Get next audio file
audio_info = self.queue[0]
try:
# Read the entire audio file
audio_path = Path(audio_info["filepath"])
with audio_path.open("rb") as audio_file:
file_size = audio_path.stat().st_size
logger.info(
f"Streaming file: {audio_info['filename']}, Size: {file_size} bytes"
)
# Stream audio to all connected clients
for client in self.clients:
try:
# Reset file pointer to beginning
audio_file.seek(0)
# Send file size first (as a header)
await client.send_text(f"FILE_HEADER:{file_size}")
# Stream file in chunks
chunk = audio_file.read(128) # Larger chunk size
chunk_count = 0
while chunk:
logger.info(f"Streamed {chunk_count} chunks")
chunk_count += 1
await client.send_bytes(chunk)
chunk = audio_file.read(128)
# Send file footer
await client.send_text("FILE_FOOTER")
except Exception: # noqa: BLE001
logger.error(
f"Error streaming to client {client.client}. Removing it."
)
if client in self.clients:
self.clients.remove(client)
except Exception as e: # noqa: BLE001
logger.error(f"Error processing audio file: {e}")
# Remove the processed item from the queue
async with self._lock:
if self.queue and self.queue[0] == audio_info:
self.queue.popleft()

View file

@ -0,0 +1,16 @@
from enum import StrEnum
from pydantic import BaseModel
class WebsocketCommands(StrEnum):
TTS_SEND = "tts_send"
CHATBOT_START = "chatbot_start"
CHATBOT_STOP = "chatbot_stop"
CHATBOT_STATUS = "chatbot_status"
CHATBOT_UPDATE = "chatbot_update"
class WebsocketMessage(BaseModel):
command: WebsocketCommands
data: dict

View file

@ -1,154 +0,0 @@
import queue
import threading
from pathlib import Path
from traceback import print_exc
from kivy.clock import Clock
from kivy.metrics import dp
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import Button
from kivy.uix.textinput import TextInput
from loguru import logger
from src.markovbot_gui.libs.markov_chain_bot import MarkovChain
from src.markovbot_gui.libs.settings import Settings
class QueueHandler:
def __init__(self, queue):
self.queue = queue
def write(self, message):
self.queue.put(message)
def flush(self):
pass
class BotRunner(BoxLayout):
def __init__(self, settings_path: Path, **kwargs):
super().__init__(**kwargs)
self.settings_path = settings_path
self.orientation = "vertical"
self.spacing = dp(10)
self.padding = dp(20)
self.bot_thread = None
self.log_queue: queue.Queue = queue.Queue()
self.settings = Settings.read(self.settings_path)
self.queue_handler = QueueHandler(self.log_queue)
logger.remove()
logger.add(
self.queue_handler,
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}",
level=self.settings.log_level,
)
self.log_display = TextInput(
multiline=True,
readonly=True,
size_hint=(1, 1),
background_color=[0.1, 0.1, 0.1, 1], # Dark background
foreground_color=[0.9, 0.9, 0.9, 1], # Light text
)
self.add_widget(self.log_display)
# Create button layout
button_layout = BoxLayout(
orientation="horizontal",
size_hint=(1, None),
height=dp(40),
spacing=dp(10),
)
# Create start button
self.start_button = Button(
text="Start Bot",
size_hint=(None, None),
size=(dp(100), dp(40)),
)
self.start_button.bind(on_release=self.start_bot)
button_layout.add_widget(self.start_button)
# Create stop button
self.stop_button = Button(
text="Stop Bot",
size_hint=(None, None),
size=(dp(100), dp(40)),
disabled=True,
)
self.stop_button.bind(on_release=self.stop_bot)
button_layout.add_widget(self.stop_button)
# Create clear log button
self.clear_button = Button(
text="Clear Log",
size_hint=(None, None),
size=(dp(100), dp(40)),
)
self.clear_button.bind(on_release=self.clear_log)
button_layout.add_widget(self.clear_button)
self.add_widget(button_layout)
Clock.schedule_interval(self.update_log, 0.1)
def start_bot(self, instance=None):
try:
# Create and start bot thread
self.bot_thread = threading.Thread(target=self.run_bot_thread, daemon=True)
self.bot_thread.start()
self.start_button.disabled = True
self.stop_button.disabled = False
logger.info("Starting bot...")
except Exception as e: # noqa: BLE001
logger.error(f"Failed to start bot: {e}")
def run_bot_thread(self):
try:
self.bot = MarkovChain(self.settings)
self.bot.run_bot()
except Exception: # noqa: BLE001
logger.exception("Bot error")
finally:
Clock.schedule_once(lambda dt: self.reset_button_states(), 0)
def stop_bot(self, _=None):
self.bot.stop_bot()
# Wait for thread to finish
if self.bot_thread and self.bot_thread.is_alive():
self.bot_thread.join(timeout=3.0)
logger.info("Bot stopped")
self.reset_button_states()
def reset_button_states(self):
self.start_button.disabled = False
self.stop_button.disabled = True
def clear_log(self, instance=None):
self.log_display.text = ""
logger.info("Log cleared")
def update_log(self, dt):
try:
while not self.log_queue.empty():
message = self.log_queue.get_nowait()
if message.strip(): # Only add non-empty messages
self.log_display.text += message
# Keep only the last 1000 lines to prevent memory issues
lines = self.log_display.text.split("\n")
if len(lines) > 1000: # noqa: PLR2004
self.log_display.text = "\n".join(lines[-1000:]) + "\n"
# Auto-scroll to bottom
self.log_display.cursor = (0, len(self.log_display.text))
except queue.Empty:
pass
except Exception: # noqa: BLE001
print_exc()

View file

@ -1,161 +0,0 @@
from pathlib import Path
from kivy.clock import Clock
from kivy.metrics import dp
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import Button
from kivy.uix.label import Label
from kivy.uix.popup import Popup
from kivy.uix.textinput import TextInput
from src.markovbot_gui.libs.settings import Settings
from src.markovbot_gui.libs.timer import logger
class ConfigWindow(BoxLayout):
def __init__(self, config_path: Path, **kwargs):
super().__init__(**kwargs)
self.config_path = config_path
self.orientation = "vertical"
self.spacing = dp(10)
self.padding = dp(20)
# Load existing configuration
default_config = {
"Host": "irc.chat.twitch.tv",
"Port": 6667,
"Channel": "#<channel>",
"Nickname": "<name>",
"Authentication": "oauth:<auth>",
"DeniedUsers": ["StreamElements", "Nightbot", "Moobot", "Marbiebot"],
"Cooldown": 20,
"KeyLength": 2,
"MaxSentenceWordAmount": 25,
"MinSentenceWordAmount": -1,
"HelpMessageTimer": 60 * 60 * 5, # 18000 seconds, 5 hours
"AutomaticGenerationTimer": -1,
"WhisperCooldown": True,
"EnableGenerateCommand": True,
"SentenceSeparator": " - ",
"AllowGenerateParams": True,
}
if config_path.exists():
self.s = Settings.read(config_path)
else:
self.s = Settings(**default_config) # type: ignore[arg-type]
self.s.write(config_path)
# Create widgets
# Channel input
channel_layout = BoxLayout(
orientation="horizontal",
size_hint_y=None,
height=dp(40),
)
channel_label = Label(text="Channel:", size_hint_x=0.3)
self.channel_input = TextInput(
multiline=False,
size_hint_x=0.7,
text=self.s.channel,
)
channel_layout.add_widget(channel_label)
channel_layout.add_widget(self.channel_input)
# Nickname input
nickname_layout = BoxLayout(
orientation="horizontal",
size_hint_y=None,
height=dp(40),
)
nickname_label = Label(text="Nickname:", size_hint_x=0.3)
self.nickname_input = TextInput(
multiline=False,
size_hint_x=0.7,
text=self.s.nickname,
)
nickname_layout.add_widget(nickname_label)
nickname_layout.add_widget(self.nickname_input)
# Authentication input
auth_layout = BoxLayout(
orientation="horizontal",
size_hint_y=None,
height=dp(40),
)
auth_label = Label(text="Auth:", size_hint_x=0.3)
self.auth_input = TextInput(
multiline=False,
size_hint_x=0.7,
password=True,
text=self.s.authentication,
)
auth_layout.add_widget(auth_label)
auth_layout.add_widget(self.auth_input)
automatic_generation_label = Label(text="Automatic generation (seconds): ")
self.automatic_generation_input = TextInput(
multiline=False,
size_hint_x=0.7,
text=str(self.s.automatic_generation_timer),
)
automatic_generation_layout = BoxLayout(
orientation="horizontal",
size_hint_y=None,
height=dp(40),
)
automatic_generation_layout.add_widget(automatic_generation_label)
automatic_generation_layout.add_widget(self.automatic_generation_input)
# Save button
save_button = Button(
text="Save",
size_hint=(None, None),
size=(dp(100), dp(40)),
pos_hint={"center_x": 0.5},
)
save_button.bind(on_release=self.save_config)
# Add all widgets to the layout
self.add_widget(channel_layout)
self.add_widget(nickname_layout)
self.add_widget(auth_layout)
self.add_widget(automatic_generation_layout)
self.add_widget(save_button)
def save_config(self, instance):
try:
self.s.channel = self.channel_input.text.strip()
self.s.nickname = self.nickname_input.text.strip()
self.s.authentication = self.auth_input.text.strip()
self.s.automatic_generation_timer = int(
self.automatic_generation_input.text
)
if 0 < self.s.automatic_generation_timer < 29: # noqa: PLR2004
raise ValueError(
"Value for 'Automatic generation' must be at least 30 seconds, " # noqa: EM101
"or a negative number for no automatic generations."
)
self.s.write(self.config_path)
# Show success message
success_popup = Popup(
title="Success",
content=Label(text="Configuration saved successfully"),
size_hint=(None, None),
size=(dp(250), dp(100)),
)
success_popup.open()
Clock.schedule_once(success_popup.dismiss, 1)
except Exception as e: # noqa: BLE001
self.show_error_message(f"Failed to save configuration:\n{e!s}")
error_popup = Popup(
title="Error",
content=Label(text=f"Failed to save configuration:\n{e!s}"),
size_hint=(None, None),
size=(dp(400), dp(150)),
)
error_popup.open()
logger.exception("Failed to save configuration")

View file

@ -1,10 +0,0 @@
import logging
class LogHandler(logging.Handler):
def __init__(self, log_queue):
super().__init__()
self.log_queue = log_queue
def emit(self, record):
self.log_queue.put(self.format(record))

View file

@ -1,75 +0,0 @@
import platformdirs
from kivy.app import App
from kivy.metrics import dp
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import Button
from kivy.uix.popup import Popup
from kivy.uix.widget import Widget
from src.markovbot_gui.bot_runner import BotRunner
from src.markovbot_gui.config_window import ConfigWindow
class BotApp(App):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.config_path = (
platformdirs.user_config_path("markovbot_gui") / "settings.json"
)
self.data_path = platformdirs.user_data_path("markovbot_gui")
def run_bot(self, instance):
bot_runner = BotRunner(settings_path=self.config_path)
popup = Popup(
title=f"Bot runner, database available at {self.data_path}",
content=bot_runner,
size_hint=(None, None),
size=(dp(600), dp(600)),
auto_dismiss=False,
)
popup.open()
def run_config(self, instance):
config_window = ConfigWindow(config_path=self.config_path)
popup = Popup(
title=f"Bot configuration, available at {self.config_path}",
content=config_window,
size_hint=(None, None),
size=(dp(600), dp(400)),
auto_dismiss=False,
)
# Add close button
close_button = Button(
text="Close",
size_hint=(None, None),
size=(dp(100), dp(40)),
pos_hint={"center_x": 0.5},
)
close_button.bind(on_release=popup.dismiss)
config_window.add_widget(close_button)
popup.open()
def build(self):
widget = Widget()
layout = BoxLayout(size_hint=(1, None), height=50)
run_button = Button(text="Run bot")
run_button.bind(on_release=self.run_bot)
layout.add_widget(run_button)
config_button = Button(text="Open config")
config_button.bind(on_release=self.run_config)
layout.add_widget(config_button)
root = BoxLayout(orientation="vertical")
root.add_widget(widget)
root.add_widget(layout)
return root
if __name__ == "__main__":
BotApp().run()