kami_dev #1

Merged
kami merged 7 commits from kami_dev into main 2025-02-01 20:30:34 +00:00
9 changed files with 272 additions and 123 deletions

View File

@ -5,10 +5,10 @@ import importlib
import cmd_discord
class DiscordBot(commands.Bot):
def __init__(self, config, logger):
def __init__(self, config, log_func):
super().__init__(command_prefix="!", intents=discord.Intents.all())
self.config = config
self.logger = logger
self.log = log_func # Use the logging function from bots.py
self.load_commands()
def load_commands(self):
@ -18,21 +18,15 @@ class DiscordBot(commands.Bot):
try:
importlib.reload(cmd_discord)
cmd_discord.setup(self)
self.logger.info("Discord commands loaded successfully.")
self.log("Discord commands loaded successfully.", "INFO")
except Exception as e:
self.logger.error(f"Error loading Discord commands: {e}")
self.log(f"Error loading Discord commands: {e}", "ERROR")
async def on_ready(self):
self.logger.info(f"Discord bot is online as {self.user}")
# @commands.command()
# async def reload(self, ctx):
# """ Reload all Discord commands dynamically. """
# self.load_commands()
# await ctx.send("Commands reloaded!")
self.log(f"Discord bot is online as {self.user}", "INFO")
async def run(self, token):
try:
await super().start(token)
except Exception as e:
self.logger.error(f"Discord bot error: {e}")
self.log(f"Discord bot error: {e}", "CRITICAL")

View File

