diff --git a/README.md b/README.md index ec2a0b4..0a18306 100644 --- a/README.md +++ b/README.md @@ -32,23 +32,16 @@ Share opinions, laugh together, and level up your server with a bot that can tal - `#!search` - Searches for stuff on the web using DuckDuckGo and provides a Rob-certified™ summary. # Development setup -1. Install Obun -```bash -git clone https://github.com/Dogo6647/obun.git -cd obun -./install.sh -``` - -2. Clone this repo and install requirements +1. Clone this repo and install requirements ```bash git clone https://github.com/Dogo6647/rob.git cd rob pip install -r requirements.txt ``` -3. Edit .env.example with your preferred text editor and rename it to .env +2. Edit .env.example with your preferred text editor and rename it to .env -4. Run the bot +3. Run the bot ``` -obun -w +python3 src/main.py ``` diff --git a/index.obun b/index.obun deleted file mode 100644 index 663f7a4..0000000 --- a/index.obun +++ /dev/null @@ -1,123 +0,0 @@ -0o --- -artifact-name: rob -shebang: /usr/bin/env python3 -build-mode: run ---- o0 - -import discord -import random -import aiohttp -import asyncio -import json -import os -import io -import cv2 -import pytesseract -from PIL import Image -import tempfile -import requests -from ddgs import DDGS -from bs4 import BeautifulSoup -from contextlib import redirect_stdout -import re -from collections import defaultdict, deque, Counter -from datetime import datetime -from discord.gateway import DiscordWebSocket -from discord.ext import tasks -from typing import Dict -import sys -from dotenv import load_dotenv -from discord.gateway import DiscordWebSocket, _log -load_dotenv(".env") - -class MobileWebSocket(DiscordWebSocket): - async def identify(self) -> None: - # Spoofs UA to mobile for funsies - payload = { - 'op': self.IDENTIFY, - 'd': { - 'token': self.token, - 'properties': { - 'os': sys.platform, - 'browser': 'Discord Android', - 'device': 'Discord Android', - }, - 'compress': True, - 'large_threshold': 250, - }, - } - - if self.shard_id is not None and self.shard_count is not None: - payload['d']['shard'] = [self.shard_id, self.shard_count] - - state = self._connection - if state._activity is not None or state._status is not None: - payload['d']['presence'] = { - 'status': state._status, - 'game': state._activity, - 'since': 0, - 'afk': False, - } - - if state._intents is not None: - payload['d']['intents'] = state._intents.value - - await self.call_hooks('before_identify', self.shard_id, initial=self._initial_identify) - await self.send_as_json(payload) - _log.debug('Shard ID %s has sent the IDENTIFY payload.', self.shard_id) - -DiscordWebSocket.identify = MobileWebSocket.identify - -TOKEN = os.getenv("TOKEN", None) -LLM_KEY = os.getenv("LLM_KEY", None) -LLM_LOCAL_URL = os.getenv("LLM_LOCAL_URL", "http://localhost:4891/v1/chat/completions") -LLM_PROXY_URL = os.getenv("LLM_PROXY_URL", "https://api.groq.com/openai/v1/chat/completions") -OWNER_ID = os.getenv("OWNER_ID", None) -CHANGELOG_FILE = "changelog.txt" - -CONFIG_DIR = "../rob-config/" -DIALECT_PATH = "dialect.json" -MNSSD_PROTO = "include/MobileNetSSD_deploy.prototxt" -MNSSD_MODEL = "include/MobileNetSSD_deploy.caffemodel" -net = cv2.dnn.readNetFromCaffe(MNSSD_PROTO, MNSSD_MODEL) - -def load_dialect(): - if os.path.exists(DIALECT_PATH): - with open(DIALECT_PATH, "r", encoding="utf-8") as f: - return json.load(f) - return {} -dialect_map = load_dialect() - -tin_can_chance = 0.005 - -intents = discord.Intents.default() -intents.messages = True -intents.guilds = True -intents.members = True -intents.dm_messages = True -intents.message_content = True -current_status = None -changelog_checked = False - -client = discord.Client(intents=intents) # , allowed_mentions=discord.AllowedMentions.none() -#:section src/statuses.obun.py - -# Store message history per server and per user in DMs -guild_message_histories = defaultdict(lambda: deque(maxlen=24)) -dm_message_histories = defaultdict(lambda: deque(maxlen=24)) - -#:section src/sysutils.obun.py -#:section src/guildconfig.obun.py -#:section src/stats.obun.py -#:section src/broadcast.obun.py -#:section src/channeldetect.obun.py -#:section src/response.obun.py - -#:section src/components/letter.obun.py -#:section src/components/phonebook.obun.py - -#:section src/events/ready.obun.py -#:section src/events/join.obun.py -#:section src/events/message.obun.py - -client.run(TOKEN) diff --git a/src/broadcast.obun.py b/src/broadcast.obun.py deleted file mode 100644 index 4a942bf..0000000 --- a/src/broadcast.obun.py +++ /dev/null @@ -1,38 +0,0 @@ -async def broadcast(): - if not os.path.exists(CHANGELOG_FILE): - return - - with open(CHANGELOG_FILE, "r", encoding="utf-8") as f: - raw = f.read() - - if raw.lstrip().startswith("[sent]"): - return - - changelog_text = raw.split("[sent]", 1)[0].strip() - if not changelog_text: - return - - print(":: Broadcasting changelog...") - - for guild in client.guilds: - try: - config = load_config(guild.id) - channel = get_mail_channel(guild, config) - - if not channel: - continue - - owner_ping = guild.owner.mention if guild.owner else "" - - await channel.send( - f"{owner_ping if '[noping]' not in changelog_text else ""}\n" - f"{changelog_text.replace('[noping]', '')}" - ) - - except Exception as e: - print(f":: Failed to send changelog to {guild.name}: {e}") - - with open(CHANGELOG_FILE, "w", encoding="utf-8") as f: - f.write("[sent]\n" + raw) - - print(":: Changelog marked as sent.") diff --git a/src/channeldetect.obun.py b/src/channeldetect.obun.py deleted file mode 100644 index 1149886..0000000 --- a/src/channeldetect.obun.py +++ /dev/null @@ -1,27 +0,0 @@ -def get_mail_channel(guild, config): - # explicitly configured channel prioritized++ - if config.get("mailChannel"): - channel = guild.get_channel(config["mailChannel"]) - if channel and isinstance(channel, discord.TextChannel) and channel.permissions_for(guild.me).send_messages: - return channel - - # try common names - preferred = ["general", "main", "chat", "lobby", "discussion"] - for name in preferred: - for channel in guild.text_channels: - if channel.name.lower() == name: - if channel.permissions_for(guild.me).send_messages: - return channel - - # channels containing "general" - for channel in guild.text_channels: - if "general" in channel.name.lower(): - if channel.permissions_for(guild.me).send_messages: - return channel - - # first writable text channel as last resort - for channel in guild.text_channels: - if channel.permissions_for(guild.me).send_messages: - return channel - - return None diff --git a/src/cogs/config.py b/src/cogs/config.py new file mode 100644 index 0000000..f3dcf8f --- /dev/null +++ b/src/cogs/config.py @@ -0,0 +1,94 @@ +import re +from discord import app_commands +from discord.ext import commands +from core.client import Rob +from util.config import has_rob_admin, save_config +from typing import Optional + +@commands.check(has_rob_admin) +@commands.guild_only() +class ConfigCog(commands.Cog): + def __init__(self, bot: Rob): + super().__init__() + self.bot = bot + + @commands.hybrid_command( + name="option", + description="Change Rob's settings to your liking." + ) + @app_commands.describe( + option="ID of the option to change.", + value="Value to assign, leave empty to view the current value." + ) + async def option(self, ctx: commands.Context, option: str, value: Optional[str]): + assert ctx.guild + if not value: + return await ctx.send(f"its `{self.bot.config[option]}`") + elif not option: + return await ctx.send("#!option \nthats how you do it btw ;3", ephemeral=True) + + if option in self.bot.config: + if not option == "model" and not option == "mailChannel" and not option == "mailTrusted": + if value.lower() in ["enable", "disable"]: + self.bot.config[option] = value.lower() == "enable" + else: + try: + self.bot.config[option] = int(value) + except ValueError: + await ctx.send("no not like that :X\nuse `enable`, `disable`, or a number", ephemeral=True) + return + elif option == "mailChannel": + match = re.match(r"<#(\d+)>", value) + + if match: + self.bot.config[option] = int(match.group(1)) + elif value.isdigit(): + self.bot.config[option] = int(value) + else: + await ctx.send("give me a channel mention or channel id", ephemeral=True) + return + elif option == "mailTrusted" or option == "model": + await ctx.send("you cannot change that part of my config with #!option :X", ephemeral=True) + return + else: + config[option] = value + save_config(ctx.guild.id, self.bot.config) + await ctx.send(f"alr, `{option}` is now `{value}` :)") + else: + await ctx.send(f"umm idk what a `{option}` is :/", ephemeral=True) + return + + @commands.hybrid_command( + name="trust", + description="Trusts another server's address, allowing them to send you letters." + ) + @app_commands.describe(address="Mail address of the server to trust.") + async def trust(self, ctx: commands.Context, address: str): + if not address: + await ctx.send("its like #!trust
", ephemeral=True) + return + + if address not in self.bot.config["mailTrusted"]: + self.bot.config["mailTrusted"].append(address) + save_config(ctx.guild.id, self.bot.config) + + return await ctx.send(f"trusted `{address}` :)") + + @commands.hybrid_command( + name="untrust", + description="Stops trusting another server's address, blocking them from sending you letters." + ) + @app_commands.describe(address="Mail address of the server to untrust.") + async def untrust(self, ctx: commands.Context, address: str): + if not address: + await ctx.send("its like #!untrust
", ephemeral=True) + return + + if address in self.bot.config["mailTrusted"]: + self.bot.config["mailTrusted"].remove(address) + save_config(ctx.guild.id, self.bot.config) + + return await ctx.send(f"bleh, untrusted `{address}` -_-") + +async def setup(bot: Rob): + return await bot.add_cog(ConfigCog(bot)) \ No newline at end of file diff --git a/src/cogs/dev.py b/src/cogs/dev.py new file mode 100644 index 0000000..817606f --- /dev/null +++ b/src/cogs/dev.py @@ -0,0 +1,36 @@ +import io +from discord.ext import commands +from core.client import Rob +from contextlib import redirect_stdout + +@commands.is_owner() +class DevCog(commands.Cog): + def __init__(self, bot: Rob): + super().__init__() + self.bot = bot + + @commands.command(name="eval", description="Evaluates Python code.") + async def eval(self, ctx: commands.Context, *, code: str): + print(f":: [SECURITY WARNING] - executing eval '{code}'.") + buffer = io.StringIO() + + try: + with redirect_stdout(buffer): + exec(code) + + output = buffer.getvalue() or "(no output)" + await ctx.channel.send(f"```\n{output}\n```") + except Exception as e: + await ctx.channel.send(f"```\n{type(e).__name__}: {e}\n```") + return + + @commands.command(name="reload", description="Reloads a loaded cog.") + async def reload(self, ctx: commands.Context, cog: str): + try: + await self.bot.reload_extension(f"cogs.{cog}") + return await ctx.reply(f":: Cog {cog} loaded successfully") + except Exception as e: + return await ctx.reply(f":: Cog {cog} failed to load: {e}") + +async def setup(bot: Rob): + return await bot.add_cog(DevCog(bot)) \ No newline at end of file diff --git a/src/cogs/info.py b/src/cogs/info.py new file mode 100644 index 0000000..ec49dae --- /dev/null +++ b/src/cogs/info.py @@ -0,0 +1,46 @@ +from discord.ext import commands +from core.client import Rob + +class InfoCog(commands.Cog): + def __init__(self, bot: Rob): + super().__init__() + self.bot = bot + + @commands.hybrid_command( + name="help", + description="Sends a link to Rob's official website." + ) + async def help(self, ctx: commands.Context): + return await ctx.send("go to https://dogo6647.github.io/rob for help :)") + + @commands.hybrid_command( + name="about", + description="Shows bot credits and interaction stats." + ) + async def about(self, ctx: commands.Context): + self.bot.reset_stats() + + ranked = [] + users = sum(g.member_count for g in self.bot.guilds) + + await ctx.send(f"haiiiii im rob, a conversational bot created by `dogo6647` :)") + await ctx.send(f"im currently in {len(self.bot.guilds)} servers and have met {users} users, isnt that cool? :D") + + this_guild_msgs = self.bot.guild_daily_stats[ctx.guild.id] + for guild in self.bot.guilds: + if "COMMUNITY" not in guild.features: + continue + if guild.member_count <= 50: + continue + msg_count = self.bot.guild_daily_stats[guild.id] + ranked.append((guild.name, guild.member_count, msg_count)) + + ranked.sort(key=lambda x: x[2], reverse=True) + top_10 = ranked[:10] + + if top_10: + top_servers = "\n".join(f"{i+1}. {name} ({members} members) - {msgs} interactions today" for i, (name, members, msgs) in enumerate(top_10)) + await ctx.send(f"i've sent {this_guild_msgs} messages in this server today, heres today's biggest rob addicts:\n```{top_servers}```") + +async def setup(bot: Rob): + return await bot.add_cog(InfoCog(bot)) \ No newline at end of file diff --git a/src/cogs/mail.py b/src/cogs/mail.py new file mode 100644 index 0000000..1a22d40 --- /dev/null +++ b/src/cogs/mail.py @@ -0,0 +1,111 @@ +import re +from discord import app_commands, Embed, Guild +from discord.ext import commands +from core.client import Rob +from components.mail import LetterView, PhonebookView +from util.config import load_config, get_mail_channel + +class MailCog(commands.Cog): + def __init__(self, bot: Rob): + super().__init__() + self.bot = bot + + def guild_address(self, guild: Guild): + slug = re.sub(r"[^a-z0-9]+", "-", guild.name.lower()) + slug = slug.strip("-") + return f"{slug}-{str(guild.id)[-2:]}" + + @commands.hybrid_command( + name="address", + description="Shows your server's robmail address." + ) + async def address(self, ctx: commands.Context): + return await ctx.send(f"this server's address is `{self.guild_address(ctx.guild)}`") + + @commands.hybrid_command( + name="phonebook", + description="Presents all addresses trusted by the current server." + ) + async def phonebook(self, ctx: commands.Context): + trusted = self.bot.config.get("mailTrusted", []) + + if not trusted: + return await ctx.send( + "this server's phonebook is empty :(\nuse `#!trust
` to add servers" + ) + + entries = [] + + for address in trusted: + guild_name = "unknown server" + for guild in self.bot.guilds: + if self.guild_address(guild) == address: + guild_name = guild.name + break + + entries.append((guild_name, address)) + + view = PhonebookView(entries, ctx.author.id) + return await ctx.send(embed=view.make_embed(), view=view) + + @commands.hybrid_command( + name="send", + description="Sends a letter to a specified robmail address." + ) + @app_commands.describe( + address="Address of the server to send the letter to.", + message="Content of this letter." + ) + async def send(self, ctx: commands.Context, address: str, *, message: str): + if not address or not message: + return await ctx.send("its like #!send
", ephemeral=True) + + sender_address = self.guild_address(ctx.guild) + target_guild = None + + for guild in self.bot.guilds: + if self.guild_address(guild) == address: + target_guild = guild + break + + if target_guild is None: + await ctx.send("i couldn't find that server :(", ephemeral=True) + return + + sender_cfg = load_config(ctx.guild.id) + receiver_cfg = load_config(target_guild.id) + + if address not in sender_cfg["mailTrusted"]: + return await ctx.send(f"that address isn't on your trusted list, run '#!trust {address}'", ephemeral=True) + + if sender_address not in receiver_cfg["mailTrusted"]: + return await ctx.send(f"that server hasn't trusted you yet, ask them to run '#!trust {sender_address}'", ephemeral=True) + + channel = get_mail_channel(target_guild, receiver_cfg) + + if not channel: + return await ctx.send("that server has nowhere i can deliver mail :(", ephemeral=True) + + if not channel: + return await ctx.send("delivery failed :(", ephemeral=True) + + letter_text = ( + f"Dear {target_guild.name}:\n\n" + f"{message}\n\n" + f"- {message.author.name}" + ) + + embed = Embed( + title="📬 you've got mail!", + description=f"a letter has arrived from **{message.guild.name}**.", + color=0xF4D58D + ) + + await channel.send( + embed=embed, + view=LetterView(letter_text) + ) + return await ctx.send("letter delivered! :D", ephemeral=True) + +async def setup(bot: Rob): + return await bot.add_cog(MailCog(bot)) \ No newline at end of file diff --git a/src/cogs/misc.py b/src/cogs/misc.py new file mode 100644 index 0000000..4213233 --- /dev/null +++ b/src/cogs/misc.py @@ -0,0 +1,67 @@ +from aiohttp import ClientSession +from discord import app_commands +from discord.ext import commands +from core.client import Rob +from util.gen import generate_response +from util.sys import websearch + +class MiscCog(commands.Cog): + def __init__(self, bot: Rob): + super().__init__() + self.bot = bot + + @commands.hybrid_command( + name="dadjoke", + description="Sends a random joke from the icanhazadadjoke API." + ) + async def dadjoke(self, ctx: commands.Context): + async with ClientSession(headers={ + "Accept": "text/plain", + "User-Agent": "Rob/1.0.0 (Discord Bot; https://dogo6647.github.io/rob)" + }) as session: + async with session.get("https://icanhazdadjoke.com/") as response: + joke = await response.text() + return await ctx.send(joke) + + @commands.hybrid_command( + name="owobonk", + description="Hits Rob with the magic owo stick that temporarily uwuifies his responses.", + guild_only=True + ) + async def owobonk(self, ctx: commands.Context): + self.bot.history.append({ + "role": "system", + "content": "You have been hit with the OwO magic stik. Youw head huwts a wittwe, and you can onwwy tawwk in uwu femboy furry language fwom now on. Replace evewy 'r' you say with 'w'. Occassionawwy say stufz like *blushes*, *giggles*, rawr, and hehe~. Use '~' non-spawringwy." + }) + + response = await generate_response( + "BANNNNNNGGGG!!!!! say your head is feeling funny. Start your response with 'ow' or similar.", + self.bot.history, + self.bot.config.get("model"), + self.bot.config.get("dumb"), + f"the {ctx.guild.name} server" if ctx.guild else "DMs" + ) + await ctx.send(f"🪄💥 >~< {response}") + + @commands.hybrid_command( + name="search", + description="Searches for stuff on the web using DuckDuckGo and provides a Rob-certified™ summary.", + ) + @app_commands.describe(query="Query to search on the web.") + async def search(self, ctx: commands.Context, query: str): + async def update_status(text): + await ctx.send(text) + + result = await websearch(query, update_status) + response = await generate_response( + f"Summarize the following text so that it's relevant to the conversation: '{result}'. Use the amount of words necessary to make a detailed explanation.", + self.bot.history, + self.bot.config.get("model"), + self.bot.config.get("dumb"), + f"the {ctx.guild.name} server" if ctx.guild else "DMs" + ) + await ctx.send(response) + return + +async def setup(bot: Rob): + return await bot.add_cog(MiscCog(bot)) \ No newline at end of file diff --git a/src/commands/about.obun.py b/src/commands/about.obun.py deleted file mode 100644 index c171a27..0000000 --- a/src/commands/about.obun.py +++ /dev/null @@ -1,23 +0,0 @@ - if message.content.startswith("#!about") and message.guild: - ranked = [] - reset_stats() - - await message.channel.send(f"haiiiii im rob, a conversational bot created by `dogo6647` :)") - await message.channel.send(f"im currently in {len(client.guilds)} servers and have met {sum(g.member_count for g in client.guilds)} users, isnt that cool? :D") - - this_guild_msgs = guild_daily_stats[message.guild.id] - for guild in client.guilds: - if "COMMUNITY" not in guild.features: - continue - if guild.member_count <= 50: - continue - msg_count = guild_daily_stats[guild.id] - ranked.append((guild.name, guild.member_count, msg_count)) - - ranked.sort(key=lambda x: x[2], reverse=True) - top_10 = ranked[:10] - - if top_10: - top_servers = "\n".join(f"{i+1}. {name} ({members} members) - {msgs} interactions today" for i, (name, members, msgs) in enumerate(top_10)) - await message.channel.send(f"i've sent {this_guild_msgs} messages in this server today, heres today's biggest rob addicts:\n```{top_servers}```") - return diff --git a/src/commands/address.obun.py b/src/commands/address.obun.py deleted file mode 100644 index 34d6c04..0000000 --- a/src/commands/address.obun.py +++ /dev/null @@ -1,3 +0,0 @@ - if message.content.startswith("#!address") and message.guild: - await message.channel.send(f"this server's address is `{guild_address(message.guild)}`") - return diff --git a/src/commands/dadjoke.obun.py b/src/commands/dadjoke.obun.py deleted file mode 100644 index e0a5821..0000000 --- a/src/commands/dadjoke.obun.py +++ /dev/null @@ -1,10 +0,0 @@ - import requests - if message.content.startswith("#!dadjoke") and message.guild: - try: - data = requests.get("https://icanhazdadjoke.com/", headers={"Accept": "application/json"}) - joke = data.json().get('joke') - await message.channel.send(f"{joke}") - except Exception as e: - await message.channel.send(f"sorry, cant fetch a dad joke rn ):") - await message.channel.send(f"you can try later tho :3") - return diff --git a/src/commands/eval.obun.py b/src/commands/eval.obun.py deleted file mode 100644 index 2868d8f..0000000 --- a/src/commands/eval.obun.py +++ /dev/null @@ -1,14 +0,0 @@ - if message.content.startswith("#!eval") and str(message.author.id) == str(OWNER_ID): - code = message.content.replace("#!eval ", "") - print(f":: [SECURITY WARNING] - executing eval '{code}'.") - buffer = io.StringIO() - - try: - with redirect_stdout(buffer): - exec(code) - - output = buffer.getvalue() or "(no output)" - await message.channel.send(f"```\n{output}\n```") - except Exception as e: - await message.channel.send(f"```\n{type(e).__name__}: {e}\n```") - return diff --git a/src/commands/help.obun.py b/src/commands/help.obun.py deleted file mode 100644 index 34aad76..0000000 --- a/src/commands/help.obun.py +++ /dev/null @@ -1,3 +0,0 @@ - if message.content.startswith("#!help") and message.guild: - await message.channel.send("go to https://dogo6647.github.io/rob for help :)") - return diff --git a/src/commands/option.obun.py b/src/commands/option.obun.py deleted file mode 100644 index f8fcf98..0000000 --- a/src/commands/option.obun.py +++ /dev/null @@ -1,46 +0,0 @@ - if message.content.startswith("#!option") and message.guild: - parts = message.content.split() - if len(parts) == 2: - await message.channel.send(f"its `{config[parts[1]]}`") - return - if len(parts) < 3: - await message.channel.send("#!option \nthats how you do it btw ;3") - return - - role = discord.utils.get(message.guild.roles, name="RobAdmin") - if role not in message.author.roles and not message.author.guild_permissions.administrator and str(message.author.id) != str(OWNER_ID): - await message.channel.send("...you dont have the RobAdmin role yk\ngonna need that to change my settings :3") - return - - option, value = parts[1], parts[2] - if option in config: - if not option == "model" and not option == "mailChannel" and not option == "mailTrusted": - if value.lower() in ["enable", "disable"]: - config[option] = value.lower() == "enable" - else: - try: - config[option] = int(value) - except ValueError: - await message.channel.send("no not like that :X\nuse `enable`, `disable`, or a number") - return - elif option == "mailChannel": - match = re.match(r"<#(\d+)>", value) - - if match: - config[option] = int(match.group(1)) - elif value.isdigit(): - config[option] = int(value) - else: - await message.channel.send("give me a channel mention or channel id") - return - elif option == "mailTrusted" or option == "model": - await message.channel.send("you cannot change that part of my config with #!option :X") - return - else: - config[option] = value - save_config(guild_id, config) - await message.channel.send(f"alr, `{option}` is now `{value}` :)") - else: - await message.channel.send(f"umm idk what a `{option}` is :/") - return - diff --git a/src/commands/owobonk.obun.py b/src/commands/owobonk.obun.py deleted file mode 100644 index d13359b..0000000 --- a/src/commands/owobonk.obun.py +++ /dev/null @@ -1,11 +0,0 @@ - if message.content.startswith("#!owobonk") and message.guild: - history.append({"role": "system", "content": "You have been hit with the OwO magic stik. Youw head huwts a wittwe, and you can onwwy tawwk in uwu femboy furry language fwom now on. Replace evewy 'r' you say with 'w'. Occassionawwy say stufz like *blushes*, *giggles*, rawr, and hehe~. Use '~' non-spawringwy."}) - response = await generate_response( - "BANNNNNNGGGG!!!!! say your head is feeling funny. Start your response with 'ow' or similar.", - history, - config.get("model"), - config.get("dumb"), - f"the {message.guild.name} server" if message.guild else "DMs" - ) - await message.channel.send(f"🪄💥 >~< {response}") - return diff --git a/src/commands/phonebook.obun.py b/src/commands/phonebook.obun.py deleted file mode 100644 index 965d582..0000000 --- a/src/commands/phonebook.obun.py +++ /dev/null @@ -1,24 +0,0 @@ - if message.content.startswith("#!phonebook") and message.guild: - trusted = config.get("mailTrusted", []) - - if not trusted: - await message.channel.send( - "this server's phonebook is empty :(\nuse `#!trust
` to add servers" - ) - return - - entries = [] - - for address in trusted: - guild_name = "unknown server" - - for guild in client.guilds: - if guild_address(guild) == address: - guild_name = guild.name - break - - entries.append((guild_name, address)) - - view = PhonebookView(entries, message.author.id) - await message.channel.send(embed=view.make_embed(), view=view) - return diff --git a/src/commands/search.obun.py b/src/commands/search.obun.py deleted file mode 100644 index 80beb99..0000000 --- a/src/commands/search.obun.py +++ /dev/null @@ -1,16 +0,0 @@ - if message.content.startswith("#!search"): - q = message.content.replace("#!search ", "") - - async def update_status(text): - await message.channel.send(text) - - result = await websearch(q, update_status) - response = await generate_response( - f"Summarize the following text so that it's relevant to the conversation: '{result}'. Use the amount of words necessary to make a detailed explanation.", - history, - config.get("model"), - config.get("dumb"), - f"the {message.guild.name} server" if message.guild else "DMs" - ) - await message.channel.send(response) - return diff --git a/src/commands/send.obun.py b/src/commands/send.obun.py deleted file mode 100644 index 7057d5a..0000000 --- a/src/commands/send.obun.py +++ /dev/null @@ -1,62 +0,0 @@ - if message.content.startswith("#!send") and message.guild: - parts = message.content.split(maxsplit=2) - - if len(parts) < 3: - await message.channel.send("its like #!send
") - return - - target_address = parts[1] - body = parts[2] - - sender_address = guild_address(message.guild) - target_guild = None - - for guild in client.guilds: - if guild_address(guild) == target_address: - target_guild = guild - break - - if target_guild is None: - await message.channel.send("i couldn't find that server :(") - return - - sender_cfg = load_config(message.guild.id) - receiver_cfg = load_config(target_guild.id) - - if target_address not in sender_cfg["mailTrusted"]: - await message.channel.send(f"that address isn't on your trusted list, run '#!trust {target_address}'") - return - - if sender_address not in receiver_cfg["mailTrusted"]: - await message.channel.send(f"that server hasn't trusted you yet, ask them to run '#!trust {sender_address}'") - return - - channel = get_mail_channel(target_guild, receiver_cfg) - - if not channel: - await message.channel.send("that server has nowhere i can deliver mail :(") - return - - if not channel: - await message.channel.send("delivery failed :(") - return - - letter_text = ( - f"Dear {target_guild.name}:\n\n" - f"{body}\n\n" - f"- {message.author.name}" - ) - - embed = discord.Embed( - title="📬 you've got mail!", - description=f"a letter has arrived from **{message.guild.name}**.", - color=0xF4D58D - ) - - await channel.send( - embed=embed, - view=LetterView(letter_text) - ) - - await message.channel.send("letter delivered! :D") - return diff --git a/src/commands/trustuntrust.obun.py b/src/commands/trustuntrust.obun.py deleted file mode 100644 index 00aaec2..0000000 --- a/src/commands/trustuntrust.obun.py +++ /dev/null @@ -1,43 +0,0 @@ - # /// Trust /// - if message.content.startswith("#!trust") and message.guild: - role = discord.utils.get(message.guild.roles, name="RobAdmin") - if role not in message.author.roles and not message.author.guild_permissions.administrator and str(message.author.id) != str(OWNER_ID): - await message.channel.send("...you dont have the RobAdmin role yk\ngonna need that to change my settings :3") - return - - parts = message.content.split(maxsplit=1) - - if len(parts) != 2: - await message.channel.send("its like #!trust
") - return - - address = parts[1].strip() - - if address not in config["mailTrusted"]: - config["mailTrusted"].append(address) - save_config(guild_id, config) - - await message.channel.send(f"trusted `{address}` :)") - return - - # /// Untrust /// - if message.content.startswith("#!untrust") and message.guild: - role = discord.utils.get(message.guild.roles, name="RobAdmin") - if role not in message.author.roles and not message.author.guild_permissions.administrator and str(message.author.id) != str(OWNER_ID): - await message.channel.send("...you dont have the RobAdmin role yk\ngonna need that to change my settings :3") - return - - parts = message.content.split(maxsplit=1) - - if len(parts) != 2: - await message.channel.send("its like #!untrust
") - return - - address = parts[1].strip() - - if address in config["mailTrusted"]: - config["mailTrusted"].remove(address) - save_config(guild_id, config) - - await message.channel.send(f"bleh, untrusted `{address}` -_-") - return diff --git a/src/components/letter.obun.py b/src/components/letter.obun.py deleted file mode 100644 index babedc3..0000000 --- a/src/components/letter.obun.py +++ /dev/null @@ -1,11 +0,0 @@ -class LetterView(discord.ui.View): - def __init__(self, letter_text): - super().__init__(timeout=None) - self.letter_text = letter_text - - @discord.ui.button(label="open letter!", style=discord.ButtonStyle.primary) - async def open_letter(self, interaction: discord.Interaction, button: discord.ui.Button): - await interaction.response.send_message( - self.letter_text, - ephemeral=True - ) diff --git a/src/components/phonebook.obun.py b/src/components/mail.py similarity index 80% rename from src/components/phonebook.obun.py rename to src/components/mail.py index 3bd7ad6..27f502d 100644 --- a/src/components/phonebook.obun.py +++ b/src/components/mail.py @@ -1,3 +1,17 @@ +import discord + +class LetterView(discord.ui.View): + def __init__(self, letter_text): + super().__init__(timeout=None) + self.letter_text = letter_text + + @discord.ui.button(label="open letter!", style=discord.ButtonStyle.primary) + async def open_letter(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.send_message( + self.letter_text, + ephemeral=True + ) + class PhonebookView(discord.ui.View): def __init__(self, entries, author_id): super().__init__(timeout=180) diff --git a/src/core/client.py b/src/core/client.py new file mode 100644 index 0000000..a120396 --- /dev/null +++ b/src/core/client.py @@ -0,0 +1,232 @@ +import os, random +from asyncio import sleep +from datetime import datetime +from discord import Intents, Message +from discord.ext import commands, tasks +from discord.gateway import DiscordWebSocket +from collections import defaultdict, deque + +from .ws import MobileWebSocket +from util.config import load_config, save_config, get_mail_channel +from util.gen import generate_response +from util.sys import process_msg, websearch +from util.status import STATUSES + +CHANGELOG_FILE = "changelog.txt" +ENABLED_COGS = ["config", "dev", "info", "mail", "misc", "util"] +TIN_CAN_CHANCE = 0.005 + +class Rob(commands.Bot): + changelog_checked = False + # Store message history per server and per user in DMs + dm_message_histories = defaultdict(lambda: deque(maxlen=24)) + guild_message_histories = defaultdict(lambda: deque(maxlen=24)) + guild_daily_stats = defaultdict(int) + stats_day = datetime.utcnow().date() + + def __init__(self): + intents = Intents.default() + intents.messages = True + intents.guilds = True + intents.members = True + intents.dm_messages = True + intents.message_content = True + + super().__init__( + command_prefix="#!", + intents=intents, + help_command=None # this is overwritten in the Info cog + ) + + async def setup_hook(self): + for cog in ENABLED_COGS: + try: + await self.load_extension(f"cogs.{cog}") + print(f":: Cog {cog} loaded successfully") + except Exception as e: + print(f":: Cog {cog} failed to load: {e}") + await self.tree.sync() + + async def on_ready(self): + print(f':: Logged in as {self.user}') + #print(":: Guilds:") # should not normally be enabled in large instances + #for guild in client.guilds: + # print(f"- {guild.name} | owned by {guild.owner} | {guild.member_count} members") + + if not changelog_checked: + changelog_checked = True + await self.broadcast() + + self.loop.create_task(self.send_random_message) + self.change_status.start() + + async def on_guild_join(guild): + config = load_config(guild.id) + save_config(guild.id, config) + channel = get_mail_channel(guild, config) + if channel: + await channel.send("hi! visit https://dogo6647.github.io/rob to learn how to set me up :)") + + async def on_message(self, message: Message): + if message.guild: + guild_id = message.guild.id + config = load_config(guild_id) + if config["listen"] and message.author != self.user: + self.guild_message_histories[guild_id].append({ + "role": "user", + "content": process_msg(message) + }) + history = self.guild_message_histories[guild_id] + if not message.channel.permissions_for(message.guild.me).send_messages: + return + else: + user_id = message.author.id + userconfig = "userland" + config = load_config(userconfig) + if config["listen"] and message.author != self.user: + self.dm_message_histories[user_id].append({ + "role": "user", + "content": process_msg(message) + }) + history = self.dm_message_histories[user_id] + + if message.author == self.user: + history.append({"role": "assistant", "content": message.content}) + return # Ignore itself lol + + if not config["listen"]: + return + + should_respond = message.mention_everyone or self.user.mentioned_in(message) or random.random() < (config["responseFrequency"] / 100) + should_reply = self.user.mentioned_in(message) or message.reference is not None + + if should_respond: + async with message.channel.typing(): + num_responses = random.choices([1, 2], weights=[85, 15], k=1)[0] + + for i in range(num_responses): + if random.random() < TIN_CAN_CHANCE: + if random.randint(0, 1) == 0: + response = "*tin can noises*" + else: + response = "https://odysea.us.to/assets/dump/iamarobot.mov" + else: + #print('response' if i == 0 else 'continuation') + response = await generate_response( + 'respond' if i == 0 else 'continue Rob\'s previous message', + history, + config.get("model"), + config.get("dumb"), + f"the {message.guild.name} server" if message.guild else "DMs" + ) + + if (history and history[-1]["role"] == "assistant" and history[-1]["content"] == response): + continue + + if "[searchfor: " in response: + should_reply = False + + if should_reply and i == 0: + await message.reply(response, mention_author=False) + else: + if "[searchfor: " in response: + async def update_status(text): + await message.channel.send(text) + + q = response[len("[searchfor:"): -1].strip() + result = await websearch(q, update_status) + response = await generate_response( + f"Summarize the following text so that it's relevant to the conversation: '{result}'. Use the amount of words necessary to make a detailed explanation.", + history, + config.get("model"), + config.get("dumb"), + f"the {message.guild.name} server" if message.guild else "DMs" + ) + await message.channel.send(response) + else: + await message.channel.send(response) + + # sleep between responses, not after the last one + if i < num_responses - 1: + await sleep(random.uniform(0.5, 2)) + + if message.guild: + self.guild_daily_stats[message.guild.id] += num_responses + + async def change_status(self): + global current_status + current_status = random.choice(STATUSES) + if current_status is None: + await self.change_presence(activity=None) + else: + await self.change_presence(activity=current_status) + print(f"Changed status to: {current_status.name if current_status else 'nothing'}") + + async def reset_stats(self): + today = datetime.utcnow().date() + if today != self.stats_day: + self.guild_daily_stats.clear() + self.stats_day = today + + async def broadcast(self): + if not os.path.exists(CHANGELOG_FILE): + return + + with open(CHANGELOG_FILE, "r", encoding="utf-8") as f: + raw = f.read() + + if raw.lstrip().startswith("[sent]"): + return + + changelog_text = raw.split("[sent]", 1)[0].strip() + if not changelog_text: + return + + print(":: Broadcasting changelog...") + + for guild in self.guilds: + try: + config = load_config(guild.id) + channel = get_mail_channel(guild, config) + + if not channel: + continue + + owner_ping = guild.owner.mention if guild.owner else "" + + await channel.send( + f"{owner_ping if '[noping]' not in changelog_text else ""}\n" + f"{changelog_text.replace('[noping]', '')}" + ) + + except Exception as e: + print(f":: Failed to send changelog to {guild.name}: {e}") + + with open(CHANGELOG_FILE, "w", encoding="utf-8") as f: + f.write("[sent]\n" + raw) + + print(":: Changelog marked as sent.") + + async def send_random_message(self): + await self.wait_until_ready() + while not self.is_closed(): + wait_time = random.randint(1, 480) * 60 + print(f":: Waiting for {wait_time} seconds before sending a random message.") + await sleep(wait_time) + for guild in self.guilds: + config = load_config(guild.id) + general_channels = [channel for channel in guild.text_channels if "general" in channel.name.lower()] + if config["randomlyMessage"] and general_channels: + channel = random.choice(general_channels) + if channel: + response = await generate_response( + "Say something as Rob based on the chat history; focus on the last sent message. If there are no messages, start the conversation by saying something interesting.", + self.guild_message_histories[guild.id], + config.get("model"), + config.get("dumb"), + f"the {guild.name} server" + ) + await channel.send(response) + self.guild_message_histories[guild.id].append({"role": "assistant", "content": response}) # {client.user.name} (you) + +DiscordWebSocket.identify = MobileWebSocket.identify diff --git a/src/core/ws.py b/src/core/ws.py new file mode 100644 index 0000000..da50a69 --- /dev/null +++ b/src/core/ws.py @@ -0,0 +1,38 @@ +import sys +from discord.gateway import DiscordWebSocket, _log + +class MobileWebSocket(DiscordWebSocket): + async def identify(self) -> None: + # Spoofs UA to mobile for funsies + payload = { + 'op': self.IDENTIFY, + 'd': { + 'token': self.token, + 'properties': { + 'os': sys.platform, + 'browser': 'Discord Android', + 'device': 'Discord Android', + }, + 'compress': True, + 'large_threshold': 250, + }, + } + + if self.shard_id is not None and self.shard_count is not None: + payload['d']['shard'] = [self.shard_id, self.shard_count] + + state = self._connection + if state._activity is not None or state._status is not None: + payload['d']['presence'] = { + 'status': state._status, + 'game': state._activity, + 'since': 0, + 'afk': False, + } + + if state._intents is not None: + payload['d']['intents'] = state._intents.value + + await self.call_hooks('before_identify', self.shard_id, initial=self._initial_identify) + await self.send_as_json(payload) + _log.debug('Shard ID %s has sent the IDENTIFY payload.', self.shard_id) \ No newline at end of file diff --git a/src/events/join.obun.py b/src/events/join.obun.py deleted file mode 100644 index 4fcdbcd..0000000 --- a/src/events/join.obun.py +++ /dev/null @@ -1,7 +0,0 @@ -@client.event -async def on_guild_join(guild): - config = load_config(guild.id) - save_config(guild.id, config) - channel = get_mail_channel(guild, config) - if channel: - await channel.send("hi! visit https://dogo6647.github.io/rob to learn how to set me up :)") diff --git a/src/events/message.obun.py b/src/events/message.obun.py deleted file mode 100644 index 05d4ecf..0000000 --- a/src/events/message.obun.py +++ /dev/null @@ -1,94 +0,0 @@ -@client.event -async def on_message(message): - if message.guild: - guild_id = message.guild.id - config = load_config(guild_id) - if config["listen"] and message.author != client.user: - guild_message_histories[guild_id].append({"role": "user", "content": process_msg(message)}) - history = guild_message_histories[guild_id] - if not message.channel.permissions_for(message.guild.me).send_messages: - return - else: - user_id = message.author.id - userconfig = "userland" - config = load_config(userconfig) - if config["listen"] and message.author != client.user: - dm_message_histories[user_id].append({"role": "user", "content": process_msg(message)}) - history = dm_message_histories[user_id] - - if message.author == client.user: - history.append({"role": "assistant", "content": message.content}) - return # Ignore itself lol - - # --- BOT COMMANDS --- - #:section src/commands/option.obun.py - #:section src/commands/help.obun.py - #:section src/commands/about.obun.py - #:section src/commands/eval.obun.py - #:section src/commands/address.obun.py - #:section src/commands/trustuntrust.obun.py - #:section src/commands/send.obun.py - #:section src/commands/phonebook.obun.py - #:section src/commands/dadjoke.obun.py - #:section src/commands/owobonk.obun.py - #:section src/commands/search.obun.py - # ------------------ - - if not config["listen"]: - return - - should_respond = message.mention_everyone or client.user.mentioned_in(message) or random.random() < (config["responseFrequency"] / 100) - should_reply = client.user.mentioned_in(message) or message.reference is not None - - if should_respond: - async with message.channel.typing(): - num_responses = random.choices([1, 2], weights=[85, 15], k=1)[0] - - for i in range(num_responses): - if random.random() < tin_can_chance: - if random.randint(0, 1) == 0: - response = "*tin can noises*" - else: - response = "https://odysea.us.to/assets/dump/iamarobot.mov" - else: - #print('response' if i == 0 else 'continuation') - response = await generate_response( - 'respond' if i == 0 else 'continue Rob\'s previous message', - history, - config.get("model"), - config.get("dumb"), - f"the {message.guild.name} server" if message.guild else "DMs" - ) - - if (history and history[-1]["role"] == "assistant" and history[-1]["content"] == response): - continue - - if "[searchfor: " in response: - should_reply = False - - if should_reply and i == 0: - await message.reply(response, mention_author=False) - else: - if "[searchfor: " in response: - async def update_status(text): - await message.channel.send(text) - - q = response[len("[searchfor:"): -1].strip() - result = await websearch(q, update_status) - response = await generate_response( - f"Summarize the following text so that it's relevant to the conversation: '{result}'. Use the amount of words necessary to make a detailed explanation.", - history, - config.get("model"), - config.get("dumb"), - f"the {message.guild.name} server" if message.guild else "DMs" - ) - await message.channel.send(response) - else: - await message.channel.send(response) - - # sleep between responses, not after the last one - if i < num_responses - 1: - await asyncio.sleep(random.uniform(0.5, 2)) - - if message.guild: - guild_daily_stats[message.guild.id] += num_responses diff --git a/src/events/ready.obun.py b/src/events/ready.obun.py deleted file mode 100644 index d918346..0000000 --- a/src/events/ready.obun.py +++ /dev/null @@ -1,31 +0,0 @@ -async def send_random_message(): - await client.wait_until_ready() - while not client.is_closed(): - wait_time = random.randint(1, 480) * 60 - print(f":: Waiting for {wait_time} seconds before sending a random message.") - await asyncio.sleep(wait_time) - for guild in client.guilds: - config = load_config(guild.id) - general_channels = [channel for channel in guild.text_channels if "general" in channel.name.lower()] - if config["randomlyMessage"] and general_channels: - channel = random.choice(general_channels) - if channel: - response = await generate_response("Say something as Rob based on the chat history; focus on the last sent message. If there are no messages, start the conversation by saying something interesting.", guild_message_histories[guild.id], config.get("model"), config.get("dumb"), f"the {guild.name} server") - await channel.send(response) - guild_message_histories[guild.id].append({"role": "assistant", "content": response}) # {client.user.name} (you) - -@client.event -async def on_ready(): - global changelog_checked - - print(f':: Logged in as {client.user}') - #print(":: Guilds:") # should not normally be enabled in large instances - #for guild in client.guilds: - # print(f"- {guild.name} | owned by {guild.owner} | {guild.member_count} members") - - if not changelog_checked: - changelog_checked = True - await broadcast() - - client.loop.create_task(send_random_message()) - change_status.start() diff --git a/src/guildconfig.obun.py b/src/guildconfig.obun.py deleted file mode 100644 index 0e6ab65..0000000 --- a/src/guildconfig.obun.py +++ /dev/null @@ -1,12 +0,0 @@ -def load_config(guild_id): - config_path = os.path.join(CONFIG_DIR, f"{guild_id}.json") - if os.path.exists(config_path): - with open(config_path, "r") as f: - return json.load(f) - return {"randomlyMessage": False, "responseFrequency": 4, "listen": True, "dumb": False, "mailTrusted": [], "mailChannel": None} - -def save_config(guild_id, config): - os.makedirs(CONFIG_DIR, exist_ok=True) - config_path = os.path.join(CONFIG_DIR, f"{guild_id}.json") - with open(config_path, "w") as f: - json.dump(config, f, indent=4) diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..7c4d6d6 --- /dev/null +++ b/src/main.py @@ -0,0 +1,19 @@ +import os +from dotenv import load_dotenv +from core.client import Rob + +def main(): + load_dotenv(".env") + TOKEN = os.getenv("TOKEN", None) + + bot = Rob() + try: + bot.run(TOKEN) + except KeyboardInterrupt: + print(':: Program interrupted, shutting down gracefully') + bot.close() + except Exception as e: + print(f':: Unknown error: {e}') + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/stats.obun.py b/src/stats.obun.py deleted file mode 100644 index a979e72..0000000 --- a/src/stats.obun.py +++ /dev/null @@ -1,11 +0,0 @@ -guild_daily_stats = defaultdict(int) -stats_day = datetime.utcnow().date() - -def reset_stats(): - global stats_day, guild_daily_stats - - today = datetime.utcnow().date() - - if today != stats_day: - guild_daily_stats.clear() - stats_day = today diff --git a/src/util/config.py b/src/util/config.py new file mode 100644 index 0000000..00eb3d4 --- /dev/null +++ b/src/util/config.py @@ -0,0 +1,57 @@ +import discord, json, os +from discord.ext import commands + +OWNER_ID = os.getenv("OWNER_ID", None) +CONFIG_DIR = "../rob-config/" + +def load_config(guild_id): + config_path = os.path.join(CONFIG_DIR, f"{guild_id}.json") + if os.path.exists(config_path): + with open(config_path, "r") as f: + return json.load(f) + return {"randomlyMessage": False, "responseFrequency": 4, "listen": True, "dumb": False, "mailTrusted": [], "mailChannel": None} + +def save_config(guild_id, config): + os.makedirs(CONFIG_DIR, exist_ok=True) + config_path = os.path.join(CONFIG_DIR, f"{guild_id}.json") + with open(config_path, "w") as f: + json.dump(config, f, indent=4) + +def get_mail_channel(guild, config): + # explicitly configured channel prioritized++ + if config.get("mailChannel"): + channel = guild.get_channel(config["mailChannel"]) + if channel and isinstance(channel, discord.TextChannel) and channel.permissions_for(guild.me).send_messages: + return channel + + # try common names + preferred = ["general", "main", "chat", "lobby", "discussion"] + for name in preferred: + for channel in guild.text_channels: + if channel.name.lower() == name: + if channel.permissions_for(guild.me).send_messages: + return channel + + # channels containing "general" + for channel in guild.text_channels: + if "general" in channel.name.lower(): + if channel.permissions_for(guild.me).send_messages: + return channel + + # first writable text channel as last resort + for channel in guild.text_channels: + if channel.permissions_for(guild.me).send_messages: + return channel + + return None + +async def has_rob_admin(ctx: commands.Context): + if not ctx.guild: + return False + + role = discord.utils.get(ctx.guild.roles, name="RobAdmin") + if role not in ctx.author.roles and not ctx.author.guild_permissions.administrator and str(ctx.author.id) != str(OWNER_ID): + await ctx.send("...you dont have the RobAdmin role yk\ngonna need that to change my settings :3", ephemeral=True) + return False + + return True \ No newline at end of file diff --git a/src/response.obun.py b/src/util/gen.py similarity index 71% rename from src/response.obun.py rename to src/util/gen.py index 601b50d..d74e359 100644 --- a/src/response.obun.py +++ b/src/util/gen.py @@ -1,3 +1,28 @@ +import aiohttp, os, random, re +from .markov import MarkovChain, MARKOV_CONFIG +from .sys import apply_dialect + +LLM_KEY = os.getenv("LLM_KEY", None) +LLM_LOCAL_URL = os.getenv("LLM_LOCAL_URL", "http://localhost:4891/v1/chat/completions") +LLM_PROXY_URL = os.getenv("LLM_PROXY_URL", "https://api.groq.com/openai/v1/chat/completions") +ERROR_MESSAGES = [ + "gimme a sec i have other servers to talk to", + "just a sec pls", + "hold on", + "lemme look that up", + "hold on im hungry *chip bag noises*", + "maybe", + "yes", + "yeahhhh :D", + "no", + ":) shut up", + "whar :)", + "what", + "idk what your talkin abt :3", + "ig :P", + "idk :P" +] + def get_content(obj): last = None if isinstance(obj, dict): @@ -14,10 +39,43 @@ def get_content(obj): last = found return last -async def generate_response(prompt, history, model="llama-3.1-8b-instant", dumb=False, where="a server"): +def generate_markov(history) -> str: + # generate markov + texts = [] + + for msg in history: + content = msg.get("content", "") + + if isinstance(content, list): + parts = [] + for item in content: + if item.get("type") == "text": + parts.append(item.get("text", "")) + + content = " ".join(parts) + + texts.append(content) + + # last message becomes the point of departure + prompt = texts[-1] + + # train chain from conversation history + chain = MarkovChain(order=MARKOV_CONFIG["chain_order"]) + chain.train(texts) + + output = chain.generate(prompt, max_tokens=MARKOV_CONFIG["max_tokens"]) + return output + +async def generate_response( + prompt, + history, + model="llama-3.1-8b-instant", + dumb=False, + where="a server" +): global current_status if dumb: - model="cas/llama-3.2-1b-instruct" + return generate_markov(history) else: model="meta-llama/llama-4-scout-17b-16e-instruct" #model="llama-3.3-70b-versatile" @@ -36,7 +94,7 @@ async def generate_response(prompt, history, model="llama-3.1-8b-instant", dumb= "stream": False } #print(f":: Dropping the payload: \n {payload}") # debug, should not normally enable - async with session.post(LLM_LOCAL_URL if dumb else LLM_PROXY_URL, json=payload, headers={"Authorization": f"Bearer {LLM_KEY}"}) as resp: + async with session.post(LLM_PROXY_URL, json=payload, headers={"Authorization": f"Bearer {LLM_KEY}"}) as resp: if resp.status == 200: data = await resp.json() #print(data) # request data for debugging, should not be uncommented normally @@ -56,24 +114,7 @@ async def generate_response(prompt, history, model="llama-3.1-8b-instant", dumb= # /// ERROR MESSAGES /// if resp.status == 429 or resp.status == 402: - errmsgs = [ - "gimme a sec i have other servers to talk to", - "just a sec pls", - "hold on", - "lemme look that up", - "hold on im hungry *chip bag noises*", - "maybe", - "yes", - "yeahhhh :D", - "no", - ":) shut up", - "whar :)", - "what", - "idk what your talkin abt :3", - "ig :P", - "idk :P" - ] - return random.choice(errmsgs) + return random.choice(ERROR_MESSAGES) elif resp.status == 413: return "bro sent me the entire internet" elif resp.status == 500: diff --git a/src/util/markov.py b/src/util/markov.py new file mode 100644 index 0000000..a242b99 --- /dev/null +++ b/src/util/markov.py @@ -0,0 +1,121 @@ +from flask import Flask, request, jsonify +from collections import defaultdict +import random +import re +import uuid + +app = Flask(__name__) + +MARKOV_CONFIG = { + "chain_order": 2, # n-gram size + "max_tokens": 60, # max generated tokens + "temperature": 1.0, # randomness scaling + "fallback_to_random": True +} + +# --- Tokenization --- +TOKEN_REGEX = re.compile(r"\w+|[^\w\s]") + +def tokenize(text): + return TOKEN_REGEX.findall(text.lower()) + +def untokenize(tokens): + out = [] + + for token in tokens: + if token in ".,!?;:": + if out: + out[-1] += token + else: + out.append(token) + else: + out.append(token) + + return " ".join(out) + + +# --- Markov --- +class MarkovChain: + def __init__(self, order=2): + self.order = order + self.chain = defaultdict(list) + self.starts = [] + + def train(self, texts): + for text in texts: + tokens = tokenize(text) + + if len(tokens) < self.order + 1: + continue + + self.starts.append(tuple(tokens[:self.order])) + + for i in range(len(tokens) - self.order): + key = tuple(tokens[i:i + self.order]) + next_token = tokens[i + self.order] + self.chain[key].append(next_token) + + def find_best_seed(self, prompt): + prompt_tokens = tokenize(prompt) + + # try longest matching suffix first + for size in range(self.order, 0, -1): + if len(prompt_tokens) < size: + continue + + suffix = tuple(prompt_tokens[-size:]) + + for key in self.chain.keys(): + if key[:size] == suffix: + return key + + return random.choice(self.starts) if self.starts else None + + def sample_next(self, choices): + if not choices: + return None + + # meh temperature support + counts = defaultdict(int) + + for token in choices: + counts[token] += 1 + + weighted = [] + + for token, count in counts.items(): + weight = count ** (1.0 / max(MARKOV_CONFIG["temperature"], 0.01)) + weighted.append((token, weight)) + + total = sum(w for _, w in weighted) + r = random.uniform(0, total) + + upto = 0 + + for token, weight in weighted: + upto += weight + if upto >= r: + return token + + return random.choice(choices) + + def generate(self, prompt, max_tokens=50): + seed = self.find_best_seed(prompt) + + if not seed: + return "hehe whar" + + generated = list(seed) + + for _ in range(max_tokens): + key = tuple(generated[-self.order:]) + + next_choices = self.chain.get(key) + + if not next_choices: + break + + next_token = self.sample_next(next_choices) + generated.append(next_token) + + return untokenize(generated) diff --git a/src/statuses.obun.py b/src/util/status.py similarity index 89% rename from src/statuses.obun.py rename to src/util/status.py index dbb3700..b612181 100644 --- a/src/statuses.obun.py +++ b/src/util/status.py @@ -1,4 +1,6 @@ -statuses = [ +import discord + +STATUSES = [ discord.Game("Minecraft"), discord.Game("Minceraft"), discord.Game("Minecraft with garmin"), @@ -55,13 +57,3 @@ discord.Activity(type=discord.ActivityType.listening, name="dj toenail"), None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None ] - -@tasks.loop(minutes=5) -async def change_status(): - global current_status - current_status = random.choice(statuses) - if current_status is None: - await client.change_presence(activity=None) - else: - await client.change_presence(activity=current_status) - print(f"Changed status to: {current_status.name if current_status else 'nothing'}") diff --git a/src/sysutils.obun.py b/src/util/sys.py similarity index 92% rename from src/sysutils.obun.py rename to src/util/sys.py index aea81b4..c937365 100644 --- a/src/sysutils.obun.py +++ b/src/util/sys.py @@ -1,3 +1,24 @@ +import asyncio, json, os, re, tempfile +import aiohttp, cv2, pytesseract, requests +from bs4 import BeautifulSoup +from collections import Counter +from ddgs import DDGS +from PIL import Image + +CONFIG_DIR = "../rob-config/" +DIALECT_PATH = "dialect.json" +MNSSD_PROTO = "include/MobileNetSSD_deploy.prototxt" +MNSSD_MODEL = "include/MobileNetSSD_deploy.caffemodel" +net = cv2.dnn.readNetFromCaffe(MNSSD_PROTO, MNSSD_MODEL) + +def load_dialect(): + if os.path.exists(DIALECT_PATH): + with open(DIALECT_PATH, "r", encoding="utf-8") as f: + return json.load(f) + return {} + +dialect_map = load_dialect() + def apply_dialect(text: str) -> str: for original, replacement in dialect_map.items(): pattern = r'\b' + re.escape(original) + r'\b' @@ -16,6 +37,7 @@ def guild_address(guild): "person", "pottedplant", "sheep", "sofa", "train", "tvmonitor" ] + def describe(image_url: str, conf_threshold: float = 0.35) -> str: try: response = requests.get(image_url, timeout=10)