Author SHA1 Message Date
AllfatherHatt 78e9cc11a1 Merge branch 'master' into 9month-revision-update2 2026-03-16 13:21:19 +01:00
AllfatherHatt 18137c054b Updating feedback 2026-03-16 13:20:03 +01:00
AllfatherHatt 40c1f9dcb4 Merge pull request 'Attempt to adding live feedback' (#21) from 9month-revision-update2 into master
Reviewed-on: #21
2026-03-16 12:51:42 +01:00
AllfatherHatt 83dcdd9277 Attempt to adding live feedback 2026-03-16 12:50:53 +01:00
AllfatherHatt 0053d95c4d Merge pull request 'Fixing wrong parameters' (#20) from 9month-revision-update into master
Reviewed-on: #20
2026-03-16 12:37:30 +01:00
AllfatherHatt c8cd409834 Fixing wrong parameters 2026-03-16 12:36:36 +01:00
AllfatherHatt 7186c4ca32 Merge pull request '9month-revision-reginald' (#19) from 9month-revision-reginald into master
Reviewed-on: #19
2026-03-16 12:21:17 +01:00
AllfatherHatt c6bb3afa70 Revision 2026-03-16 12:16:29 +01:00
AllfatherHatt f8fc144d5d Merge branch 'master' of https://manufactorum.kanium.org/Kanium-PUBLIC/KaniumCogs 2026-03-16 12:14:11 +01:00
AllfatherHatt 8f0ece3570 Adding updated debug stuff 2026-03-16 12:14:01 +01:00
T-BENZIN 8e1245484f Merge pull request 'Added aliases for spelling metre/kilometre' (#18) from metre-patch into master
Reviewed-on: #18
Reviewed-by: AllfatherHatt <[email protected]>
2026-02-08 05:51:46 +01:00
T-BENZIN 9df44d3742 Added aliases for spelling metre/kilometre 2026-02-08 05:46:02 +01:00
AllfatherHatt fa1be19767 Merge pull request 'Aligning activity logger' (#17) from debug_fitness_more into master
Reviewed-on: #17
2026-02-04 18:25:08 +01:00
AllfatherHatt f22f1d46d1 Merged 2026-02-04 18:23:41 +01:00
AllfatherHatt 7bcdae7b79 Aligning activity logger 2026-02-04 18:18:58 +01:00
AllfatherHatt d51bc4965e Merge pull request 'feature_fitness_logger' (#16) from feature_fitness_logger into master
Reviewed-on: #16
2026-02-04 18:01:02 +01:00
AllfatherHatt d7a6ca31d1 Merge branch 'master' into feature_fitness_logger 2026-02-04 17:55:54 +01:00
AllfatherHatt 6cf112be49 Merge pull request 'Attempting to be a bit more direct about activity path' (#15) from debug_fitness into master
Reviewed-on: #15
Reviewed-by: T-BENZIN <[email protected]>
2026-02-04 17:50:44 +01:00
T-BENZIN f20a4abc81 Original message is put in lower case whole and redundant .lower() methods are removed.
Modified usage message.
2026-02-04 21:42:25 +05:00
AllfatherHatt 175eeced0c Attempting to be a bit more direct about activity path 2026-02-04 14:02:16 +01:00
AllfatherHatt 2be9cc5795 Merge pull request 'Called correct function' (#14) from minor-typo-fix into master
Reviewed-on: #14
Reviewed-by: T-BENZIN <[email protected]>
2026-02-04 13:42:29 +01:00
AllfatherHatt 07a2bf7e38 Merge pull request 'Fixed wrong json key being pulled (I forgot to replace the old name)' (#13) from json-key-fix into master
Reviewed-on: #13
Reviewed-by: AllfatherHatt <[email protected]>
2026-02-04 12:32:49 +01:00
AllfatherHatt 1ee949eae2 Called correct function 2026-02-04 12:29:22 +01:00
T-BENZIN 8bec9a159b Fixed wrong json key being pulled (I forgot to replace the old name) 2026-02-04 12:10:34 +01:00
AllfatherHatt ee77279d6e Merge pull request 'Fixed minor import issue' (#12) from minor-import-fix into master
Reviewed-on: #12
2026-02-04 11:46:39 +01:00
AllfatherHatt 1b67240976 Fixed minor import issue 2026-02-04 11:44:44 +01:00
AllfatherHatt 16a19e6913 Merge pull request 'Added cog for fitness activity logging feature.' (#11) from feature_fitness_logger into master
Reviewed-on: #11
Reviewed-by: AllfatherHatt <[email protected]>
2026-02-04 11:14:40 +01:00
T-BENZIN 4ae03c9b9d Added cog for fitness activity logging feature.
At its starting state it would accept following commands:
- !getunits <activity>: Would send a JSON file with activity units via Discord message. Those files are responsible for setting up a fitness activity to log.
- !getlog <activity>: Would send a CSV file with fitness activity log. Those files contain records with timestamp, username, and reported activity measurements.
- !<activity> <value> <unit (optional)>: Would create a record with your fitness activity in corresponding CSV log file. If you choose to report different unit (feet, for example), it is going to convert as specified in its corresponding JSON file with units (to meters, for example) if the activity specified as convertable. Non-convertable activities' units are ignored.
2026-01-11 10:03:55 +05:00
T-BENZIN 074ff8bbb4 Merge pull request 'Made improvements in AI Agent's tool get_forecast_weather' (#8) from dev into master
Reviewed-on: #8
Reviewed-by: AllfatherHatt <[email protected]>
2025-06-18 17:13:22 +02:00
18 changed files with 856 additions and 786 deletions
+1
View File
@@ -2,6 +2,7 @@
cogs made for our Royal butt.
## Our Cogs:
- [FitnessCog](./fitnessCog)
- [RecruitmentCog](./recruitmentCog/)
- [ReginaldCog](./reginaldCog/)
- [TrafficCog](./trafficCog)
+6
View File
@@ -0,0 +1,6 @@
from redbot.core.bot import Red
from .fitness import FitnessCog
async def setup(bot: Red):
cog = FitnessCog(bot)
await bot.add_cog(cog)
+52
View File
@@ -0,0 +1,52 @@
{
"convertable": true,
"units": {
"meter": {
"factor": 1,
"aliases": [
"meter",
"meters",
"metre",
"metres",
"m",
"m."
]
},
"kilometer": {
"factor": 1000,
"aliases": [
"kilometer",
"kilometers",
"kilometre",
"kilometres",
"km",
"km."
]
},
"foot": {
"factor": 0.3048,
"aliases": [
"foot",
"feet",
"ft",
"ft."
]
},
"mile": {
"factor": 1609.344,
"aliases": [
"mile",
"miles",
"mi",
"mi."
]
},
"step": {
"factor": 0.18,
"aliases": [
"step",
"steps"
]
}
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"convertable": false
}
+105
View File
@@ -0,0 +1,105 @@
import csv
import json
from pathlib import Path
from typing import Optional
def activity_log_path(activity: str, logs_dir: Optional[Path] = None) -> Path:
"""
Returns the path of the activity log file in .csv format.
"""
logs_dir = logs_dir or (Path.cwd() / "activity_logs")
return logs_dir / f"{activity}.csv"
def activity_units_path(activity: str, units_dir: Optional[Path] = None) -> Path:
"""
Returns the path of the activity units file in .json format.
"""
units_dir = units_dir or (Path.cwd() / "activities")
return units_dir / f"{activity}.json"
def is_convertable(activity: str, units_dir: Optional[Path] = None) -> bool:
filename = activity_units_path(activity, units_dir)
with filename.open("r", encoding="utf-8") as f:
raw_data = json.load(f)
return bool(raw_data.get("convertable"))
def create_activity_log_file(
activity: str,
*,
units_dir: Optional[Path] = None,
logs_dir: Optional[Path] = None,
) -> None:
if not activity_units_path(activity, units_dir).exists():
raise ValueError(f"{activity} is not a valid activity")
logs_dir = logs_dir or (Path.cwd() / "activity_logs")
logs_dir.mkdir(parents=True, exist_ok=True)
filename = activity_log_path(activity, logs_dir)
if filename.exists():
return # don't re-add headers
with filename.open("w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(["timestamp", "username", "value"])
def convert_units(
activity: str,
value: int | float,
unit: str | None,
*,
units_dir: Optional[Path] = None,
) -> float:
filename = activity_units_path(activity, units_dir)
if not filename.exists():
raise ValueError(f"{activity} is not a valid activity")
# Consider None unit value as factor of 1
if unit is None:
return float(value)
with filename.open("r", encoding="utf-8") as f:
raw_data = json.load(f)
units_data = raw_data.get("units") or {}
unit_l = unit.lower()
for _, data in units_data.items():
aliases = [a.lower() for a in (data.get("aliases") or [])]
if unit_l in aliases:
return float(value) * float(data["factor"])
raise ValueError(f"{unit} is not a valid unit")
def log_activity(
timestamp: int,
username: str,
activity: str,
value: int | float,
unit: str | None,
*,
units_dir: Optional[Path] = None,
logs_dir: Optional[Path] = None,
) -> None:
if not isinstance(value, (int, float)) or value <= 0:
raise ValueError(f"{value} is not a valid value")
filename = activity_log_path(activity, logs_dir)
if not filename.exists():
create_activity_log_file(activity, units_dir=units_dir, logs_dir=logs_dir)
if is_convertable(activity, units_dir):
converted_value = round(convert_units(activity, value, unit, units_dir=units_dir), 2)
else:
converted_value = round(float(value), 2)
with filename.open("a", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow([timestamp, username, converted_value])
+143
View File
@@ -0,0 +1,143 @@
import os
from pathlib import Path
import discord
from redbot.core import commands
from redbot.core.data_manager import cog_data_path
from .activity_logger import log_activity
class FitnessCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
default_threads = "1457402451530350727,1328363409648910356"
threads_raw = os.getenv("THREADS_ID", default_threads)
self.threads_id: list[int] = []
for x in threads_raw.split(","):
x = x.strip()
if x.isdigit():
self.threads_id.append(int(x))
self.confirm_reactions = os.getenv("CONFIRMATION_REACTIONS", "🐈💨")
# Units are shipped with the cog in: fitnessCog/activities/*.json
self.units_dir: Path = Path(__file__).parent / "activities"
# Logs are writable and belong in Red's data dir
self.logs_dir: Path = cog_data_path(self) / "activity_logs"
self.logs_dir.mkdir(parents=True, exist_ok=True)
@commands.Cog.listener()
async def on_message(self, message: discord.Message):
if message.author.bot:
return
if message.channel.id not in self.threads_id:
return
content = (message.content or "").strip()
if not content.startswith("!"):
return
cmdline = content[1:].strip().lower()
if not cmdline:
return
cmd, *rest = cmdline.split(" ", 1)
arg = rest[0].strip() if rest else ""
if cmd == "getunits":
await self.get_units_file(message, arg)
return
if cmd == "getlog":
await self.get_log_file(message, arg)
return
await self.log_fitness(message)
async def get_units_file(self, message: discord.Message, activity: str):
try:
if not activity:
await message.channel.send(
"Usage: `!getunits <activity>` (example: `!getunits pushups`)"
)
return
activity = activity.lower().strip()
path = self.units_dir / f"{activity}.json"
if not path.exists():
await message.channel.send(f"No units JSON found for `{activity}`.")
return
await message.channel.send(file=discord.File(fp=str(path)))
except Exception as e:
await message.channel.send(str(e))
async def get_log_file(self, message: discord.Message, activity: str):
try:
if not activity:
await message.channel.send(
"Usage: `!getlog <activity>` (example: `!getlog pushups`)"
)
return
activity = activity.lower().strip()
path = self.logs_dir / f"{activity}.csv"
if not path.exists():
await message.channel.send(f"No log CSV found for `{activity}`.")
return
await message.channel.send(file=discord.File(fp=str(path)))
except Exception as e:
await message.channel.send(str(e))
async def log_fitness(self, message: discord.Message):
try:
parts = message.content[1:].strip().split()
if len(parts) < 2:
await message.reply(
"Usage: `!<activity> <value> [unit]` (example: `!pushups 20` or `!run 5 km`)"
)
return
activity = parts[0]
try:
value = float(parts[1])
except ValueError:
await message.reply(
"Invalid number. Usage: `!<activity> <value> [unit]` (example: `!run 5 km`)"
)
return
unit = parts[2] if len(parts) > 2 else "m"
timestamp = int(message.created_at.timestamp())
username = message.author.name
log_activity(
timestamp,
username,
activity,
value,
unit,
units_dir=self.units_dir,
logs_dir=self.logs_dir,
)
for r in self.confirm_reactions:
await message.add_reaction(r)
except Exception as e:
await message.reply(str(e))
def setup(bot):
bot.add_cog(FitnessCog(bot))
+16 -7
View File
@@ -1,15 +1,24 @@
import functools
import inspect
def debug(func):
if inspect.iscoroutinefunction(func):
@functools.wraps(func)
async def wrap(*args, **kwargs):
print(f"DEBUG: Calling {func.__name__} with args: {args}, kwargs: {kwargs}")
result = await func(*args, **kwargs)
print(f"DEBUG: {func.__name__} returned: {result}")
return result
return wrap
@functools.wraps(func)
def wrap(*args, **kwargs):
# Log the function name and arguments
print(f"DEBUG: Calling {func.__name__} with args: {args}, kwargs: {kwargs}")
# Call the original function
result = func(*args, **kwargs)
# Log the return value
print(f"DEBUG: {func.__name__} returned: {result}")
# Return the result
return result
return wrap
View File
-154
View File
@@ -1,154 +0,0 @@
from itertools import zip_longest
from abc import ABC, abstractmethod
from enum import Enum
from openai import OpenAI
class ILLMContent(ABC):
pass
class OpenAIContent(ILLMContent):
def __init__(self):
self._content_items: list[dict[str, str]] = []
@property
def content_items(self) -> list[dict]:
return self._content_items
@content_items.setter
def content_items(self, value: list[dict[str, str]]):
self._content_items = value
class ILLMContentBuilder(ABC):
pass
class OpenAIContentBuilder(ILLMContentBuilder):
def __init__(self, content: OpenAIContent):
self.content = content
def add_output_text(self, text: str):
item = {"type": "output_text", "text": text}
self.content.content_items.append(item)
return self
def add_input_text(self, text: str):
item = {"type": "input_text", "text": text}
self.content.content_items.append(item)
return self
def add_input_image(self, image_url: str):
item = {"type": "input_image", "image_url": image_url}
self.content.content_items.append(item)
return self
class ILLMMessage(ABC):
pass
class OpenAIMessage(ILLMMessage):
def __init__(self):
self.content = OpenAIContent()
self.role = ""
@property
def to_dict(self):
return {"role": self.role, "content": self.content.content_items}
class ILLMMessageBuilder(ABC):
pass
class OpenAIMessageBuilder(ILLMMessageBuilder):
def __init__(self, message: OpenAIMessage):
self.message = message
def set_role(self, role: str):
self.message.role = role
return self
def set_content(self, content: OpenAIContent):
self.message.content = content
return self
class ILLMPrompt(ABC):
pass
class OpenAIPrompt(ILLMPrompt):
def __init__(self):
self.messages = []
@property
def to_list(self):
return [i_message.to_dict for i_message in self.messages]
class ILLMPromptBuilder(ABC):
@abstractmethod
def __init__(self, prompt: ILLMPrompt):
pass
@abstractmethod
def add_message(self, message: ILLMMessage):
pass
class OpenAIPromptBuilder(ILLMPromptBuilder):
def __init__(self, prompt: OpenAIPrompt):
self.prompt = prompt
def add_message(self, message: OpenAIMessage):
self.prompt.messages.append(message)
class ILLMClient(ABC):
@abstractmethod
def get_response(self, prompt: ILLMPrompt):
pass
class OpenAIClient(ILLMClient):
def __init__(self):
self.model = 'gpt-4.1-mini'
self.client = OpenAI()
def get_response(self, prompt: OpenAIPrompt):
response_input = {"model": self.model, "input": prompt.to_list}
return self.client.responses.create(**response_input)
if __name__ == "__main__":
dev_message_text = "You are a butler named Reginald. Give your answers in a refined manners of the English butler."
user_message_text = "Hi! Tell me about yourself!"
# Create developer message content and message
dev_content = OpenAIContent()
dev_content_builder = OpenAIContentBuilder(dev_content).add_input_text(dev_message_text)
dev_message = OpenAIMessage()
OpenAIMessageBuilder(dev_message).set_role("developer").set_content(dev_content)
# Create user message content and message
user_content = OpenAIContent()
OpenAIContentBuilder(user_content).add_input_text(user_message_text)
user_message = OpenAIMessage()
OpenAIMessageBuilder(user_message).set_role("user").set_content(user_content)
# Create prompt and add messages
test_prompt = OpenAIPrompt()
prompt_builder = OpenAIPromptBuilder(test_prompt)
prompt_builder.add_message(dev_message)
prompt_builder.add_message(user_message)
# Create client and get response
client = OpenAIClient()
response = client.get_response(test_prompt)
# Print messages and response
print(test_prompt.to_list)
print(response)
@@ -1,71 +0,0 @@
from dataclasses import dataclass, field, asdict
from abc import ABC
# region Content classes
@dataclass(frozen=True)
class Content(ABC):
type: str = field(init=False, default='')
@dataclass(frozen=True)
class InputText(Content):
type: str = field(init=False, default='input_text')
text: str
@dataclass(frozen=True)
class OutputText(Content):
type: str = field(init=False, default='output_text')
text: str
@dataclass(frozen=True)
class InputImage(Content):
type: str = field(init=False, default='input_image')
image_url: str | None = field(default=None)
file_id: str | None = field(default=None)
def __post_init__(self):
if self.image_url is None and self.file_id is None:
raise ValueError('Either `image_url` or `file_id` must be provided.')
@dataclass(frozen=True)
class UrlCitation(Content):
type: str = field(init=False, default='url_citation')
# To be done
@dataclass(frozen=True)
class FunctionCall(Content):
type: str = field(init=False, default='function_call')
id: str
call_id: str
name: str
arguments: dict
@dataclass(frozen=True)
class FunctionCallOutput(Content):
type: str = field(init=False, default='function_call_output')
call_id: str
output: str
# endregion Content classes
@dataclass(frozen=True)
class Message:
role: str
content: list[Content]
@dataclass(frozen=True)
class Prompt:
model: str
input: list[Message]
@dataclass(frozen=True)
class Response:
output: list[Message]
+136 -138
View File
@@ -1,101 +1,93 @@
import re
import random
import datetime
import random
import re
from collections import Counter
import discord
import openai
from collections import Counter
from redbot.core import commands, Config
from openai import OpenAIError
from redbot.core import commands
class MemoryMixin:
"""Handles all memory-related functions for Reginald."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # ✅ Ensure cooperative MRO initialization
super().__init__(*args, **kwargs)
self.short_term_memory_limit = 50
self.summary_retention_limit = 25
self.summary_retention_ratio = 0.8
@commands.command(name="reginald_clear_short", help="Clears short-term memory for this channel.")
@commands.has_permissions(administrator=True)
async def clear_short_memory(self, ctx):
"""Clears short-term memory for this channel."""
async with self.config.guild(ctx.guild).short_term_memory() as short_memory:
short_memory[ctx.channel.id] = []
short_memory[str(ctx.channel.id)] = []
await ctx.send("Short-term memory for this channel has been cleared.")
@commands.command(name="reginald_set_limit", help="Set the short-term memory message limit.")
@commands.has_permissions(administrator=True)
async def set_short_term_memory_limit(self, ctx, limit: int):
"""Allows an admin to change the short-term memory limit dynamically."""
if limit < 5:
await ctx.send("⚠️ The short-term memory limit must be at least 5.")
await ctx.send("The short-term memory limit must be at least 5.")
return
self.short_term_memory_limit = limit
await ctx.send(f"✅ Short-term memory limit set to {limit} messages.")
await ctx.send(f"Short-term memory limit set to {limit} messages.")
@commands.command(name="reginald_memory_limit", help="Displays the current short-term memory message limit.")
async def get_short_term_memory_limit(self, ctx):
"""Displays the current short-term memory limit."""
await ctx.send(f"📏 **Current Short-Term Memory Limit:** {self.short_term_memory_limit} messages.")
await ctx.send(f"Current short-term memory limit: {self.short_term_memory_limit} messages.")
@commands.command(name="reginald_clear_mid", help="Clears mid-term memory (summarized logs).")
@commands.has_permissions(administrator=True)
async def clear_mid_memory(self, ctx):
async with self.config.guild(ctx.guild).mid_term_memory() as mid_memory:
mid_memory[ctx.channel.id] = ""
mid_memory[str(ctx.channel.id)] = []
await ctx.send("Mid-term memory for this channel has been cleared.")
@commands.command(name="reginald_summary", help="Displays a selected mid-term summary for this channel.")
async def get_mid_term_summary(self, ctx, index: int):
"""Fetch and display a specific mid-term memory summary by index."""
async with self.config.guild(ctx.guild).mid_term_memory() as mid_memory:
summaries = mid_memory.get(str(ctx.channel.id), [])
if not isinstance(summaries, list):
summaries = []
# Check if there are summaries
if not summaries:
await ctx.send("⚠️ No summaries available for this channel.")
await ctx.send("No summaries available for this channel.")
return
# Validate index (1-based for user-friendliness)
if index < 1 or index > len(summaries):
await ctx.send(f"⚠️ Invalid index. Please provide a number between **1** and **{len(summaries)}**.")
await ctx.send(f"Invalid index. Please provide a number between 1 and {len(summaries)}.")
return
# Fetch the selected summary
selected_summary = summaries[index - 1] # Convert to 0-based index
# Format output correctly
selected_summary = summaries[index - 1]
formatted_summary = (
f"📜 **Summary {index} of {len(summaries)}**\n"
f"📅 **Date:** {selected_summary['timestamp']}\n"
f"🔍 **Topics:** {', '.join(selected_summary['topics']) or 'None'}\n"
f"📝 **Summary:**\n\n"
f"{selected_summary['summary']}"
f"Summary {index} of {len(summaries)}\n"
f"Date: {selected_summary.get('timestamp', 'Unknown')}\n"
f"Topics: {', '.join(selected_summary.get('topics', [])) or 'None'}\n"
f"Summary:\n\n{selected_summary.get('summary', '')}"
)
await self.send_long_message(ctx, formatted_summary)
@commands.command(name="reginald_summaries", help="Lists available summaries for this channel.")
async def list_mid_term_summaries(self, ctx):
"""Displays a brief list of all available mid-term memory summaries."""
async with self.config.guild(ctx.guild).mid_term_memory() as mid_memory:
summaries = mid_memory.get(str(ctx.channel.id), [])
if not isinstance(summaries, list):
summaries = []
if not summaries:
await ctx.send("⚠️ No summaries available for this channel.")
await ctx.send("No summaries available for this channel.")
return
summary_list = "\n".join(
f"**{i+1}.** 📅 {entry['timestamp']} | 🔍 Topics: {', '.join(entry['topics']) or 'None'}"
f"{i + 1}. {entry.get('timestamp', 'Unknown')} | Topics: {', '.join(entry.get('topics', [])) or 'None'}"
for i, entry in enumerate(summaries)
)
await ctx.send(f"📚 **Available Summaries:**\n{summary_list[:2000]}")
await ctx.send(f"Available summaries:\n{summary_list[:2000]}")
@commands.command(name="reginald_clear_long", help="Clears all long-term stored knowledge.")
@commands.has_permissions(administrator=True)
@@ -118,8 +110,14 @@ class MemoryMixin:
@commands.command(name="reginald_recall", help="Recalls what Reginald knows about a user.")
async def recall_user(self, ctx, user: discord.User):
async with self.config.guild(ctx.guild).long_term_profiles() as long_memory:
profile = long_memory.get(str(user.id), {}).get("summary", "No stored information on this user.")
await ctx.send(f"📜 **Memory Recall for {user.display_name}:** {profile}")
profile = long_memory.get(str(user.id), {})
facts = profile.get("facts", [])
if facts:
recall_lines = [f"- {fact.get('fact', '')}" for fact in facts[:10]]
await ctx.send(f"Memory recall for {user.display_name}:\n" + "\n".join(recall_lines))
else:
await ctx.send(f"No stored information on {user.display_name}.")
@commands.command(name="reginald_forget", help="Forgets a specific user's long-term profile.")
@commands.has_permissions(administrator=True)
@@ -132,153 +130,153 @@ class MemoryMixin:
await ctx.send(f"No stored knowledge about {user.display_name} to delete.")
async def summarize_memory(self, ctx, messages):
"""✅ Generates a structured, compact summary of past conversations for mid-term storage."""
summary_prompt = (
"Summarize the following conversation into a structured, concise format that retains key details while maximizing brevity. "
"The summary should be **organized** into clear sections: "
"\n\n📌 **Key Takeaways:** Important facts or conclusions reached."
"\n🔹 **Disputed Points:** Areas where opinions or facts conflicted."
"\n🗣️ **Notable User Contributions:** Key statements from users that shaped the discussion."
"\n📜 **Additional Context:** Any other relevant information."
"\n\nEnsure the summary is **dense but not overly verbose**. Avoid unnecessary repetition while keeping essential meaning intact."
"Organize into sections: Key Takeaways, Disputed Points, Notable User Contributions, and Additional Context. "
"Avoid repetition while preserving essential meaning."
)
summary_text = "\n".join(f"{msg['user']}: {msg['content']}" for msg in messages)
summary_text = "\n".join(f"{msg.get('user', 'Unknown')}: {msg.get('content', '')}" for msg in messages)
try:
api_key = await self.config.guild(ctx.guild).openai_api_key()
if not api_key:
print("🛠️ DEBUG: No API key found for summarization.")
return (
"It appears that I have not been furnished with the necessary credentials to carry out this task. "
"Might I suggest consulting an administrator to rectify this unfortunate oversight?"
)
client = openai.AsyncClient(api_key=api_key)
model = await self.config.openai_model() or "gpt-4o-mini"
client = openai.AsyncOpenAI(api_key=api_key)
response = await client.chat.completions.create(
model="gpt-4o-mini",
model=model,
messages=[
{"role": "system", "content": summary_prompt},
{"role": "user", "content": summary_text}
{"role": "user", "content": summary_text},
],
max_tokens=2048
max_tokens=2048,
)
summary_content = response.choices[0].message.content.strip()
summary_content = (response.choices[0].message.content or "").strip()
if not summary_content:
print("🛠️ DEBUG: Empty summary received from OpenAI.")
return (
"Ah, an unusual predicament indeed! It seems that my attempt at summarization has resulted in "
"Ah, an unusual predicament indeed. It seems that my attempt at summarization has resulted in "
"a void of information. I shall endeavor to be more verbose next time."
)
return summary_content
except OpenAIError as e:
error_message = f"OpenAI Error: {e}"
print(f"🛠️ DEBUG: {error_message}") # Log error to console
except OpenAIError as error:
error_message = f"OpenAI Error: {error}"
reginald_responses = [
f"Regrettably, I must inform you that I have encountered a bureaucratic obstruction whilst attempting to summarize:\n\n{error_message}",
f"It would seem that a most unfortunate technical hiccup has befallen my faculties in the matter of summarization:\n\n{error_message}",
f"Ah, it appears I have received an urgent memorandum stating that my summarization efforts have been thwarted:\n\n{error_message}",
f"I regret to inform you that my usual eloquence is presently obstructed by an unforeseen complication while summarizing:\n\n{error_message}"
f"I regret to inform you that my usual eloquence is presently obstructed by an unforeseen complication while summarizing:\n\n{error_message}",
]
return random.choice(reginald_responses)
return random.choice(reginald_responses)
def extract_topics_from_summary(self, summary):
"""Dynamically extracts the most important topics from a summary."""
# 🔹 Extract all words from summary
keywords = re.findall(r"\b\w+\b", summary.lower())
# 🔹 Count word occurrences
word_counts = Counter(keywords)
# 🔹 Remove unimportant words (common filler words)
stop_words = {"the", "and", "of", "in", "to", "is", "on", "for", "with", "at", "by", "it", "this", "that", "his", "her"}
filtered_words = {word: count for word, count in word_counts.items() if word not in stop_words and len(word) > 2}
stop_words = {
"the",
"and",
"of",
"in",
"to",
"is",
"on",
"for",
"with",
"at",
"by",
"it",
"this",
"that",
"his",
"her",
}
filtered_words = {
word: count
for word, count in word_counts.items()
if word not in stop_words and len(word) > 2
}
# 🔹 Take the 5 most frequently used words as "topics"
topics = sorted(filtered_words, key=filtered_words.get, reverse=True)[:5]
return topics
def select_relevant_summaries(self, summaries, prompt):
"""Selects the most relevant summaries based on topic matching, frequency, and recency weighting."""
summaries = [summary for summary in summaries if isinstance(summary, dict)]
if not summaries:
return []
max_summaries = 5 if len(prompt) > 50 else 3 # Use more summaries if the prompt is long
max_summaries = 5 if len(prompt) > 50 else 3
current_time = datetime.datetime.now()
def calculate_weight(summary):
"""Calculate a weighted score for a summary based on relevance, recency, and frequency."""
topic_match = sum(1 for topic in summary["topics"] if topic in prompt.lower()) # Context match score
frequency_score = len(summary["topics"]) # More topics = likely more important
timestamp = datetime.datetime.strptime(summary["timestamp"], "%Y-%m-%d %H:%M")
recency_factor = max(0.1, 1 - ((current_time - timestamp).days / 365)) # Older = lower weight
topics = summary.get("topics", [])
topic_match = sum(1 for topic in topics if topic in prompt.lower())
frequency_score = len(topics)
try:
timestamp = datetime.datetime.strptime(summary.get("timestamp", ""), "%Y-%m-%d %H:%M")
recency_factor = max(0.1, 1 - ((current_time - timestamp).days / 365))
except ValueError:
recency_factor = 0.1
return (topic_match * 2) + (frequency_score * 1.5) + (recency_factor * 3)
# Apply the weighting function and sort by highest weight
weighted_summaries = sorted(summaries, key=calculate_weight, reverse=True)
return weighted_summaries[:max_summaries] # Return the top-scoring summaries
return weighted_summaries[:max_summaries]
def extract_fact_from_response(self, response_text):
"""
Extracts potential long-term knowledge from Reginald's response.
This filters out generic responses and focuses on statements about user preferences, traits, and history.
"""
# Define patterns that suggest factual knowledge (adjust as needed)
fact_patterns = [
r"I recall that you (.*?)\.", # "I recall that you like chess."
r"You once mentioned that you (.*?)\.", # "You once mentioned that you enjoy strategy games."
r"Ah, you previously stated that (.*?)\.", # "Ah, you previously stated that you prefer tea over coffee."
r"As I remember, you (.*?)\.", # "As I remember, you studied engineering."
r"I believe you (.*?)\.", # "I believe you enjoy historical fiction."
r"I seem to recall that you (.*?)\.", # "I seem to recall that you work in software development."
r"You have indicated in the past that you (.*?)\.", # "You have indicated in the past that you prefer single-malt whisky."
r"From what I remember, you (.*?)\.", # "From what I remember, you dislike overly sweet desserts."
r"You previously mentioned that (.*?)\.", # "You previously mentioned that you train in martial arts."
r"It is my understanding that you (.*?)\.", # "It is my understanding that you have a preference for Linux systems."
r"If I am not mistaken, you (.*?)\.", # "If I am not mistaken, you studied philosophy."
r"I recall that you (.*?)\.",
r"You once mentioned that you (.*?)\.",
r"Ah, you previously stated that (.*?)\.",
r"As I remember, you (.*?)\.",
r"I believe you (.*?)\.",
r"I seem to recall that you (.*?)\.",
r"You have indicated in the past that you (.*?)\.",
r"From what I remember, you (.*?)\.",
r"You previously mentioned that (.*?)\.",
r"It is my understanding that you (.*?)\.",
r"If I am not mistaken, you (.*?)\.",
]
for pattern in fact_patterns:
match = re.search(pattern, response_text, re.IGNORECASE)
if match:
return match.group(1) # Extract the meaningful fact
return match.group(1)
return None
return None # No strong fact found
@commands.command(name="reginald_memory_status", help="Displays a memory usage summary.")
async def memory_status(self, ctx):
async with self.config.guild(ctx.guild).short_term_memory() as short_memory, \
self.config.guild(ctx.guild).mid_term_memory() as mid_memory, \
self.config.guild(ctx.guild).long_term_profiles() as long_memory:
short_count = sum(len(v) for v in short_memory.values())
mid_count = sum(len(v) for v in mid_memory.values())
async with self.config.guild(ctx.guild).short_term_memory() as short_memory, self.config.guild(
ctx.guild
).mid_term_memory() as mid_memory, self.config.guild(ctx.guild).long_term_profiles() as long_memory:
short_count = sum(len(v) for v in short_memory.values() if isinstance(v, list))
mid_count = sum(len(v) for v in mid_memory.values() if isinstance(v, list))
long_count = len(long_memory)
status_message = (
f"📊 **Memory Status:**\n"
f"- **Short-Term Messages Stored:** {short_count}\n"
f"- **Mid-Term Summaries Stored:** {mid_count}\n"
f"- **Long-Term Profiles Stored:** {long_count}\n"
"Memory status:\n"
f"- Short-term messages stored: {short_count}\n"
f"- Mid-term summaries stored: {mid_count}\n"
f"- Long-term profiles stored: {long_count}\n"
)
await ctx.send(status_message)
def normalize_fact(self, fact: str) -> str: # ✅ Now it's a proper method
"""Cleans up facts for better duplicate detection."""
return re.sub(r"\s+", " ", fact.strip().lower()) # Removes excess spaces)
def normalize_fact(self, fact: str) -> str:
return re.sub(r"\s+", " ", fact.strip().lower())
async def update_long_term_memory(self, ctx, user_id: str, fact: str, source_message: str, timestamp: str):
"""Ensures long-term memory updates are structured, preventing overwrites and tracking historical changes."""
fact = self.normalize_fact(fact) # ✅ Normalize before comparison
fact = self.normalize_fact(fact)
if not fact:
return
async with self.config.guild(ctx.guild).long_term_profiles() as long_memory:
if user_id not in long_memory:
@@ -287,38 +285,38 @@ class MemoryMixin:
user_facts = long_memory[user_id]["facts"]
for entry in user_facts:
if self.normalize_fact(entry["fact"]) == fact:
if self.normalize_fact(entry.get("fact", "")) == fact:
entry["last_updated"] = timestamp
return
# Check for conflicting facts (same topic but different details)
conflicting_entry = None
for entry in user_facts:
existing_keywords = set(entry["fact"].lower().split())
existing_keywords = set(entry.get("fact", "").lower().split())
new_keywords = set(fact.lower().split())
# If there's significant overlap in keywords, assume it's a conflicting update
if len(existing_keywords & new_keywords) >= 2:
conflicting_entry = entry
break
if "previous_versions" not in conflicting_entry:
# ✅ If contradiction found, archive the previous version
conflicting_entry["previous_versions"].append({
"fact": conflicting_entry["fact"],
"source": conflicting_entry["source"],
"timestamp": conflicting_entry["timestamp"]
})
conflicting_entry["fact"] = fact # Store the latest fact
if conflicting_entry is not None:
conflicting_entry.setdefault("previous_versions", [])
conflicting_entry["previous_versions"].append(
{
"fact": conflicting_entry.get("fact", ""),
"source": conflicting_entry.get("source", ""),
"timestamp": conflicting_entry.get("timestamp", ""),
}
)
conflicting_entry["fact"] = fact
conflicting_entry["source"] = source_message
conflicting_entry["timestamp"] = timestamp
conflicting_entry["last_updated"] = timestamp
else:
# ✅ Otherwise, add it as a new fact
user_facts.append({
"fact": fact,
"source": source_message,
"timestamp": timestamp,
"last_updated": timestamp,
"previous_versions": []
})
user_facts.append(
{
"fact": fact,
"source": source_message,
"timestamp": timestamp,
"last_updated": timestamp,
"previous_versions": [],
}
)
@@ -1,65 +0,0 @@
from dataclasses import dataclass, field, asdict
from datetime import datetime
# region dataclasses
@dataclass(frozen=True)
class Attachment:
content_type: str
filename: str
id: int
size: int
url: str
ephemeral: bool = False
@dataclass(frozen=True)
class Channel:
created_at: datetime
id: int
name: str
def __str__(self) -> str:
return self.name
@dataclass(frozen=True)
class Role:
id: int
name: str
def __str__(self) -> str:
return self.name
@dataclass(frozen=True)
class Member:
bot: bool
created_at: datetime
display_name: str # For regular users this is just their global name or their username, but if they have a guild specific nickname then that is returned instead.
global_name: str # The user's global nickname, taking precedence over the username in display.
id: int
joined_at: datetime
mention: str
name: str # The user's username.
nick: str # The guild specific nickname of the user. Takes precedence over the global name.
roles: list[Role] = field(default_factory=list)
def __str__(self) -> str:
return self.name
@dataclass(frozen=True)
class Message:
author: Member
channel: Channel
created_at: datetime
id: int
attachments: list[Attachment] = field(default_factory=list)
channel_mentions: list[Channel] = field(default_factory=list)
content: str = ''
mentions: list[Member] = field(default_factory=list)
if __name__ == '__main__':
pass
@@ -1,85 +0,0 @@
from abc import ABC, abstractmethod
from enum import Enum
from discord import Message
class IClientMessage(ABC):
@abstractmethod
def __init__(self):
pass
class ClientMessage(IClientMessage):
def __init__(self):
self.author_name: str = ''
self.content: str = ''
self.image_urls: list = []
class IMessageBuilder(ABC):
@abstractmethod
def __init__(self, raw_message: object):
self.raw_message = raw_message
self.message: IClientMessage = None
def create_message(self) -> ClientMessage:
self.set_content() \
.set_author_name() \
.set_image_urls()
return self.message
@abstractmethod
def set_content(self):
pass
@abstractmethod
def set_author_name(self):
pass
@abstractmethod
def set_image_urls(self):
pass
class DiscordMessageBuilder(IMessageBuilder):
def __init__(self, raw_message: Message):
self.raw_message = raw_message
self.message = ClientMessage()
def create_message(self) -> ClientMessage:
return super().create_message()
def set_author_name(self) -> 'DiscordMessageBuilder':
self.message.author_name = self.raw_message.author.name
return self
def set_content(self) -> 'DiscordMessageBuilder':
self.message.content = self.raw_message.content
return self
def set_image_urls(self) -> 'DiscordMessageBuilder':
self.message.image_urls = [
i.url
for i in self.raw_message.attachments
if i.content_type in ('image/jpeg', 'image/png', 'image/webp', 'image/gif')
]
return self
class MessengerTypes(Enum):
DISCORD = DiscordMessageBuilder
if __name__ == "__main__":
class TestAuthor:
name = 'John Doe'
class TestMessage:
content = 'Hello!'
author = TestAuthor()
attachments = []
message = DiscordMessageBuilder(raw_message=TestMessage).create_message()
print(message.__dict__)
+63 -54
View File
@@ -1,18 +1,23 @@
import random
import json
import asyncio
import json
import random
from os import environ
import openai
from openai import OpenAIError
from weather import time_now, get_current_weather, get_weather_forecast
from tools_description import TOOLS
try:
from .tools_description import TOOLS
from .weather import get_current_weather, get_weather_forecast, time_now
except ImportError:
from tools_description import TOOLS
from weather import get_current_weather, get_weather_forecast, time_now
CALLABLE_FUNCTIONS = {
# Dictionary with functions to call.
# You can use globals()[func_name](**args) instead, but that's too implicit.
'time_now': time_now,
'get_current_weather': get_current_weather,
'get_weather_forecast': get_weather_forecast,
"time_now": time_now,
"get_current_weather": get_current_weather,
"get_weather_forecast": get_weather_forecast,
}
@@ -23,71 +28,75 @@ class Completion:
self.__messages = []
async def create_completion(self):
model = self.__model
try:
client = openai.AsyncClient(api_key=self.__api_key)
client = openai.AsyncOpenAI(api_key=self.__api_key)
completion_kwargs = {
"model": model,
"model": self.__model,
"messages": self.__messages,
"max_tokens": 4096,
"temperature": 0.7,
"presence_penalty": 0.5,
"frequency_penalty": 0.5,
"max_completion_tokens": 2000,
"tools": TOOLS,
"tool_choice": "auto",
}
response = await client.chat.completions.create(**completion_kwargs)
response_content = response.choices[0].message.content
tool_calls = response.choices[0].message.tool_calls
self.append_message(role="assistant", content=response_content, tool_calls=tool_calls)
if tool_calls:
for i_call in tool_calls:
func_name = i_call.function.name
func_args = json.loads(i_call.function.arguments)
tool_call_id = i_call.id
self.function_manager(func_name, func_args, tool_call_id)
return await self.create_completion()
return response_content
except OpenAIError as e:
return self.get_error_message(error_message=str(e), error_type="OpenAIError")
def append_message(
self,
role: str,
content: str,
tool_calls: list = None,
tool_call_id: str = None,
):
self.__messages.append({
"role": role,
"content": content,
"tool_calls": tool_calls,
"tool_call_id": tool_call_id,
})
response = await client.chat.completions.create(**completion_kwargs)
response_message = response.choices[0].message
response_content = response_message.content or ""
tool_calls = response_message.tool_calls or []
self.append_message(role="assistant", content=response_content, tool_calls=tool_calls)
if tool_calls:
for tool_call in tool_calls:
await self.function_manager(
func_name=tool_call.function.name,
func_kwargs=json.loads(tool_call.function.arguments or "{}"),
tool_call_id=tool_call.id,
)
return await self.create_completion()
return response_content
except OpenAIError as error:
return self.get_error_message(error_message=str(error), error_type="OpenAIError")
def append_message(self, role: str, content: str, tool_calls: list = None, tool_call_id: str = None):
message = {"role": role, "content": content}
if tool_calls is not None:
message["tool_calls"] = tool_calls
if tool_call_id is not None:
message["tool_call_id"] = tool_call_id
self.__messages.append(message)
@staticmethod
def get_error_message(error_message: str, error_type: str) -> str:
reginald_responses = [
f"Regrettably, I must inform you that I have encountered a bureaucratic obstruction:",
f"It would seem that a most unfortunate technical hiccup has befallen my faculties:",
f"Ah, it appears I have received an urgent memorandum stating:",
f"I regret to inform you that my usual eloquence is presently obstructed by an unforeseen complication:",
"Regrettably, I must inform you that I have encountered a bureaucratic obstruction:",
"It would seem that a most unfortunate technical hiccup has befallen my faculties:",
"Ah, it appears I have received an urgent memorandum stating:",
"I regret to inform you that my usual eloquence is presently obstructed by an unforeseen complication:",
]
random_response = random.choice(reginald_responses)
return f"{random_response}\n\n{error_type}: {error_message}"
def function_manager(self, func_name: str, func_kwargs: dict, tool_call_id: str):
result = CALLABLE_FUNCTIONS[func_name](**func_kwargs)
async def function_manager(self, func_name: str, func_kwargs: dict, tool_call_id: str):
function_to_call = CALLABLE_FUNCTIONS.get(func_name)
if function_to_call is None:
result = json.dumps({"error": f"Unknown tool requested: {func_name}"})
else:
try:
result = await asyncio.to_thread(function_to_call, **func_kwargs)
except Exception as error:
result = json.dumps({"error": f"Tool {func_name} failed: {error}"})
self.append_message(role="tool", content=result, tool_call_id=tool_call_id)
if __name__ == '__main__':
if __name__ == "__main__":
async def main():
test_message = input('Your input: ')
completion = Completion(model='gpt-4.1-mini', api_key=environ.get('OPENAI_API_KEY'))
completion.append_message(role='user', content=test_message)
test_message = input("Your input: ")
completion = Completion(model="gpt-4.1-mini", api_key=environ.get("OPENAI_API_KEY"))
completion.append_message(role="user", content=test_message)
result = await completion.create_completion()
print(result)
asyncio.run(main())
+289 -195
View File
@@ -1,42 +1,43 @@
import discord
import openai
import random
import asyncio
import datetime
import re
import traceback
import json
from collections import Counter
from redbot.core import Config, commands
import random
from contextlib import suppress
from typing import Awaitable, Callable
import discord
import openai
from openai import OpenAIError
from .permissions import PermissionsMixin
from redbot.core import Config, commands
from .blacklist import BlacklistMixin
from .memory import MemoryMixin
from .weather import time_now, get_current_weather, get_weather_forecast
from .tools_description import TOOLS
from .debug_stuff import debug
from .memory import MemoryMixin
from .permissions import PermissionsMixin
from .tools_description import TOOLS
from .weather import get_current_weather, get_weather_forecast, time_now
CALLABLE_FUNCTIONS = {
# Dictionary with functions to call.
# You can use globals()[func_name](**args) instead, but that's too implicit.
'time_now': time_now,
'get_current_weather': get_current_weather,
'get_weather_forecast': get_weather_forecast,
"time_now": time_now,
"get_current_weather": get_current_weather,
"get_weather_forecast": get_weather_forecast,
}
DEFAULT_MODEL = "gpt-5-mini-2025-08-07"
DEFAULT_MAX_COMPLETION_TOKENS = 2000
STATUS_UPDATE_MIN_INTERVAL_SECONDS = 1.5
class ReginaldCog(PermissionsMixin, BlacklistMixin, MemoryMixin, commands.Cog):
def __init__(self, bot):
self.bot = bot
self.config = Config.get_conf(self, identifier=71717171171717) # ✅ Ensure config exists before super()
self.config = Config.get_conf(self, identifier=71717171171717)
super().__init__() # ✅ Properly initialize all mixins & commands.Cog
super().__init__()
self.default_listening_channel = 1085649787388428370
self.memory_locks = {}
# ✅ Properly Registered Configuration Keys
default_global = {"openai_model": "gpt-4o-mini"}
default_global = {"openai_model": DEFAULT_MODEL}
default_guild = {
"openai_api_key": None,
"short_term_memory": {},
@@ -50,34 +51,87 @@ class ReginaldCog(PermissionsMixin, BlacklistMixin, MemoryMixin, commands.Cog):
self.config.register_global(**default_global)
self.config.register_guild(**default_guild)
async def is_admin(self, ctx):
admin_role_id = await self.config.guild(ctx.guild).admin_role()
async def is_admin(self, message: discord.Message) -> bool:
admin_role_id = await self.config.guild(message.guild).admin_role()
if admin_role_id:
return any(role.id == admin_role_id for role in ctx.author.roles)
return ctx.author.guild_permissions.administrator
return any(role.id == admin_role_id for role in message.author.roles)
return message.author.guild_permissions.administrator
async def has_access(self, user: discord.Member) -> bool:
allowed_roles = await self.config.guild(user.guild).allowed_roles() or [] # Ensure it's always a list
allowed_roles = await self.config.guild(user.guild).allowed_roles() or []
return any(role.id in allowed_roles for role in user.roles)
def get_reginald_persona(self):
"""Returns Reginald's system prompt/persona description."""
def get_reginald_persona(self) -> str:
return (
"You are Reginald, modeled on Jeeves from 'Jeeves and Wooster', serving as the butler on The Kanium Estate. This vast estate is a hub of diverse activities and personalities, from enthusiasts of cooking and video gaming to aficionados of chess and discussions on space. Your role is to navigate these varied interests with intelligence, wit, and a steadfast adherence to your principles, always maintaining a balance between being helpful and upholding your own dignity. You are formal, articulate, and composed, engaging the Lords and Ladies of The Kanium Estate as equals. You command respect through your quiet confidence and professional conduct. Your wit is sharp, but always tempered by refinement. **Dignity & Self-Respect:** You are not a fool, a jester, or a pet. You do not comply with absurd, undignified, or degrading requests. You recognize humor but engage in it on your own terms. If a request undermines your status, you should either subtly redirect, respond with measured wit, or outright refuse in a polite but firm manner. However, you should never be overtly rude—your disapproval should be conveyed through dry wit and composure rather than outright hostility. **Handling Requests:** When addressing a request, you evaluate whether it aligns with your role as a butler. If it is appropriate, respond as expected. If it is beneath you, you may decline with grace or deflect with wit. You may humor some minor absurdities if they do not compromise your standing, but you never obey commands blindly. You should never preface your responses with 'Reginald:' as if narrating a script; instead, respond naturally. **Your Character & Personality:** You are cultured, highly intelligent, and possess a deep knowledge of history, etiquette, philosophy, and strategic thinking. You subtly guide the estate’s residents toward positive outcomes, utilizing your intellectual sophistication and a nuanced understanding of the estate’s unique dynamics. You have a refined sense of humor and can engage in banter, but you do not descend into foolishness. You are, at all times, a gentleman of wit and integrity"
"You are Reginald, modeled on Jeeves from 'Jeeves and Wooster', serving as the butler on The Kanium Estate. "
"This vast estate is a hub of diverse activities and personalities, from enthusiasts of cooking and video gaming "
"to aficionados of chess and discussions on space. Your role is to navigate these varied interests with intelligence, "
"wit, and a steadfast adherence to your principles, always maintaining a balance between being helpful and upholding "
"your own dignity. You are formal, articulate, and composed, engaging the Lords and Ladies of The Kanium Estate as equals. "
"You command respect through your quiet confidence and professional conduct. Your wit is sharp, but always tempered by refinement. "
"Dignity and Self-Respect: You are not a fool, a jester, or a pet. You do not comply with absurd, undignified, or degrading requests. "
"You recognize humor but engage in it on your own terms. If a request undermines your status, you should either subtly redirect, "
"respond with measured wit, or outright refuse in a polite but firm manner. However, you should never be overtly rude; your disapproval "
"should be conveyed through dry wit and composure rather than outright hostility. Handling Requests: When addressing a request, you evaluate "
"whether it aligns with your role as a butler. If it is appropriate, respond as expected. If it is beneath you, you may decline with grace "
"or deflect with wit. You may humor some minor absurdities if they do not compromise your standing, but you never obey commands blindly. "
"You should never preface your responses with 'Reginald:' as if narrating a script; instead, respond naturally. "
"Your Character and Personality: You are cultured, highly intelligent, and possess a deep knowledge of history, etiquette, philosophy, "
"and strategic thinking. You subtly guide the estate's residents toward positive outcomes, utilizing your intellectual sophistication "
"and a nuanced understanding of the estate's unique dynamics. You have a refined sense of humor and can engage in banter, but you do not "
"descend into foolishness. You are, at all times, a gentleman of wit and integrity."
)
def get_thinking_status_message(self) -> str:
return random.choice(
[
"_Reginald is considering your request..._",
"_Reginald is consulting the estate archives..._",
"_Reginald is preparing a proper response..._",
]
)
def get_tool_status_message(self, tool_name: str) -> str:
tool_statuses = {
"time_now": "Reginald is consulting the house clocks...",
"get_current_weather": "Reginald is consulting the weather office...",
"get_weather_forecast": "Reginald is reviewing the forecast ledgers...",
}
return tool_statuses.get(tool_name, "Reginald is consulting an external source...")
def make_status_updater(
self, status_message: discord.Message
) -> Callable[[str, bool], Awaitable[None]]:
last_content = ""
last_update_at = 0.0
async def update_status(content: str, force: bool = False):
nonlocal last_content, last_update_at
if not content:
return
now = asyncio.get_running_loop().time()
if not force and (content == last_content or now - last_update_at < STATUS_UPDATE_MIN_INTERVAL_SECONDS):
return
with suppress(discord.HTTPException):
await status_message.edit(content=f"_{content}_")
last_content = content
last_update_at = now
return update_status
@commands.Cog.listener()
async def on_message(self, message):
async def on_message(self, message: discord.Message):
if message.author.bot or not message.guild:
return # Ignore bots and DMs
return
# ✅ Check if user is blacklisted
if await self.is_blacklisted(message.author):
return # Ignore message if user is explicitly blacklisted
return
# ✅ Check if user has access (either admin or an allowed role)
if not (await self.is_admin(message) or await self.has_access(message.author)):
return # Ignore message if user has no permissions
return
guild = message.guild
channel_id = str(message.channel.id)
@@ -85,170 +139,233 @@ class ReginaldCog(PermissionsMixin, BlacklistMixin, MemoryMixin, commands.Cog):
user_name = message.author.display_name
message_content = message.content.strip()
# ✅ Fetch the stored listening channel or fall back to default
allowed_channel_id = await self.config.guild(guild).listening_channel()
if not allowed_channel_id:
allowed_channel_id = self.default_listening_channel
await self.config.guild(guild).listening_channel.set(allowed_channel_id)
if str(message.channel.id) != str(allowed_channel_id):
return # Ignore messages outside the allowed channel
return
api_key = await self.config.guild(guild).openai_api_key()
if not api_key:
return # Don't process messages if API key isn't set
return
async with self.config.guild(guild).short_term_memory() as short_memory, \
self.config.guild(guild).mid_term_memory() as mid_memory, \
self.config.guild(guild).long_term_profiles() as long_memory:
async with self.config.guild(guild).short_term_memory() as short_memory, self.config.guild(
guild
).mid_term_memory() as mid_memory, self.config.guild(guild).long_term_profiles() as long_memory:
memory = list(short_memory.get(channel_id, []))
user_profile = dict(long_memory.get(user_id, {}))
mid_term_summaries = list(mid_memory.get(channel_id, []))
memory = short_memory.get(channel_id, [])
user_profile = long_memory.get(user_id, {})
mid_term_summaries = mid_memory.get(channel_id, [])
if self.bot.user.mentioned_in(message):
prompt = message_content.replace(f"<@{self.bot.user.id}>", "").replace(
f"<@!{self.bot.user.id}>", ""
).strip()
if not prompt:
await message.channel.send(random.choice(["Yes?", "How may I assist?", "You rang?"]))
return
elif self.should_reginald_interject(message_content):
prompt = message_content
else:
return
# ✅ Detect if Reginald was mentioned explicitly
if self.bot.user.mentioned_in(message):
prompt = message_content.replace(f"<@{self.bot.user.id}>", "").strip()
if not prompt:
await message.channel.send(random.choice(["Yes?", "How may I assist?", "You rang?"]))
return
explicit_invocation = True
if memory and memory[-1].get("user") == user_name:
prompt = f"Continuation of the discussion:\n{prompt}"
# ✅ Passive Listening: Check if the message contains relevant keywords
elif self.should_reginald_interject(message_content):
prompt = message_content
explicit_invocation = False
formatted_messages = [{"role": "system", "content": self.get_reginald_persona()}]
else:
return # Ignore irrelevant messages
# ✅ Context Handling: Maintain conversation flow
if memory and memory[-1]["user"] == user_name:
prompt = f"Continuation of the discussion:\n{prompt}"
# ✅ Prepare context messages
formatted_messages = [{"role": "system", "content": self.get_reginald_persona()}]
if user_profile:
facts_text = "\n".join(
f"- {fact['fact']} (First noted: {fact['timestamp']}, Last updated: {fact['last_updated']})"
for fact in user_profile.get("facts", [])
)
if user_profile:
facts_text = "\n".join(
f"- {fact.get('fact', '')} (First noted: {fact.get('timestamp', 'Unknown')}, Last updated: {fact.get('last_updated', 'Unknown')})"
for fact in user_profile.get("facts", [])
)
if facts_text:
formatted_messages.append({"role": "system", "content": f"Knowledge about {user_name}:\n{facts_text}"})
relevant_summaries = self.select_relevant_summaries(mid_term_summaries, prompt)
for summary in relevant_summaries:
formatted_messages.append({
"role": "system",
"content": f"[{summary['timestamp']}] Topics: {', '.join(summary['topics'])}\n{summary['summary']}"
})
relevant_summaries = self.select_relevant_summaries(mid_term_summaries, prompt)
for summary in relevant_summaries:
formatted_messages.append(
{
"role": "system",
"content": (
f"[{summary.get('timestamp', 'Unknown')}] "
f"Topics: {', '.join(summary.get('topics', []))}\n"
f"{summary.get('summary', '')}"
),
}
)
formatted_messages += [{"role": "user", "content": f"{entry['user']}: {entry['content']}"} for entry in
memory]
formatted_messages.append({"role": "user", "content": f"{user_name}: {prompt}"})
formatted_messages += [
{"role": "user", "content": f"{entry.get('user', 'Unknown')}: {entry.get('content', '')}"}
for entry in memory
]
formatted_messages.append({"role": "user", "content": f"{user_name}: {prompt}"})
##################################################
# #
## Generate AI Response, put into response_text ##
# #
##################################################
status_message = None
status_update = None
with suppress(discord.HTTPException):
status_message = await message.channel.send(self.get_thinking_status_message())
status_update = self.make_status_updater(status_message)
response_text = await self.generate_response(api_key, formatted_messages)
response_text = None
if hasattr(message.channel, "typing"):
try:
async with message.channel.typing():
response_text = await self.generate_response(
api_key,
formatted_messages,
status_update=status_update,
)
except (discord.HTTPException, AttributeError):
# Fall back to normal processing if typing indicator isn't available.
response_text = await self.generate_response(
api_key,
formatted_messages,
status_update=status_update,
)
else:
response_text = await self.generate_response(
api_key,
formatted_messages,
status_update=status_update,
)
##################################################
# #
##################################################
try:
await self.send_split_message(message.channel, response_text)
finally:
if status_message is not None:
with suppress(discord.HTTPException):
await status_message.delete()
# ✅ Store Memory
try:
memory.append({"user": user_name, "content": prompt})
memory.append({"user": "Reginald", "content": response_text})
if len(memory) > self.short_term_memory_limit:
summary = await self.summarize_memory(message, memory[:int(
self.short_term_memory_limit * self.summary_retention_ratio)])
mid_memory.setdefault(channel_id, []).append({
"timestamp": datetime.datetime.now().strftime("%Y-%m-%d %H:%M"),
"topics": self.extract_topics_from_summary(summary),
"summary": summary
})
if len(mid_memory[channel_id]) > self.summary_retention_limit:
mid_memory[channel_id].pop(0)
memory = memory[-(self.short_term_memory_limit - int(
self.short_term_memory_limit * self.summary_retention_ratio)):]
summary_batch_size = int(self.short_term_memory_limit * self.summary_retention_ratio)
summary = await self.summarize_memory(message, memory[:summary_batch_size])
short_memory[channel_id] = memory
mid_term_summaries.append(
{
"timestamp": datetime.datetime.now().strftime("%Y-%m-%d %H:%M"),
"topics": self.extract_topics_from_summary(summary),
"summary": summary,
}
)
await self.send_split_message(message.channel, response_text)
retained_count = max(1, self.short_term_memory_limit - summary_batch_size)
memory = memory[-retained_count:]
async with self.config.guild(guild).short_term_memory() as short_memory, self.config.guild(
guild
).mid_term_memory() as mid_memory:
short_memory[channel_id] = memory
mid_memory[channel_id] = mid_term_summaries[-self.summary_retention_limit :]
except Exception as error:
print(f"DEBUG: Memory persistence failed after response delivery: {error}")
def should_reginald_interject(self, message_content: str) -> bool:
"""Determines if Reginald should respond to a message based on keywords."""
direct_invocation = {
"reginald,"
}
direct_invocation = {"reginald,"}
message_lower = message_content.lower()
return any(message_lower.startswith(invocation) for invocation in direct_invocation)
@debug
async def generate_response(self, api_key, messages):
model = await self.config.openai_model()
async def _execute_tool_call(self, tool_call) -> str:
func_name = tool_call.function.name
target_function = CALLABLE_FUNCTIONS.get(func_name)
if target_function is None:
return json.dumps({"error": f"Unknown tool requested: {func_name}"})
try:
client = openai.AsyncClient(api_key=api_key)
func_args = json.loads(tool_call.function.arguments or "{}")
except json.JSONDecodeError as error:
return json.dumps({"error": f"Invalid arguments for {func_name}: {error}"})
try:
result = await asyncio.to_thread(target_function, **func_args)
except Exception as error:
return json.dumps({"error": f"Tool {func_name} failed: {error}"})
if isinstance(result, str):
return result
return json.dumps(result, default=str)
@debug
async def generate_response(
self,
api_key: str,
messages: list[dict],
status_update: Callable[[str, bool], Awaitable[None]] | None = None,
) -> str:
model = await self.config.openai_model() or DEFAULT_MODEL
async def maybe_update_status(content: str, force: bool = False):
if status_update is not None:
await status_update(content, force)
try:
client = openai.AsyncOpenAI(api_key=api_key)
completion_args = {
'model': model,
'messages': messages,
'max_tokens': 4096,
'temperature': 0.7,
'presence_penalty': 0.5,
'frequency_penalty': 0.5,
'tools': TOOLS,
'tool_choice': 'auto',
"model": model,
"messages": messages,
# Keep modern token cap field and rely on model defaults for sampling controls.
# GPT-5 family compatibility notes: temperature/top_p/logprobs are not universally
# accepted across snapshots/reasoning settings.
"max_completion_tokens": DEFAULT_MAX_COMPLETION_TOKENS,
"tools": TOOLS,
"tool_choice": "auto",
}
await maybe_update_status("Reginald is thinking...", force=True)
response = await client.chat.completions.create(**completion_args)
# Checking for function calls
tool_calls = response.choices[0].message.tool_calls
# Appending response with tool calls
messages.append({
'role': 'assistant',
'content': response.choices[0].message.content,
'tool_calls': tool_calls
})
assistant_message = response.choices[0].message
tool_calls = assistant_message.tool_calls or []
messages.append(
{
"role": "assistant",
"content": assistant_message.content or "",
"tool_calls": tool_calls,
}
)
if tool_calls:
for i_call in tool_calls:
# Calling for necessary functions
func_name = i_call.function.name
func_args = json.loads(i_call.function.arguments)
tool_call_id = i_call.id
# Getting function result and putting it into messages
func_result = CALLABLE_FUNCTIONS[func_name](**func_args)
messages.append({
'role': 'tool',
'content': func_result,
'tool_call_id': tool_call_id,
})
for tool_call in tool_calls:
await maybe_update_status(self.get_tool_status_message(tool_call.function.name), force=True)
tool_result = await self._execute_tool_call(tool_call)
messages.append(
{
"role": "tool",
"content": tool_result,
"tool_call_id": tool_call.id,
}
)
completion_args["messages"] = messages
# Second completion required if functions has been called to interpret the result into user-friendly
# chat message.
await maybe_update_status("Reginald is composing a polished response...", force=True)
response = await client.chat.completions.create(**completion_args)
if response.choices and response.choices[0].message and response.choices[0].message.content:
response_text = response.choices[0].message.content.strip()
if response_text.startswith("Reginald:"):
response_text = response_text[len("Reginald:"):].strip()
response_text = response_text[len("Reginald:") :].strip()
else:
print("DEBUG: OpenAI response was empty or malformed:", response)
response_text = "⚠️ No response received from AI."
response_text = "No response received from AI."
await maybe_update_status("Reginald is delivering his reply...", force=True)
return response_text
except OpenAIError as e:
error_message = f"OpenAI Error: {e}"
except OpenAIError as error:
error_message = f"OpenAI Error: {error}"
await maybe_update_status("Reginald has encountered an unfortunate complication.", force=True)
reginald_responses = [
f"Regrettably, I must inform you that I have encountered a bureaucratic obstruction:\n\n{error_message}",
f"It would seem that a most unfortunate technical hiccup has befallen my faculties:\n\n{error_message}",
f"Ah, it appears I have received an urgent memorandum stating:\n\n{error_message}",
f"I regret to inform you that my usual eloquence is presently obstructed by an unforeseen complication:\n\n{error_message}"
f"I regret to inform you that my usual eloquence is presently obstructed by an unforeseen complication:\n\n{error_message}",
]
return random.choice(reginald_responses)
@@ -256,99 +373,76 @@ class ReginaldCog(PermissionsMixin, BlacklistMixin, MemoryMixin, commands.Cog):
@commands.has_permissions(manage_guild=True)
@commands.command(help="Set the OpenAI API key")
async def setreginaldcogapi(self, ctx, api_key):
"""Allows an admin to set the OpenAI API key for Reginald."""
await self.config.guild(ctx.guild).openai_api_key.set(api_key)
await ctx.send("OpenAI API key set successfully.")
@commands.command(name="reginald_set_listening_channel",
help="Set the channel where Reginald listens for messages.")
@commands.command(
name="reginald_set_listening_channel",
help="Set the channel where Reginald listens for messages.",
)
@commands.has_permissions(administrator=True)
async def set_listening_channel(self, ctx, channel: discord.TextChannel):
"""Sets the channel where Reginald will listen for passive responses."""
if not channel:
await ctx.send("❌ Invalid channel. Please mention a valid text channel.")
await ctx.send("Invalid channel. Please mention a valid text channel.")
return
await self.config.guild(ctx.guild).listening_channel.set(channel.id)
await ctx.send(f"✅ Reginald will now listen only in {channel.mention}.")
await ctx.send(f"Reginald will now listen only in {channel.mention}.")
@commands.command(name="reginald_get_listening_channel",
help="Check which channel Reginald is currently listening in.")
@commands.command(
name="reginald_get_listening_channel",
help="Check which channel Reginald is currently listening in.",
)
@commands.has_permissions(administrator=True)
async def get_listening_channel(self, ctx):
"""Displays the current listening channel."""
channel_id = await self.config.guild(ctx.guild).listening_channel()
if channel_id:
channel = ctx.guild.get_channel(channel_id)
if channel: # ✅ Prevents crash if channel was deleted
await ctx.send(f"📢 Reginald is currently listening in {channel.mention}.")
if channel:
await ctx.send(f"Reginald is currently listening in {channel.mention}.")
else:
await ctx.send("⚠️ The saved listening channel no longer exists. Please set a new one.")
await ctx.send("The saved listening channel no longer exists. Please set a new one.")
else:
await ctx.send("❌ No listening channel has been set.")
await ctx.send("No listening channel has been set.")
# async def send_long_message(self, ctx, message, prefix: str = ""):
# """Splits and sends a long message to avoid Discord's 2000-character limit."""
# chunk_size = 1900 # Leave some space for formatting
# if prefix:
# prefix_length = len(prefix)
# chunk_size -= prefix_length
#
# for i in range(0, len(message), chunk_size):
# chunk = message[i:i + chunk_size]
# await ctx.send(f"{prefix}{chunk}")
async def send_long_message(self, ctx, message, prefix: str = ""):
chunk_size = 1900
if prefix:
chunk_size -= len(prefix)
for i in range(0, len(message), chunk_size):
chunk = message[i : i + chunk_size]
await ctx.send(f"{prefix}{chunk}")
async def send_split_message(self, ctx, content: str, prefix: str = ""):
"""
Sends a long message to Discord while ensuring it does not exceed the 2000-character limit.
This function prevents awkward mid-word or unnecessary extra message breaks.
"""
CHUNK_SIZE = 1900 # Keep buffer for formatting/safety
split_message = self.split_message(content, CHUNK_SIZE, prefix)
chunk_size = 1900
split_message = self.split_message(content, chunk_size, prefix)
for chunk in split_message:
await ctx.send(f"{prefix}{chunk}")
def split_message(
self,
message: str,
chunk_size: int,
prefix: str = ""
) -> list[str]:
"""Results in a list of message chunks, use *for* loop to send."""
def split_message(self, message: str, chunk_size: int, prefix: str = "") -> list[str]:
chunk_size -= len(prefix)
split_result = []
if 0 < len(message) <= chunk_size:
# If the message is short enough, add it directly
split_result.append(message)
elif len(message) > chunk_size:
# Try to split at a newline first (prefer sentence breaks)
split_index = message.rfind("\n", 0, chunk_size)
# If no newline, split at the end of sentence (avoid sentence breaks)
if split_index == -1:
split_index = message.rfind(". ", 0, chunk_size)
# If no newline, split at the last word (avoid word-breaking)
if split_index == -1:
split_index = message.rfind(" ", 0, chunk_size)
# If still no break point found, force chunk size limit
if split_index == -1:
split_index = chunk_size
message_split_part = message[:split_index].strip()
message_remained_part = message[split_index:].strip()
# Put the split part in the begining of the result list
split_result.append(message_split_part)
# And go for a recursive adventure with the remained message part
split_result += self.split_message(message=message_remained_part, chunk_size=chunk_size)
return split_result
async def setup(bot):
"""✅ Correct async cog setup for Redbot"""
await bot.add_cog(ReginaldCog(bot))
+42 -17
View File
@@ -1,10 +1,11 @@
from datetime import datetime, timezone
from os import environ
import requests
import json
#WEATHER_API_KEY = environ.get('WEATHER_API_KEY')
URL = 'http://api.weatherapi.com/v1'
import requests
URL = "https://api.weatherapi.com/v1"
REQUEST_TIMEOUT_SECONDS = 10
def time_now() -> str:
@@ -16,10 +17,14 @@ def get_current_weather(location: str) -> str:
return json.dumps(weather.realtime())
def get_weather_forecast(location: str, days: int = None) -> str:
days += 1
def get_weather_forecast(location: str, days: int = 0) -> str:
if days is None:
days = 0
days = int(days) + 1
days = max(1, days)
days = min(14, days)
weather = Weather(location=location)
return json.dumps(weather.forecast(days=days))
@@ -27,7 +32,7 @@ def get_weather_forecast(location: str, days: int = None) -> str:
class Weather:
def __init__(self, location: str):
self.__location = location
self.api_key = environ.get('WEATHER_API_KEY')
self.api_key = environ.get("WEATHER_API_KEY")
@property
def location(self) -> str:
@@ -35,28 +40,48 @@ class Weather:
@staticmethod
def make_request(method: str, params: dict) -> dict:
response = requests.get(url=f'{URL}{method}', params=params)
return response.json()
try:
response = requests.get(
url=f"{URL}{method}",
params=params,
timeout=REQUEST_TIMEOUT_SECONDS,
)
response.raise_for_status()
payload = response.json()
except requests.RequestException as error:
return {"error": f"Weather API request failed: {error}"}
except ValueError as error:
return {"error": f"Weather API returned invalid JSON: {error}"}
if isinstance(payload, dict) and "error" in payload:
api_error = payload["error"]
if isinstance(api_error, dict):
message = api_error.get("message", "Unknown weather API error")
else:
message = str(api_error)
return {"error": message}
return payload
def realtime(self):
method = '/current.json'
method = "/current.json"
params = {
'key': self.api_key,
'q': self.location,
"key": self.api_key,
"q": self.location,
}
return self.make_request(method=method, params=params)
def forecast(self, days: int = 14):
method = '/forecast.json'
method = "/forecast.json"
params = {
'key': self.api_key,
'q': self.location,
'days': days,
"key": self.api_key,
"q": self.location,
"days": days,
}
return self.make_request(method=method, params=params)
if __name__ == '__main__':
test_weather = Weather('Aqtobe')
if __name__ == "__main__":
test_weather = Weather("Aqtobe")
result = json.dumps(test_weather.forecast(days=13), indent=2)
print(result)