@ -1,18 +1,18 @@
# bot_twitch.py
import os
import requests
import logging
import asyncio
from twitchio.ext import commands
import importlib
import cmd_twitch
class TwitchBot(commands.Bot):
def __init__(self, config, logger):
def __init__(self, config, log_func):
self.client_id = os.getenv("TWITCH_CLIENT_ID")
self.client_secret = os.getenv("TWITCH_CLIENT_SECRET")
self.token = os.getenv("TWITCH_BOT_TOKEN")
self.refresh_token = os.getenv("TWITCH_REFRESH_TOKEN")
self.logger = logger
self.log = log_func # Use the logging function from bots.py
self.config = config
# 1) Initialize the parent Bot FIRST
@ -30,7 +30,7 @@ class TwitchBot(commands.Bot):
Refreshes the Twitch access token using the stored refresh token.
Retries up to 2 times before logging a fatal error.
"""
self.logger.info("Attempting to refresh Twitch token...")
self.log("Attempting to refresh Twitch token...", "INFO")
url = "https://id.twitch.tv/oauth2/token"
params = {
@ -53,19 +53,18 @@ class TwitchBot(commands.Bot):
os.environ["TWITCH_REFRESH_TOKEN"] = self.refresh_token
self.update_env_file()
self.logger.info("Twitch token refreshed successfully.")
self.log("Twitch token refreshed successfully.", "INFO")
return # Success, exit function
else:
self.logger.warning(f"Twitch token refresh failed (Attempt {attempt+1}/3): {data}")
self.log(f"Twitch token refresh failed (Attempt {attempt+1}/3): {data}", "WARNING")
except Exception as e:
self.logger.error(f"Twitch token refresh error (Attempt {attempt+1}/3): {e}")
self.log(f"Twitch token refresh error (Attempt {attempt+1}/3): {e}", "ERROR")
await asyncio.sleep(10) # Wait before retrying
# If all attempts fail, log error
from bots import log_error
log_error("Twitch token refresh failed after 3 attempts.")
self.log("Twitch token refresh failed after 3 attempts.", "FATAL")
def update_env_file(self):
"""
@ -84,10 +83,10 @@ class TwitchBot(commands.Bot):
else:
file.write(line)
self.logger.info("Updated .env file with new Twitch token.")
self.log("Updated .env file with new Twitch token.", "INFO")
except Exception as e:
self.logger.error(f"Failed to update .env file: {e}")
self.log(f"Failed to update .env file: {e}", "ERROR")
def load_commands(self):
"""
@ -96,20 +95,25 @@ class TwitchBot(commands.Bot):
try:
importlib.reload(cmd_twitch)
cmd_twitch.setup(self)
self.logger.info("Twitch commands loaded successfully.")
self.log("Twitch commands loaded successfully.", "INFO")
except Exception as e:
self.logger.error(f"Error loading Twitch commands: {e}")
self.log(f"Error loading Twitch commands: {e}", "ERROR")
async def run(self):
"""
Run the Twitch bot, refreshing tokens if needed.
"""
try:
await self.connect() # Connect to Twitch
await self.start() # Start the bot event loop
while True:
await self.refresh_access_token()
await asyncio.sleep(10800) # 3 hours
self.log(f"Twitch bot connecting...", "INFO")
self.log(f"...Consider online if no further messages", "INFO")
await self.start()
#while True:
# await self.refresh_access_token()
# await asyncio.sleep(10800) # Refresh every 3 hours
except Exception as e:
from bots import log_error
log_error(f"Twitch bot failed to start: {e}")
self.log(f"Twitch bot failed to start: {e}", "CRITICAL")
if "Invalid or unauthorized Access Token passed." in str(e):
try:
await self.refresh_access_token()
except Exception as e:
self.log(f"Unable to refresh Twitch token! Twitch bot will be offline!", "CRITICAL")

102
bots.py
View File

@ -2,11 +2,9 @@
import os
import json
import asyncio
import logging
import sys
import traceback
import time
from collections import Counter
import traceback
from discord.ext import commands
from dotenv import load_dotenv
from bot_discord import DiscordBot
@ -28,95 +26,51 @@ except json.JSONDecodeError as e:
sys.exit(1)
# Global settings
ERROR_LOG_FILE = "error_log.txt"
DISCORD_USER_ID = os.getenv("DISCORD_OWNER_ID") # Your Discord user ID for DM alerts
DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN") # Discord bot token
ERROR_CACHE = [] # Stores pending errors
LAST_ERROR_SENT = 0 # Timestamp of last Discord error message
ERROR_COOLDOWN = 900 # 15 minutes (900 sec) cooldown between Discord reports
ERROR_BATCH_TIMEOUT = 5 # 5 seconds batching delay before sending
# Setup logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
stream=sys.stdout
)
logger = logging.getLogger("Main")
bot_start_time = time.time()
###############################
# Error Handling & Discord Alerts
# Simple Logging System
###############################
async def send_discord_error_report(bot):
def log(message, level="INFO"):
"""
Sends cached errors as a Discord DM with cooldowns and batching.
If it fails, logs to error_log.txt instead.
A simple logging function with adjustable log levels.
Logs messages in a structured format.
Available levels:
DEBUG, INFO, WARNING, ERROR, CRITICAL, FATAL
See 'config.json' for disabling/enabling logging levels
"""
global ERROR_CACHE, LAST_ERROR_SENT
log_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "FATAL"]
if level not in log_levels:
level = "INFO" # Default to INFO if an invalid level is provided
if not ERROR_CACHE:
return # No errors to send
# Wait for batching timeout
await asyncio.sleep(ERROR_BATCH_TIMEOUT)
# Compress duplicate errors
error_counter = Counter(ERROR_CACHE)
compressed_errors = [f"{err} (Occurred {count} times)" if count > 1 else err for err, count in error_counter.items()]
error_message = "\n".join(compressed_errors)
# Ensure message fits in Discord limits
if len(error_message) > 2000:
error_message = error_message[:1990] + "\n... (truncated)"
# Update timestamp
LAST_ERROR_SENT = time.time()
try:
user = await bot.fetch_user(DISCORD_USER_ID)
await user.send(f"**Bot Error Report**\n\n{error_message}")
logger.info(f"[ERROR REPORT] Sent {len(ERROR_CACHE)} errors at {time.strftime('%H:%M:%S')} | Discord Send Status: Success")
except Exception as e:
# If Discord DM fails, log errors to a file
with open(ERROR_LOG_FILE, "a") as f:
if level in config_data["log_levels"]:
timestamp = time.strftime('%Y-%m-%d %H:%M:%S')
f.write(f"\n----- {len(ERROR_CACHE)} Errors Occurred at {timestamp} -----\n")
for err in ERROR_CACHE:
f.write(f"{err}\n")
log_message = f"[{timestamp}] [{level}] {message}"
logger.error(f"[ERROR REPORT] Failed to send Discord DM. Errors saved to {ERROR_LOG_FILE}")
# Clear cache after sending
ERROR_CACHE = []
def log_error(error_msg):
"""
Handles error logging with cooldowns.
If multiple errors occur in a short time, they are cached and sent in batch.
"""
global ERROR_CACHE, LAST_ERROR_SENT
ERROR_CACHE.append(error_msg)
if time.time() - LAST_ERROR_SENT >= ERROR_COOLDOWN:
try:
discord_bot
except NameError:
discord_bot = DiscordBot(config_data, logger)
asyncio.create_task(send_discord_error_report(discord_bot))
print(log_message) # Print to terminal
except Exception:
pass # Prevent logging failures from crashing the bot
# Placeholder for future expansions (e.g., file logging, Discord alerts, etc.)
###############################
# Main Event Loop
###############################
async def main():
bot_start_time = time.time()
global discord_bot, twitch_bot
discord_bot = DiscordBot(config_data, logger)
twitch_bot = TwitchBot(config_data, logger)
log("Initializing bots...", "INFO")
discord_task = asyncio.create_task(discord_bot.run(DISCORD_BOT_TOKEN))
discord_bot = DiscordBot(config_data, log)
twitch_bot = TwitchBot(config_data, log)
log("Starting Discord and Twitch bots...", "INFO")
discord_task = asyncio.create_task(discord_bot.run(os.getenv("DISCORD_BOT_TOKEN")))
twitch_task = asyncio.create_task(twitch_bot.run())
await asyncio.gather(discord_task, twitch_task)
@ -126,4 +80,4 @@ if __name__ == "__main__":
asyncio.run(main())
except Exception as e:
error_trace = traceback.format_exc()
log_error(f"Fatal Error: {e}\n{error_trace}")
log(f"Fatal Error: {e}\n{error_trace}", "FATAL")

View File

@ -27,9 +27,38 @@ def ping():
current_time = time.time()
elapsed = current_time - bot_start_time
uptime_str = format_uptime(elapsed)
response = f"Pong! I've been awake for {uptime_str}. Send coffee!"
return f"{response}"
uptime_str, uptime_s = format_uptime(elapsed)
# Define thresholds in ascending order
# (threshold_in_seconds, message)
# The message is a short "desperation" or "awake" text.
time_ranges = [
(3600, f"I've been awake for {uptime_str}. I just woke up, feeling great!"), # < 1 hour
(10800, f"I've been awake for {uptime_str}. I'm still fairly fresh!"), # 3 hours
(21600, f"I've been awake for {uptime_str}. I'm starting to get a bit weary..."), # 6 hours
(43200, f"I've been awake for {uptime_str}. 12 hours?! Might be time for coffee."), # 12 hours
(86400, f"I've been awake for {uptime_str}. A whole day without sleep... I'm okay?"), # 1 day
(172800, f"I've been awake for {uptime_str}. Two days... I'd love a nap."), # 2 days
(259200, f"I've been awake for {uptime_str}. Three days. Is sleep optional now?"), # 3 days
(345600, f"I've been awake for {uptime_str}. Four days... I'm running on fumes."), # 4 days
(432000, f"I've been awake for {uptime_str}. Five days. Please send more coffee."), # 5 days
(518400, f"I've been awake for {uptime_str}. Six days. I've forgotten what dreams are."), # 6 days
(604800, f"I've been awake for {uptime_str}. One week. I'm turning into a zombie."), # 7 days
(1209600, f"I've been awake for {uptime_str}. Two weeks. Are you sure I can't rest?"), # 14 days
(2592000, f"I've been awake for {uptime_str}. A month! The nightmares never end."), # 30 days
(7776000, f"I've been awake for {uptime_str}. Three months. I'm mostly coffee now."), # 90 days
(15552000,f"I've been awake for {uptime_str}. Six months. This is insane..."), # 180 days
(23328000,f"I've been awake for {uptime_str}. Nine months. I might be unstoppable."), # 270 days
(31536000,f"I've been awake for {uptime_str}. A year?! I'm a legend of insomnia..."), # 365 days
]
# We'll iterate from smallest to largest threshold
for threshold, msg in time_ranges:
if uptime_s < threshold:
return msg
# If none matched, it means uptime_s >= 31536000 (1 year+)
return f"I've been awake for {uptime_str}. Over a year awake... I'm beyond mortal limits!"
def greet(target_display_name: str, platform_name: str) -> str:
"""

