Author SHA1 Message Date
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
14 changed files with 288 additions and 408 deletions
+1
View File
@@ -2,6 +2,7 @@
cogs made for our Royal butt. cogs made for our Royal butt.
## Our Cogs: ## Our Cogs:
- [FitnessCog](./fitnessCog)
- [RecruitmentCog](./recruitmentCog/) - [RecruitmentCog](./recruitmentCog/)
- [ReginaldCog](./reginaldCog/) - [ReginaldCog](./reginaldCog/)
- [TrafficCog](./trafficCog) - [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)
+48
View File
@@ -0,0 +1,48 @@
{
"convertable": true,
"units": {
"meter": {
"factor": 1,
"aliases": [
"meter",
"meters",
"m",
"m."
]
},
"kilometer": {
"factor": 1000,
"aliases": [
"kilometer",
"kilometers",
"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
}
+119
View File
@@ -0,0 +1,119 @@
import json
import csv
import os
def activity_log_path(activity: str) -> str:
"""
Returns the path of the activity log file in .csv format
:param activity:
:return:
"""
filename = os.path.join("activity_logs", f"{activity}.csv")
return filename
def activity_units_path(activity: str) -> str:
"""
Returns the path of the activity activities file in .json format
:param activity:
:return:
"""
filename = os.path.join("activities", f"{activity}.json")
return filename
def is_convertable(activity: str) -> bool:
"""
Returns True if the activity has multiple units that needed to be converted.
i.e. distance may be sent in feet, but would require to be converted in meters.
:param activity:
:return:
"""
filename = activity_units_path(activity)
raw_data = json.load(open(filename))
return raw_data.get("transformable")
def create_activity_log_file(activity: str) -> None:
"""
Creates the activity log file in .csv format
:param activity:
:return:
"""
if not os.path.exists(activity_units_path(activity)):
raise ValueError(f"{activity} is not a valid activity")
if not os.path.exists("activity_logs"):
os.makedirs("activity_logs")
filename = activity_log_path(activity)
with open(filename, 'a') as f:
writer = csv.writer(f)
writer.writerow(["timestamp", "username", "value"])
def convert_units(activity: str, value: int | float, unit: str) -> float:
"""
Returns the value of activity in a factor of 1, i.e. converting feet to meters
:param activity:
:param value:
:param unit:
:return:
"""
filename = activity_units_path(activity)
if not os.path.exists(filename):
raise ValueError(f"{activity} is not a valid activity")
# Consider None unit value as factor of 1
if unit is None:
return value
raw_data = json.load(open(filename))
units_data = raw_data.get("units")
for unit_name, data in units_data.items():
if unit in data["aliases"]:
return value * 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
) -> None:
"""
Logs the activity in .csv file
:param timestamp:
:param username:
:param activity:
:param value:
:param unit:
:return:
"""
if value <= 0 or not isinstance(value, int | float):
raise ValueError(f"{value} is not a valid value")
filename = activity_log_path(activity)
if not os.path.exists(filename):
create_activity_log_file(activity)
if is_convertable(activity):
converted_value = round(convert_units(activity, value, unit), 2)
else:
converted_value = round(value, 2)
with open(filename, 'a') as f:
writer = csv.writer(f)
writer.writerow([timestamp, username, converted_value])
if __name__ == "__main__":
log_activity(
timestamp=0,
username="test",
activity="climb",
value=1,
unit="m"
)
+78
View File
@@ -0,0 +1,78 @@
import os
import discord
from redbot.core import Config, commands
from .activity_logger import log_activity, activity_log_path, activity_units_path
class FitnessCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
# Some hardcoded values, this probably should be changed with redbot config. IDK how to use it or what's in it.
default_threads = "1457402451530350727,1328363409648910356"
self.threads_id = list(map(int, os.getenv("THREADS_ID", default_threads).split(",")))
self.confirm_reactions = os.getenv("CONFIRMATION_REACTIONS", "🐈💨")
@commands.Cog.listener()
async def on_message(self, message):
if message.author.bot:
return
if message.channel.id not in self.threads_id:
return
if not message.content.startswith("!"):
return
# region Fitness commands
if message.content.startswith("!getunits"):
await self.get_units_file(message)
return
if message.content.startswith("!getlog"):
await self.get_log_file(message)
return
# Make sure this one is past every other ! commands
await self.fitness_activity_log(message)
return
# endregion Fitness commands
# region Fitness commands functions
@staticmethod
async def get_units_file(message: discord.Message):
try:
raw = message.content[1:].lower().strip().split(" ", 1)
filename = activity_units_path(raw[1])
await message.channel.send(file=discord.File(fp=filename))
except Exception as e:
await message.channel.send(str(e))
@staticmethod
async def get_log_file(message: discord.Message):
try:
raw = message.content[1:].lower().strip().split(" ", 1)
filename = activity_log_path(raw[1])
await message.channel.send(file=discord.File(fp=filename))
except Exception as e:
await message.channel.send(str(e))
async def log_fitness(self, message):
try:
raw = message.content[1:].lower().strip().split(" ", 2)
timestamp = int(message.created_at.timestamp())
username = message.author.name
activity = raw[0]
value = float(raw[1])
unit = raw[2] if len(raw) > 2 else "m"
log_activity(timestamp, username, activity, value, unit)
for r in self.confirm_reactions:
await message.add_reaction(r)
except Exception as e:
await message.reply(str(e))
# endregion Fitness commands functions
def setup(bot):
bot.add_cog(FitnessCog(bot))
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]
@@ -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__)
+25 -25
View File
@@ -16,6 +16,7 @@ from .weather import time_now, get_current_weather, get_weather_forecast
from .tools_description import TOOLS from .tools_description import TOOLS
from .debug_stuff import debug from .debug_stuff import debug
CALLABLE_FUNCTIONS = { CALLABLE_FUNCTIONS = {
# Dictionary with functions to call. # Dictionary with functions to call.
# You can use globals()[func_name](**args) instead, but that's too implicit. # You can use globals()[func_name](**args) instead, but that's too implicit.
@@ -79,6 +80,7 @@ class ReginaldCog(PermissionsMixin, BlacklistMixin, MemoryMixin, commands.Cog):
if not (await self.is_admin(message) or await self.has_access(message.author)): if not (await self.is_admin(message) or await self.has_access(message.author)):
return # Ignore message if user has no permissions return # Ignore message if user has no permissions
guild = message.guild guild = message.guild
channel_id = str(message.channel.id) channel_id = str(message.channel.id)
user_id = str(message.author.id) user_id = str(message.author.id)
@@ -114,7 +116,7 @@ class ReginaldCog(PermissionsMixin, BlacklistMixin, MemoryMixin, commands.Cog):
return return
explicit_invocation = True explicit_invocation = True
# ✅ Passive Listening: Check if the message contains relevant keywords # ✅ Passive Listening: Check if the message contains relevant keywords
elif self.should_reginald_interject(message_content): elif self.should_reginald_interject(message_content):
prompt = message_content prompt = message_content
explicit_invocation = False explicit_invocation = False
@@ -122,11 +124,11 @@ class ReginaldCog(PermissionsMixin, BlacklistMixin, MemoryMixin, commands.Cog):
else: else:
return # Ignore irrelevant messages return # Ignore irrelevant messages
# ✅ Context Handling: Maintain conversation flow # ✅ Context Handling: Maintain conversation flow
if memory and memory[-1]["user"] == user_name: if memory and memory[-1]["user"] == user_name:
prompt = f"Continuation of the discussion:\n{prompt}" prompt = f"Continuation of the discussion:\n{prompt}"
# ✅ Prepare context messages # ✅ Prepare context messages
formatted_messages = [{"role": "system", "content": self.get_reginald_persona()}] formatted_messages = [{"role": "system", "content": self.get_reginald_persona()}]
if user_profile: if user_profile:
@@ -143,10 +145,10 @@ class ReginaldCog(PermissionsMixin, BlacklistMixin, MemoryMixin, commands.Cog):
"content": f"[{summary['timestamp']}] Topics: {', '.join(summary['topics'])}\n{summary['summary']}" "content": f"[{summary['timestamp']}] Topics: {', '.join(summary['topics'])}\n{summary['summary']}"
}) })
formatted_messages += [{"role": "user", "content": f"{entry['user']}: {entry['content']}"} for entry in formatted_messages += [{"role": "user", "content": f"{entry['user']}: {entry['content']}"} for entry in memory]
memory]
formatted_messages.append({"role": "user", "content": f"{user_name}: {prompt}"}) formatted_messages.append({"role": "user", "content": f"{user_name}: {prompt}"})
################################################## ##################################################
# # # #
## Generate AI Response, put into response_text ## ## Generate AI Response, put into response_text ##
@@ -164,8 +166,7 @@ class ReginaldCog(PermissionsMixin, BlacklistMixin, MemoryMixin, commands.Cog):
memory.append({"user": "Reginald", "content": response_text}) memory.append({"user": "Reginald", "content": response_text})
if len(memory) > self.short_term_memory_limit: if len(memory) > self.short_term_memory_limit:
summary = await self.summarize_memory(message, memory[:int( summary = await self.summarize_memory(message, memory[:int(self.short_term_memory_limit * self.summary_retention_ratio)])
self.short_term_memory_limit * self.summary_retention_ratio)])
mid_memory.setdefault(channel_id, []).append({ mid_memory.setdefault(channel_id, []).append({
"timestamp": datetime.datetime.now().strftime("%Y-%m-%d %H:%M"), "timestamp": datetime.datetime.now().strftime("%Y-%m-%d %H:%M"),
"topics": self.extract_topics_from_summary(summary), "topics": self.extract_topics_from_summary(summary),
@@ -173,18 +174,18 @@ class ReginaldCog(PermissionsMixin, BlacklistMixin, MemoryMixin, commands.Cog):
}) })
if len(mid_memory[channel_id]) > self.summary_retention_limit: if len(mid_memory[channel_id]) > self.summary_retention_limit:
mid_memory[channel_id].pop(0) mid_memory[channel_id].pop(0)
memory = memory[-(self.short_term_memory_limit - int( memory = memory[-(self.short_term_memory_limit - int(self.short_term_memory_limit * self.summary_retention_ratio)):]
self.short_term_memory_limit * self.summary_retention_ratio)):]
short_memory[channel_id] = memory short_memory[channel_id] = memory
await self.send_split_message(message.channel, response_text) await self.send_split_message(message.channel, response_text)
def should_reginald_interject(self, message_content: str) -> bool: def should_reginald_interject(self, message_content: str) -> bool:
"""Determines if Reginald should respond to a message based on keywords.""" """Determines if Reginald should respond to a message based on keywords."""
direct_invocation = { direct_invocation = {
"reginald," "reginald,"
} }
message_lower = message_content.lower() message_lower = message_content.lower()
return any(message_lower.startswith(invocation) for invocation in direct_invocation) return any(message_lower.startswith(invocation) for invocation in direct_invocation)
@@ -260,8 +261,7 @@ class ReginaldCog(PermissionsMixin, BlacklistMixin, MemoryMixin, commands.Cog):
await self.config.guild(ctx.guild).openai_api_key.set(api_key) await self.config.guild(ctx.guild).openai_api_key.set(api_key)
await ctx.send("OpenAI API key set successfully.") await ctx.send("OpenAI API key set successfully.")
@commands.command(name="reginald_set_listening_channel", @commands.command(name="reginald_set_listening_channel", help="Set the channel where Reginald listens for messages.")
help="Set the channel where Reginald listens for messages.")
@commands.has_permissions(administrator=True) @commands.has_permissions(administrator=True)
async def set_listening_channel(self, ctx, channel: discord.TextChannel): async def set_listening_channel(self, ctx, channel: discord.TextChannel):
"""Sets the channel where Reginald will listen for passive responses.""" """Sets the channel where Reginald will listen for passive responses."""
@@ -273,8 +273,7 @@ class ReginaldCog(PermissionsMixin, BlacklistMixin, MemoryMixin, commands.Cog):
await self.config.guild(ctx.guild).listening_channel.set(channel.id) 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", @commands.command(name="reginald_get_listening_channel", help="Check which channel Reginald is currently listening in.")
help="Check which channel Reginald is currently listening in.")
@commands.has_permissions(administrator=True) @commands.has_permissions(administrator=True)
async def get_listening_channel(self, ctx): async def get_listening_channel(self, ctx):
"""Displays the current listening channel.""" """Displays the current listening channel."""
@@ -289,16 +288,17 @@ class ReginaldCog(PermissionsMixin, BlacklistMixin, MemoryMixin, commands.Cog):
else: 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.""" async def send_long_message(self, ctx, message, prefix: str = ""):
# chunk_size = 1900 # Leave some space for formatting """Splits and sends a long message to avoid Discord's 2000-character limit."""
# if prefix: chunk_size = 1900 # Leave some space for formatting
# prefix_length = len(prefix) if prefix:
# chunk_size -= prefix_length prefix_length = len(prefix)
# chunk_size -= prefix_length
# for i in range(0, len(message), chunk_size):
# chunk = message[i:i + chunk_size] for i in range(0, len(message), chunk_size):
# await ctx.send(f"{prefix}{chunk}") chunk = message[i:i + chunk_size]
await ctx.send(f"{prefix}{chunk}")
async def send_split_message(self, ctx, content: str, prefix: str = ""): async def send_split_message(self, ctx, content: str, prefix: str = ""):
""" """