View File

@ -1,6 +1,7 @@
{
"discord_guilds": [896713616089309184],
"twitch_channels": ["OokamiKunTV", "ookamipup"],
"command_modules": ["cmd_discord", "cmd_twitch", "cmd_common"]
"command_modules": ["cmd_discord", "cmd_twitch", "cmd_common"],
"log_levels": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "FATAL"]
}

View File

@ -1,11 +1,17 @@
# Example .env file.
# Rename this file to ".env" and fill in your credentials accordingly.
# Discord bot credentials
DISCORD_BOT_TOKEN=[Discord bot/access token]
DISCORD_OWNER_ID=[Discord owner user ID]
# Twitch bot credentials
TWITCH_CLIENT_ID=[Twitch bot client ID]
TWITCH_CLIENT_SECRET=[Twitch bot client secret]
TWITCH_BOT_TOKEN=[Twitch bot/access token]
TWITCH_REFRESH_TOKEN=[Twitch bot refresh token]
# MariaDB Database credentials
DB_HOST=[Database host IP address]
DB_USER=[Database username]
DB_PASSWORD=[Database user password]

View File

@ -21,14 +21,14 @@ def format_uptime(seconds: float) -> str:
# Build a short string, only listing the largest units
# If you want more detail, you can keep going
if years > 0:
return f"{years} year(s), {months} month(s)"
return [f"{years} year(s), {months} month(s)", seconds]
elif months > 0:
return f"{months} month(s), {days} day(s)"
return [f"{months} month(s), {days} day(s)", seconds]
elif days > 0:
return f"{days} day(s), {hours} hour(s)"
return [f"{days} day(s), {hours} hour(s)", seconds]
elif hours > 0:
return f"{hours} hour(s), {minutes} minute(s)"
return [f"{hours} hour(s), {minutes} minute(s)", seconds]
elif minutes > 0:
return f"{minutes} minute(s)"
return [f"{minutes} minute(s)", seconds]
else:
return f"{seconds} second(s)"
return [f"{seconds} second(s)", seconds]

152
readme.md Normal file
View File

@ -0,0 +1,152 @@
# OokamiPup V2
## A combined Discord and Twitch bot written in Python, leveraging:
- discord.py (PyPI version discord.py or a maintained fork)
- TwitchIO
- aiohttp
- python-dotenv
- and other libraries listed in your requirements.txt.
## About the bot
- Monitors specified Twitch channels for messages and commands.
- Connects to a specified Discord server to respond to commands and events.
- Uses a .env file for storing sensitive credentials.
- Uses a config.json for channel configuration, logging settings, etc.
## Features
- Discord Bot
- Responds to commands from any channel or restricted channels (depending on your setup).
- Automatically loads commands from cmd_discord.py.
- Twitch Bot
- Joins multiple Twitch channels specified in your config.json.
- Automatically loads commands from cmd_twitch.py.
- Simple Logging
- Logs to the console with optional levels (DEBUG, INFO, WARNING, etc.).
- Token Refresh
- Automatically attempts to refresh your Twitch OAuth token if it becomes invalid.
## Prerequisites
- Python 3.8+ (or a compatible Python 3 version—ensure asyncio.run is supported).
- A Twitch Developer application (for your Client ID and Client Secret).
- A Discord application/bot token.
- A working pip or pipenv/poetry for installing dependencies.
## Installation
Clone this repository (or download the source code):
1. `git clone https://git.ookamikun.tv/kami/OokamiPupV2.git` *(Clone repository)*
2. `cd OokamiPupV2` *(Navigate into the project folder)*
3. `pip install -r requirements.txt` *(Install project dependancies)*
Create your .env file in the project root (same folder as `bots.py`). It should look like:
```python
DISCORD_BOT_TOKEN=YourDiscordBotToken
TWITCH_CLIENT_ID=YourTwitchAppClientID
TWITCH_CLIENT_SECRET=YourTwitchAppClientSecret
TWITCH_BOT_TOKEN=YourTwitchOAuthToken
TWITCH_REFRESH_TOKEN=YourTwitchRefreshToken
```
Note: Your Twitch OAuth/Refresh tokens typically come from generating them through the Twitch Developer Console.
Set up your config.json with your preferred channels, logging levels, etc. For example:
```json
{
"twitch_channels": ["mychannel", "anotherchannel"],
"log_levels": ["INFO", "WARNING", "ERROR", "CRITICAL", "FATAL"]
}
```
twitch_channels is a list of channel names for your bot to join on Twitch.
log_levels controls what messages get logged to console (The lowest "DEBUG" level can be added if needed).
## Directory Overview
A quick rundown of the important files:
```bash
.
├── bots.py # Main entry point: initializes/starts both Discord and Twitch bots
├── bot_discord.py # Discord bot implementation (using discord.py)
├── bot_twitch.py # Twitch bot implementation (using TwitchIO)
├── cmd_common/ # Platform-independant command logic
│ └── common_commands.py # Common low-level commands file
├── cmd_discord.py # Command definitions for Discord
├── cmd_twitch.py # Command definitions for Twitch
├── config.json # Bot configuration (channels, logging levels, etc.)
├── .env # Environment variables for tokens (DO NOT commit this)
├── requirements.txt # Python dependencies
└── README.md # You are here
```
## Usage
1. Edit your environment variables in the .env file (tokens, client IDs, secrets, etc.).
2. Edit your config.json to list the Twitch channels and your logging preferences.
3. Run the bot: `python bots.py`
4. Check the console output:
- You should see logs indicating that the Discord bot is online (Discord bot is online as ...).
- You should also see logs about the Twitch bot joining channels (Twitch bot connected) — assuming everything is configured correctly.
## Testing Commands
**Discord:**
Open your Discord server, type your command prefix (e.g., !) + the command name (e.g., !ping).
**Twitch:**
In one of the channels specified in twitch_channels, type !ping (or whichever commands you have set up in cmd_twitch.py).
### If your commands do not respond
- Confirm the bot has correct roles/permissions in Discord (especially if it needs to read/send messages in specific channels).
- Confirm your Twitch OAuth token is valid.
- Check for logs or errors in the console.
## Adding or Editing Commands
**General Commands:**
low-level commands can be made in `cmd_common/common_commands.py`, alternatively separated into separate files in that folder.
These are command logic meant to support both platforms, eg. the `!ping` command.
**Discord:**
- Open cmd_discord.py and define new commands using the standard @bot.command() decorators from discord.ext.commands.
- Make sure cmd_discord.setup(bot) registers them with your DiscordBot.
**Twitch:**
- Open cmd_twitch.py and define new commands using the @bot.command() decorators from twitchio.ext.commands.
- Ensure cmd_twitch.setup(bot) properly registers them with TwitchBot.
## Troubleshooting
- No logs from the Twitch bot
- Ensure that youre calling await bot.start() (not bot.run() and await bot.start() together).
- If you only see the Discord bot logs, verify that your .env variables are loaded and valid (TWITCH_CLIENT_ID, TWITCH_CLIENT_SECRET, TWITCH_BOT_TOKEN, TWITCH_REFRESH_TOKEN).
- Confirm that you have internet connectivity and that the Twitch IRC service isnt blocked by a firewall.
- Token refresh issues
- If you see repeated warnings about “Invalid or unauthorized Access Token,” double-check your TWITCH_REFRESH_TOKEN and TWITCH_CLIENT_SECRET are correct.
- The bot attempts to refresh automatically; if it still fails, manually re-generate tokens from the Twitch Developer Console.
- "Cannot write to closing transport"
- Make sure you do not call connect() manually before start() in TwitchIO.
## Contributing
- Fork the repository
- Create a new branch (git checkout -b feature/myfeature)
- Make changes and commit (git commit -am 'Add new feature')
- Push to the branch (git push origin feature/myfeature)
- Create a new Pull Request in Git

View File

@ -1,4 +1,13 @@
discord.py
twitchio
python-dotenv
PyMySQL
# Core Dependencies
python-dotenv==1.0.0 # Loads environment variables from .env
requests==2.31.0 # HTTP requests for Twitch API (token refresh, etc.)
# Discord Bot Dependencies
discord.py==2.3.2 # Main Discord bot library (async)
PyNaCl==1.5.0 # Enables voice support (optional, required if using voice features)
# Twitch Bot Dependencies
twitchio==2.7.1 # Twitch chat bot library (async)
# Utility & Logging
aiohttp==3.9.1 # Async HTTP requests (dependency for discord.py & twitchio)