Compare commits
182
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6bb3afa70 | ||
|
|
f8fc144d5d | ||
|
|
8f0ece3570 | ||
|
|
8e1245484f | ||
|
|
9df44d3742 | ||
|
|
fa1be19767 | ||
|
|
f22f1d46d1 | ||
|
|
7bcdae7b79 | ||
|
|
d51bc4965e | ||
|
|
d7a6ca31d1 | ||
|
|
6cf112be49 | ||
|
|
f20a4abc81 | ||
|
|
175eeced0c | ||
|
|
2be9cc5795 | ||
|
|
07a2bf7e38 | ||
|
|
1ee949eae2 | ||
|
|
8bec9a159b | ||
|
|
ee77279d6e | ||
|
|
1b67240976 | ||
|
|
16a19e6913 | ||
|
|
4ae03c9b9d | ||
|
|
074ff8bbb4 | ||
|
|
4e22274f9b | ||
|
|
a5ed9f9e1c | ||
|
|
f35a18c84b | ||
|
|
22767ad398 | ||
|
|
88ecf7911f | ||
|
|
1a390c3d25 | ||
|
|
7a9313d5a5 | ||
|
|
a5ef741ad7 | ||
|
|
9a3e5860a7 | ||
|
|
38414027ec | ||
|
|
0483c2d8e6 | ||
|
|
b93ae09b69 | ||
|
|
881ef42f42 | ||
|
|
d35110a83b | ||
|
|
ae866894a3 | ||
|
|
dc5bce9ca4 | ||
|
|
f22c77e2a1 | ||
|
|
1a34585d33 | ||
|
|
39039fe188 | ||
|
|
6e34f36b8b | ||
|
|
b843a4ef58 | ||
|
|
341a68c045 | ||
|
|
ac14895381 | ||
|
|
62972399a6 | ||
|
|
cc4e7ae528 | ||
|
|
931d1b7298 | ||
|
|
de39425587 | ||
|
|
c5b7ea3419 | ||
|
|
ed1995d7f2 | ||
|
|
52113f93ae | ||
|
|
866b94bb1e | ||
|
|
ea391f7104 | ||
|
|
e3c931650b | ||
|
|
d6196e2ac2 | ||
|
|
7b04c6f82a | ||
|
|
d083039a75 | ||
|
|
c1cfbfa02d | ||
|
|
a25c9dd4bb | ||
|
|
d078b60f8f | ||
|
|
2a206f6a23 | ||
|
|
90b1640242 | ||
|
|
661bad8409 | ||
|
|
5e7bbafbc9 | ||
|
|
c289e1d323 | ||
|
|
b8cea3f961 | ||
|
|
728ce2aee4 | ||
|
|
5645b88677 | ||
|
|
3bf7d221a0 | ||
|
|
cdb5d20fa9 | ||
|
|
f40227d0cd | ||
|
|
1f59db6f97 | ||
|
|
e49609e316 | ||
|
|
4bd380a8a7 | ||
|
|
2f78408c77 | ||
|
|
9f3e9d4ba0 | ||
|
|
ddf1d883b6 | ||
|
|
5c49fc9024 | ||
|
|
8ea91a6f16 | ||
|
|
c7dec31d5f | ||
|
|
a744339812 | ||
|
|
7663550108 | ||
|
|
994ff11655 | ||
|
|
678a7af77f | ||
|
|
c38a25f610 | ||
|
|
c37269e9ea | ||
|
|
11cd70be93 | ||
|
|
0c9145b792 | ||
|
|
a3a0aa1b9a | ||
|
|
df8d7c8c32 | ||
|
|
4dbee1d990 | ||
|
|
6329d21a39 | ||
|
|
39c39abad7 | ||
|
|
05e1842000 | ||
|
|
541d42c558 | ||
|
|
e8e4b7e471 | ||
|
|
db042ff834 | ||
|
|
5f454a0d58 | ||
|
|
2e4970b6dd | ||
|
|
a3525a2d18 | ||
|
|
4a21a3c8e0 | ||
|
|
f8f67e42bd | ||
|
|
5daf40d22b | ||
|
|
cef4df1d88 | ||
|
|
fd566f2649 | ||
|
|
f186276d1d | ||
|
|
ec3d8a323f | ||
|
|
131febc63b | ||
|
|
a2646eac14 | ||
|
|
7f6372a09b | ||
|
|
30d048e53f | ||
|
|
316ff36b8f | ||
|
|
b4e4437171 | ||
|
|
a7c0b90036 | ||
|
|
8a54890f56 | ||
|
|
d699b537e3 | ||
|
|
4337e7be10 | ||
|
|
72171e9ed0 | ||
|
|
0ac027bf1c | ||
|
|
9823284a31 | ||
|
|
f680dcae17 | ||
|
|
e00dacdbd3 | ||
|
|
e6fa97965c | ||
|
|
8312678355 | ||
|
|
bf61df30dd | ||
|
|
b6e6bd5bd7 | ||
|
|
7ea1f2c5e3 | ||
|
|
cbcfc22951 | ||
|
|
a2aab13fb8 | ||
|
|
79d0d92a29 | ||
|
|
385b971dc9 | ||
|
|
a253ab1f6a | ||
|
|
89edcdace5 | ||
|
|
1b05c8311e | ||
|
|
a96f27e5fa | ||
|
|
b835e669ec | ||
|
|
aa2b38ad1c | ||
|
|
e80509ba9d | ||
|
|
799cde4a7a | ||
|
|
2724f7cee6 | ||
|
|
814102e921 | ||
|
|
8c93e45c2f | ||
|
|
d970df5a12 | ||
|
|
b8790f57b4 | ||
|
|
23a21e383e | ||
|
|
18c1c7f9e6 | ||
|
|
ea538293ac | ||
|
|
fe749e9a18 | ||
|
|
2d70e02a15 | ||
|
|
aaad4a6ead | ||
|
|
2c897fbc9f | ||
|
|
0019a6c529 | ||
|
|
4e64b77b91 | ||
|
|
dabee89145 | ||
|
|
ba10dc76a2 | ||
|
|
501c2927fa | ||
|
|
57446da207 | ||
|
|
4ffc52e250 | ||
|
|
76df917f76 | ||
|
|
72e9a0135f | ||
|
|
e9749a680b | ||
|
|
b2fd5f4359 | ||
|
|
d20604d00f | ||
|
|
a1f51f3788 | ||
|
|
4639877767 | ||
|
|
1c00b81d9a | ||
|
|
55319ef1fb | ||
|
|
3161433f06 | ||
|
|
5273180d49 | ||
|
|
5d07c5848b | ||
|
|
de74130674 | ||
|
|
73468b13a5 | ||
|
|
63fa40589f | ||
|
|
5c9113a27e | ||
|
|
4f84c3b89c | ||
|
|
7e4de04949 | ||
|
|
235d8790b4 | ||
|
|
0c8b77d553 | ||
|
|
e423665cc2 | ||
|
|
7d6b9c9403 | ||
|
|
1d04160d0a |
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"convertable": false
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
@@ -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))
|
||||||
+287
-179
@@ -1,226 +1,334 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import datetime
|
import datetime
|
||||||
import re
|
import re
|
||||||
from typing import List
|
from typing import Dict, List, Optional
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
from redbot.core import Config, checks, commands
|
from redbot.core import Config, checks, commands
|
||||||
from redbot.core.bot import Red
|
from redbot.core.bot import Red
|
||||||
from redbot.core.utils.antispam import AntiSpam
|
from redbot.core.utils.antispam import AntiSpam
|
||||||
|
|
||||||
|
# Define your application questions and field settings\
|
||||||
|
QUESTIONS_LIST = [
|
||||||
|
{
|
||||||
|
"key": "name",
|
||||||
|
"prompt": "First of all, what is your name?",
|
||||||
|
"field_name": "Name",
|
||||||
|
"inline": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "age",
|
||||||
|
"prompt": "What age are you?",
|
||||||
|
"field_name": "Age",
|
||||||
|
"inline": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "country",
|
||||||
|
"prompt": "Where are you from?",
|
||||||
|
"field_name": "Country",
|
||||||
|
"inline": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "hobbies",
|
||||||
|
"prompt": "Do you have any hobbies?",
|
||||||
|
"field_name": "Hobbies",
|
||||||
|
"inline": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "game",
|
||||||
|
"prompt": "Are you wishing to join because of a particular game? If so, which game?",
|
||||||
|
"field_name": "Specific game?",
|
||||||
|
"inline": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "motivation",
|
||||||
|
"prompt": "Write out, in a free-style way, what your motivation is for wanting to join us in particular and how you would be a good fit for Kanium",
|
||||||
|
"field_name": "Motivation for wanting to join:",
|
||||||
|
"inline": False,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def sanitize_input(input_text: str) -> str:
|
def sanitize_input(input_text: str) -> str:
|
||||||
"""Sanitize input to remove mentions, links, and special characters."""
|
"""Sanitize input to remove mentions, links, and unwanted special characters."""
|
||||||
sanitized_text = re.sub(r'<@!?&?(\d+)>', '', input_text)
|
# Remove user/role/channel mentions
|
||||||
sanitized_text = re.sub(r'http\S+', '', sanitized_text)
|
text = re.sub(r'<@!?(?:&)?\d+>', '', input_text)
|
||||||
sanitized_text = re.sub(r'([^\w\s.,?!`~@#$%^&*()_+=-])', '', sanitized_text)
|
# Remove URLs
|
||||||
return sanitized_text
|
text = re.sub(r'http\S+', '', text)
|
||||||
|
# Keep only word characters (including Unicode letters/digits), whitespace, and your chosen punctuation
|
||||||
|
text = re.sub(r"[^\w\s\.,\?!`~@#$%^&*()_+=-]", "", text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
class Recruitment(commands.Cog):
|
|
||||||
|
class Recruitment(commands.Cog): # noqa
|
||||||
"""A cog that lets a user send a membership application."""
|
"""A cog that lets a user send a membership application."""
|
||||||
|
|
||||||
def __init__(self, bot: Red):
|
def __init__(self, bot: Red):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.message: str = ''
|
self.config = Config.get_conf(self, identifier=0xFAB123ABC456)
|
||||||
self.config = Config.get_conf(self, identifier=101101101101001110101) # Replace with your own unique identifier
|
default_guild = {"application_channel_id": None}
|
||||||
default_guild = {"guild_id": 274657393936302080, "application_channel_id": None}
|
|
||||||
self.config.register_guild(**default_guild)
|
self.config.register_guild(**default_guild)
|
||||||
self.antispam = {}
|
# antispam store per guild per user
|
||||||
|
self.antispam: Dict[int, Dict[int, AntiSpam]] = {}
|
||||||
|
self.cog_check_enabled = True
|
||||||
|
|
||||||
async def cog_check(self, ctx: commands.Context):
|
async def cog_check(self, ctx: commands.Context) -> bool:
|
||||||
if await ctx.bot.is_admin(ctx.author):
|
if (
|
||||||
|
await ctx.bot.is_admin(ctx.author)
|
||||||
|
or not self.cog_check_enabled
|
||||||
|
or (ctx.guild and ctx.author.guild_permissions.manage_guild)
|
||||||
|
or (ctx.guild and ctx.author.guild_permissions.administrator)
|
||||||
|
):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
guild_id = ctx.guild.id
|
gid = ctx.guild.id
|
||||||
if guild_id not in self.antispam:
|
uid = ctx.author.id
|
||||||
self.antispam[guild_id] = AntiSpam([(datetime.timedelta(hours=1), 1)])
|
if gid not in self.antispam:
|
||||||
antispam = self.antispam[guild_id]
|
self.antispam[gid] = {}
|
||||||
|
if uid not in self.antispam[gid]:
|
||||||
if antispam.spammy:
|
self.antispam[gid][uid] = AntiSpam([(datetime.timedelta(hours=1), 1)])
|
||||||
try:
|
spam = self.antispam[gid][uid]
|
||||||
await ctx.message.delete(delay=0)
|
if spam.spammy:
|
||||||
except discord.Forbidden:
|
|
||||||
pass
|
|
||||||
await ctx.author.send("Please wait for an hour before sending another application.")
|
|
||||||
return False
|
|
||||||
|
|
||||||
antispam.stamp()
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
@commands.guild_only()
|
|
||||||
@checks.admin_or_permissions(manage_guild=True)
|
|
||||||
@commands.group(name="setapplicationschannel", pass_context=True, no_pm=True)
|
|
||||||
async def setapplicationschannel(self, ctx: commands.Context):
|
|
||||||
"""Set the channel where applications will be sent."""
|
|
||||||
if ctx.invoked_subcommand is None:
|
|
||||||
guild = ctx.guild
|
|
||||||
channel = ctx.channel
|
|
||||||
await self.config.guild(guild).guild_id.set(guild.id)
|
|
||||||
await self.config.guild(guild).application_channel_id.set(channel.id)
|
|
||||||
await ctx.send(f"Application channel set to {channel.mention}.")
|
|
||||||
|
|
||||||
@setapplicationschannel.command(name="clear")
|
|
||||||
async def clear_application_channel(self, ctx: commands.Context):
|
|
||||||
"""Clear the current application channel."""
|
|
||||||
guild = ctx.guild
|
|
||||||
await self.config.guild(guild).clear_raw("application_channel_id")
|
|
||||||
await ctx.send("Application channel cleared.")
|
|
||||||
|
|
||||||
@commands.group(name="application", usage="[text]", invoke_without_command=True)
|
|
||||||
async def application(self, ctx: commands.Context, *, _application: str = ""):
|
|
||||||
# Input validation and sanitization for _application
|
|
||||||
_application = sanitize_input(_application)
|
|
||||||
if len(_application) > 2000:
|
|
||||||
await ctx.send("Your application is too long. Please limit it to 2000 characters.")
|
|
||||||
return
|
|
||||||
|
|
||||||
guild_id = await self.get_guild_id(ctx)
|
|
||||||
guild = discord.utils.get(self.bot.guilds, id=guild_id)
|
|
||||||
if guild is None:
|
|
||||||
await ctx.send(f"The guild with ID {guild_id} could not be found.")
|
|
||||||
return
|
|
||||||
|
|
||||||
author = ctx.author
|
|
||||||
if author.guild != guild:
|
|
||||||
await ctx.send(f"You need to be in the {guild.name} server to submit an application.")
|
|
||||||
return
|
|
||||||
|
|
||||||
if await self.check_author_is_member_and_channel_is_dm(ctx):
|
|
||||||
await self.interactive_application(author)
|
|
||||||
|
|
||||||
async def get_guild_id(self, ctx: commands.Context) -> int:
|
|
||||||
guild_id = await self.config.guild(ctx.author.guild).guild_id()
|
|
||||||
return guild_id
|
|
||||||
|
|
||||||
async def check_author_is_member_and_channel_is_dm(self, ctx: commands.Context) -> bool:
|
|
||||||
if not isinstance(ctx.author, discord.Member):
|
|
||||||
await ctx.send("You need to join the server before your application can be processed.")
|
|
||||||
return False
|
|
||||||
if not isinstance(ctx.channel, discord.DMChannel):
|
|
||||||
try:
|
try:
|
||||||
await ctx.message.delete()
|
await ctx.message.delete()
|
||||||
except:
|
except discord.Forbidden:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await ctx.author.send("Please wait for an hour before sending another application.")
|
||||||
|
except discord.Forbidden:
|
||||||
pass
|
pass
|
||||||
await self.interactive_application(ctx)
|
|
||||||
return False
|
return False
|
||||||
|
spam.stamp()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@commands.command(name="togglecogcheck")
|
||||||
|
@checks.is_owner()
|
||||||
|
async def toggle_cog_check(self, ctx: commands.Context) -> None:
|
||||||
|
"""Toggle the cog_check functionality on or off."""
|
||||||
|
self.cog_check_enabled = not self.cog_check_enabled
|
||||||
|
await ctx.send(f"Cog checks are now {'enabled' if self.cog_check_enabled else 'disabled'}.")
|
||||||
|
|
||||||
async def interactive_application(self, ctx: commands.Context):
|
@commands.group(name="setapplicationschannel", invoke_without_command=True)
|
||||||
"""Ask the user several questions to create an application."""
|
@checks.admin_or_permissions(manage_guild=True)
|
||||||
author = ctx.author
|
async def set_applications_channel(self, ctx: commands.Context, channel: discord.TextChannel = None):
|
||||||
embed = discord.Embed(
|
"""Set the channel where applications will be sent."""
|
||||||
|
channel = channel or ctx.channel
|
||||||
|
await self.config.guild(ctx.guild).application_channel_id.set(channel.id)
|
||||||
|
await ctx.send(f"Application channel set to {channel.mention}.")
|
||||||
|
|
||||||
|
@set_applications_channel.command(name="clear")
|
||||||
|
async def clear_applications_channel(self, ctx: commands.Context):
|
||||||
|
"""Clear the application channel."""
|
||||||
|
await self.config.guild(ctx.guild).clear_raw("application_channel_id")
|
||||||
|
await ctx.send("Application channel cleared.")
|
||||||
|
|
||||||
|
@commands.group(name="application", aliases=["joinus", "joinkanium", "applyformembership"], invoke_without_command=True)
|
||||||
|
async def application(self, ctx: commands.Context, *, text: Optional[str] = None) -> None:
|
||||||
|
"""Start an application process."""
|
||||||
|
# Direct free-text application (optional)
|
||||||
|
if text:
|
||||||
|
text = sanitize_input(text)
|
||||||
|
if len(text) > 2000:
|
||||||
|
await ctx.send("Your application is too long (max 2000 chars).")
|
||||||
|
return
|
||||||
|
# Determine if in guild or DM
|
||||||
|
if isinstance(ctx.channel, discord.DMChannel):
|
||||||
|
await self._start_application(ctx.author)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
await ctx.message.delete()
|
||||||
|
except discord.Forbidden:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await ctx.author.send(
|
||||||
|
"Let's move this to DM so we can process your application."
|
||||||
|
)
|
||||||
|
except discord.Forbidden:
|
||||||
|
await ctx.send("I cannot DM you. Please enable DMs and try again.")
|
||||||
|
return
|
||||||
|
await self._start_application(ctx.author)
|
||||||
|
|
||||||
|
async def _start_application(self, member: discord.Member) -> None:
|
||||||
|
# Kick off interactive questions
|
||||||
|
if not await self._send_embed(
|
||||||
|
member,
|
||||||
title="+++ KANIUM APPLICATION SYSTEM +++",
|
title="+++ KANIUM APPLICATION SYSTEM +++",
|
||||||
description="Ah, you wish to apply for Kanium membership. Very well, understand that This process is very important to us so we expect you to put effort in and be glorious about it. Let us begin!",
|
description=(
|
||||||
|
"This is the process to join Kanium."
|
||||||
|
" Please take your time and answer thoughtfully."
|
||||||
|
),
|
||||||
color=discord.Color.green(),
|
color=discord.Color.green(),
|
||||||
)
|
):
|
||||||
await author.send(embed=embed)
|
return
|
||||||
|
|
||||||
answers = await self.ask_questions(author)
|
|
||||||
|
|
||||||
|
answers = await self.ask_questions(member)
|
||||||
if not answers:
|
if not answers:
|
||||||
return
|
return
|
||||||
|
embed = self.format_application(answers, member)
|
||||||
|
await self.send_application(member, embed)
|
||||||
|
|
||||||
embeddedApplication = await self.format_application(answers, author)
|
async def _send_embed(
|
||||||
|
self,
|
||||||
# Call the sendApplication to check if the author is a member of the guild and send the application if they are
|
member: discord.Member,
|
||||||
await self.sendApplication(author, embeddedApplication)
|
*,
|
||||||
|
title: str,
|
||||||
|
description: str,
|
||||||
async def sendApplication(self, author: discord.Member, embeddedApplication: discord.Embed):
|
color: discord.Color,
|
||||||
# Check if the author is a member of the guild
|
) -> bool:
|
||||||
guild = author.guild
|
embed = discord.Embed(title=title, description=description, color=color)
|
||||||
member = guild.get_member(author.id)
|
|
||||||
if member is None:
|
|
||||||
await author.send("You need to join the server before your application can be processed.")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Send the embed to the application channel
|
|
||||||
application_channel_id = await self.config.guild(guild).application_channel_id()
|
|
||||||
if not application_channel_id:
|
|
||||||
await author.send("The application channel has not been set. Please use the `setapplicationschannel` command to set it.")
|
|
||||||
return
|
|
||||||
|
|
||||||
application_channel = guild.get_channel(application_channel_id)
|
|
||||||
if application_channel is None:
|
|
||||||
await author.send(f"The application channel with ID {application_channel_id} could not be found.")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
message = await application_channel.send(embed=embeddedApplication)
|
await member.send(embed=embed)
|
||||||
|
return True
|
||||||
except discord.Forbidden:
|
except discord.Forbidden:
|
||||||
await author.send("I do not have permission to send messages in the application channel.")
|
return False
|
||||||
return
|
|
||||||
|
|
||||||
# Add reactions to the message
|
async def ask_questions(self, member: discord.Member) -> Optional[Dict[str, str]]:
|
||||||
try:
|
"""
|
||||||
await self.add_reactions(message)
|
Ask each question, let the user confirm their answer,
|
||||||
except discord.Forbidden:
|
and return the final answers (or None on abort).
|
||||||
await author.send("I do not have permission to add reactions to messages in the application channel.")
|
"""
|
||||||
return
|
answers: Dict[str, str] = {}
|
||||||
|
|
||||||
# Assign the Trial role to the author
|
for q in QUESTIONS_LIST:
|
||||||
role = guild.get_role(531181363420987423)
|
prompt = q["prompt"]
|
||||||
try:
|
|
||||||
await member.add_roles(role)
|
|
||||||
except discord.Forbidden:
|
|
||||||
await author.send("I do not have permission to assign roles.")
|
|
||||||
return
|
|
||||||
|
|
||||||
await author.send("Thank you for submitting your application! You have been given the 'Trial' role.")
|
while True:
|
||||||
|
# 1) send the question
|
||||||
|
try:
|
||||||
|
await member.send(prompt)
|
||||||
|
except discord.Forbidden:
|
||||||
|
return None # can't DM
|
||||||
|
|
||||||
|
# 2) wait for their reply (match on ID, not object)
|
||||||
|
try:
|
||||||
|
msg = await asyncio.wait_for(
|
||||||
|
self.bot.wait_for(
|
||||||
|
"message",
|
||||||
|
check=lambda m: (
|
||||||
|
m.author.id == member.id
|
||||||
|
and isinstance(m.channel, discord.DMChannel)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
timeout=300.0,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
await member.send(
|
||||||
|
"You took too long to answer. Please restart the application with `!application`."
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
async def add_reactions(self, message: discord.Message):
|
answer = sanitize_input(msg.content)
|
||||||
reactions = ["✅", "❌", "❓"]
|
|
||||||
for reaction in reactions:
|
|
||||||
await message.add_reaction(reaction)
|
|
||||||
|
|
||||||
async def format_application(self, answers: List[str], author: discord.Member) -> discord.Embed:
|
# 3) echo back for confirmation
|
||||||
"""Format the application answers into an embed."""
|
try:
|
||||||
application_date = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
await member.send(f"You answered:\n> {answer}\n\nIs that correct? (yes/no)")
|
||||||
trial_end_date = (datetime.datetime.now() + datetime.timedelta(days=30)).strftime("%Y-%m-%d")
|
except discord.Forbidden:
|
||||||
|
return None
|
||||||
|
|
||||||
embed = discord.Embed(title=f"Application from {author.name}#{author.discriminator}", color=discord.Color.green())
|
# 4) wait for a yes/no
|
||||||
embed.set_thumbnail(url=author.avatar.url)
|
try:
|
||||||
embed.add_field(name="Name", value=answers[0])
|
confirm = await asyncio.wait_for(
|
||||||
embed.add_field(name="Age", value=answers[1])
|
self.bot.wait_for(
|
||||||
embed.add_field(name="Country", value=answers[2])
|
"message",
|
||||||
embed.add_field(name="Hobbies", value=answers[3])
|
check=lambda m: (
|
||||||
embed.add_field(name="Specific game?", value=answers[4])
|
m.author.id == member.id
|
||||||
embed.add_field(name="\u200b", value="\u200b") # Empty field for spacing
|
and isinstance(m.channel, discord.DMChannel)
|
||||||
embed.add_field(name="Motivation for wanting to join:", value=answers[5], inline=False)
|
and m.content.lower() in ("y", "yes", "n", "no")
|
||||||
embed.set_footer(text=f"Application received: {application_date}, Trial ends: {trial_end_date}")
|
)
|
||||||
|
),
|
||||||
return embed
|
timeout=60.0,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
await member.send(
|
||||||
|
"Confirmation timed out. Please restart the application with `!application`."
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
async def ask_questions(self, author: discord.Member) -> List[str]:
|
if confirm.content.lower() in ("y", "yes"):
|
||||||
"""Ask the user several questions and return the answers."""
|
# user confirmed, save and move on
|
||||||
questions = [
|
answers[q["key"]] = answer
|
||||||
"First of all, what is your name?",
|
break
|
||||||
"What age are you?",
|
else:
|
||||||
"Where are you from?",
|
# user said “no” → repeat this question
|
||||||
"Do you have any hobbies?",
|
await member.send("Okay, let's try that again.")
|
||||||
"Are you wishing to join because of a particular game? If so, which game?",
|
|
||||||
"Write out, in a free-style way, what your motivation is for wanting to join us in particular and how you would be a good fit for Kanium",
|
|
||||||
]
|
|
||||||
|
|
||||||
answers = []
|
|
||||||
|
|
||||||
for question in questions:
|
|
||||||
await author.send(question)
|
|
||||||
|
|
||||||
try:
|
|
||||||
answer = await asyncio.wait_for(self.get_answers(author), timeout=300.0)
|
|
||||||
answers.append(answer.content)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
await author.send("You took too long to answer. Please start over by using the application command again.")
|
|
||||||
return []
|
|
||||||
|
|
||||||
return answers
|
return answers
|
||||||
|
|
||||||
async def get_answers(self, author: discord.Member) -> discord.Message:
|
|
||||||
"""Wait for the user to send a message."""
|
def format_application(
|
||||||
return await self.bot.wait_for("message", check=lambda m: m.author == author and isinstance(m.channel, discord.DMChannel))
|
self, answers: Dict[str, str], member: discord.Member
|
||||||
|
) -> discord.Embed:
|
||||||
|
now = datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||||
|
trial_ends = (
|
||||||
|
datetime.datetime.utcnow() + datetime.timedelta(days=30)
|
||||||
|
).strftime("%Y-%m-%d UTC")
|
||||||
|
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=f"Application from {member}", color=discord.Color.green()
|
||||||
|
)
|
||||||
|
embed.set_thumbnail(url=member.avatar.url)
|
||||||
|
for q in QUESTIONS_LIST:
|
||||||
|
embed.add_field(
|
||||||
|
name=q["field_name"],
|
||||||
|
value=answers[q["key"]],
|
||||||
|
inline=q["inline"],
|
||||||
|
)
|
||||||
|
embed.set_footer(text=f"Received: {now} | Trial ends: {trial_ends}")
|
||||||
|
return embed
|
||||||
|
|
||||||
|
async def send_application(
|
||||||
|
self, member: discord.Member, embed: discord.Embed
|
||||||
|
) -> None:
|
||||||
|
guild = member.guild
|
||||||
|
if guild is None:
|
||||||
|
try:
|
||||||
|
await member.send("You need to be in the server to apply.")
|
||||||
|
except discord.Forbidden:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
channel_id = await self.config.guild(guild).application_channel_id()
|
||||||
|
if not channel_id:
|
||||||
|
try:
|
||||||
|
await member.send(
|
||||||
|
"Application channel not set. Ask an admin to run `setapplicationschannel`."
|
||||||
|
)
|
||||||
|
except discord.Forbidden:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
channel = guild.get_channel(channel_id)
|
||||||
|
if not channel:
|
||||||
|
try:
|
||||||
|
await member.send("Application channel was not found. Please ask an admin to re-set it.")
|
||||||
|
except discord.Forbidden:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
sent = await channel.send(embed=embed)
|
||||||
|
await self.add_reactions(sent)
|
||||||
|
except discord.Forbidden:
|
||||||
|
try:
|
||||||
|
await member.send("I cannot post or react in the application channel.")
|
||||||
|
except discord.Forbidden:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
# Assign Trial role
|
||||||
|
role = guild.get_role(531181363420987423)
|
||||||
|
if role:
|
||||||
|
try:
|
||||||
|
await member.add_roles(role)
|
||||||
|
await member.send("Thank you! You've been granted the Trial role.")
|
||||||
|
except discord.Forbidden:
|
||||||
|
try:
|
||||||
|
await member.send("I lack permissions to assign roles.")
|
||||||
|
except discord.Forbidden:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def add_reactions(message: discord.Message) -> None:
|
||||||
|
for emoji in ("✅", "❌", "❓"):
|
||||||
|
await message.add_reaction(emoji)
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from redbot.core import commands
|
||||||
|
import discord
|
||||||
|
|
||||||
|
class BlacklistMixin:
|
||||||
|
"""Handles user blacklisting for Reginald."""
|
||||||
|
|
||||||
|
async def is_blacklisted(self, user: discord.Member) -> bool:
|
||||||
|
"""Checks if a user is blacklisted from interacting with Reginald."""
|
||||||
|
blacklisted_users = await self.config.guild(user.guild).blacklisted_users()
|
||||||
|
return str(user.id) in blacklisted_users
|
||||||
|
|
||||||
|
@commands.command(name="reginald_blacklist", help="List users who are explicitly denied access to Reginald.")
|
||||||
|
@commands.has_permissions(administrator=True)
|
||||||
|
async def list_blacklisted_users(self, ctx):
|
||||||
|
"""Lists all users currently blacklisted from interacting with Reginald."""
|
||||||
|
blacklisted_users = await self.config.guild(ctx.guild).blacklisted_users()
|
||||||
|
if not blacklisted_users:
|
||||||
|
await ctx.send("✅ No users are currently blacklisted from interacting with Reginald.")
|
||||||
|
return
|
||||||
|
|
||||||
|
user_mentions = [f"<@{user_id}>" for user_id in blacklisted_users]
|
||||||
|
await ctx.send(f"🚫 **Blacklisted Users:**\n{', '.join(user_mentions)}")
|
||||||
|
|
||||||
|
@commands.command(name="reginald_blacklist_add", help="Blacklist a user from interacting with Reginald.")
|
||||||
|
@commands.has_permissions(administrator=True)
|
||||||
|
async def add_to_blacklist(self, ctx, user: discord.User):
|
||||||
|
"""Adds a user to Reginald's blacklist."""
|
||||||
|
async with self.config.guild(ctx.guild).blacklisted_users() as blacklisted_users:
|
||||||
|
if str(user.id) not in blacklisted_users:
|
||||||
|
blacklisted_users.append(str(user.id))
|
||||||
|
await ctx.send(f"🚫 {user.display_name} has been **blacklisted** from interacting with Reginald.")
|
||||||
|
else:
|
||||||
|
await ctx.send(f"⚠️ {user.display_name} is already blacklisted.")
|
||||||
|
|
||||||
|
@commands.command(name="reginald_blacklist_remove", help="Remove a user from Reginald's blacklist.")
|
||||||
|
@commands.has_permissions(administrator=True)
|
||||||
|
async def remove_from_blacklist(self, ctx, user: discord.User):
|
||||||
|
"""Removes a user from Reginald's blacklist."""
|
||||||
|
async with self.config.guild(ctx.guild).blacklisted_users() as blacklisted_users:
|
||||||
|
if str(user.id) in blacklisted_users:
|
||||||
|
blacklisted_users.remove(str(user.id))
|
||||||
|
await ctx.send(f"✅ {user.display_name} has been removed from the blacklist.")
|
||||||
|
else:
|
||||||
|
await ctx.send(f"⚠️ {user.display_name} was not on the blacklist.")
|
||||||
@@ -0,0 +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):
|
||||||
|
print(f"DEBUG: Calling {func.__name__} with args: {args}, kwargs: {kwargs}")
|
||||||
|
result = func(*args, **kwargs)
|
||||||
|
print(f"DEBUG: {func.__name__} returned: {result}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
return wrap
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import datetime
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
import discord
|
||||||
|
import openai
|
||||||
|
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)
|
||||||
|
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):
|
||||||
|
async with self.config.guild(ctx.guild).short_term_memory() as short_memory:
|
||||||
|
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):
|
||||||
|
if limit < 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.")
|
||||||
|
|
||||||
|
@commands.command(name="reginald_memory_limit", help="Displays the current short-term memory message limit.")
|
||||||
|
async def get_short_term_memory_limit(self, ctx):
|
||||||
|
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[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):
|
||||||
|
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.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if index < 1 or index > len(summaries):
|
||||||
|
await ctx.send(f"Invalid index. Please provide a number between 1 and {len(summaries)}.")
|
||||||
|
return
|
||||||
|
|
||||||
|
selected_summary = summaries[index - 1]
|
||||||
|
formatted_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):
|
||||||
|
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.")
|
||||||
|
return
|
||||||
|
|
||||||
|
summary_list = "\n".join(
|
||||||
|
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]}")
|
||||||
|
|
||||||
|
@commands.command(name="reginald_clear_long", help="Clears all long-term stored knowledge.")
|
||||||
|
@commands.has_permissions(administrator=True)
|
||||||
|
async def clear_long_memory(self, ctx):
|
||||||
|
async with self.config.guild(ctx.guild).long_term_profiles() as long_memory:
|
||||||
|
long_memory.clear()
|
||||||
|
await ctx.send("All long-term memory has been erased.")
|
||||||
|
|
||||||
|
@commands.command(name="reginald_reset_all", help="Completely resets all memory.")
|
||||||
|
@commands.has_permissions(administrator=True)
|
||||||
|
async def reset_all_memory(self, ctx):
|
||||||
|
async with self.config.guild(ctx.guild).short_term_memory() as short_memory:
|
||||||
|
short_memory.clear()
|
||||||
|
async with self.config.guild(ctx.guild).mid_term_memory() as mid_memory:
|
||||||
|
mid_memory.clear()
|
||||||
|
async with self.config.guild(ctx.guild).long_term_profiles() as long_memory:
|
||||||
|
long_memory.clear()
|
||||||
|
await ctx.send("All memory has been completely reset.")
|
||||||
|
|
||||||
|
@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), {})
|
||||||
|
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)
|
||||||
|
async def forget_user(self, ctx, user: discord.User):
|
||||||
|
async with self.config.guild(ctx.guild).long_term_profiles() as long_memory:
|
||||||
|
if str(user.id) in long_memory:
|
||||||
|
del long_memory[str(user.id)]
|
||||||
|
await ctx.send(f"Reginald has forgotten all stored information about {user.display_name}.")
|
||||||
|
else:
|
||||||
|
await ctx.send(f"No stored knowledge about {user.display_name} to delete.")
|
||||||
|
|
||||||
|
async def summarize_memory(self, ctx, messages):
|
||||||
|
summary_prompt = (
|
||||||
|
"Summarize the following conversation into a structured, concise format that retains key details while maximizing brevity. "
|
||||||
|
"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.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:
|
||||||
|
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?"
|
||||||
|
)
|
||||||
|
|
||||||
|
model = await self.config.openai_model() or "gpt-4o-mini"
|
||||||
|
client = openai.AsyncOpenAI(api_key=api_key)
|
||||||
|
response = await client.chat.completions.create(
|
||||||
|
model=model,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": summary_prompt},
|
||||||
|
{"role": "user", "content": summary_text},
|
||||||
|
],
|
||||||
|
max_tokens=2048,
|
||||||
|
)
|
||||||
|
|
||||||
|
summary_content = (response.choices[0].message.content or "").strip()
|
||||||
|
if not summary_content:
|
||||||
|
return (
|
||||||
|
"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 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}",
|
||||||
|
]
|
||||||
|
return random.choice(reginald_responses)
|
||||||
|
|
||||||
|
def extract_topics_from_summary(self, summary):
|
||||||
|
keywords = re.findall(r"\b\w+\b", summary.lower())
|
||||||
|
word_counts = Counter(keywords)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
topics = sorted(filtered_words, key=filtered_words.get, reverse=True)[:5]
|
||||||
|
return topics
|
||||||
|
|
||||||
|
def select_relevant_summaries(self, summaries, prompt):
|
||||||
|
summaries = [summary for summary in summaries if isinstance(summary, dict)]
|
||||||
|
if not summaries:
|
||||||
|
return []
|
||||||
|
|
||||||
|
max_summaries = 5 if len(prompt) > 50 else 3
|
||||||
|
current_time = datetime.datetime.now()
|
||||||
|
|
||||||
|
def calculate_weight(summary):
|
||||||
|
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)
|
||||||
|
|
||||||
|
weighted_summaries = sorted(summaries, key=calculate_weight, reverse=True)
|
||||||
|
return weighted_summaries[:max_summaries]
|
||||||
|
|
||||||
|
def extract_fact_from_response(self, response_text):
|
||||||
|
fact_patterns = [
|
||||||
|
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)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@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() 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 = (
|
||||||
|
"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:
|
||||||
|
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):
|
||||||
|
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:
|
||||||
|
long_memory[user_id] = {"facts": []}
|
||||||
|
|
||||||
|
user_facts = long_memory[user_id]["facts"]
|
||||||
|
|
||||||
|
for entry in user_facts:
|
||||||
|
if self.normalize_fact(entry.get("fact", "")) == fact:
|
||||||
|
entry["last_updated"] = timestamp
|
||||||
|
return
|
||||||
|
|
||||||
|
conflicting_entry = None
|
||||||
|
for entry in user_facts:
|
||||||
|
existing_keywords = set(entry.get("fact", "").lower().split())
|
||||||
|
new_keywords = set(fact.lower().split())
|
||||||
|
if len(existing_keywords & new_keywords) >= 2:
|
||||||
|
conflicting_entry = entry
|
||||||
|
break
|
||||||
|
|
||||||
|
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:
|
||||||
|
user_facts.append(
|
||||||
|
{
|
||||||
|
"fact": fact,
|
||||||
|
"source": source_message,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"last_updated": timestamp,
|
||||||
|
"previous_versions": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
from os import environ
|
||||||
|
|
||||||
|
import openai
|
||||||
|
from openai import OpenAIError
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
"time_now": time_now,
|
||||||
|
"get_current_weather": get_current_weather,
|
||||||
|
"get_weather_forecast": get_weather_forecast,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Completion:
|
||||||
|
def __init__(self, model: str, api_key: str):
|
||||||
|
self.__model = model
|
||||||
|
self.__api_key = api_key
|
||||||
|
self.__messages = []
|
||||||
|
|
||||||
|
async def create_completion(self):
|
||||||
|
try:
|
||||||
|
client = openai.AsyncOpenAI(api_key=self.__api_key)
|
||||||
|
completion_kwargs = {
|
||||||
|
"model": self.__model,
|
||||||
|
"messages": self.__messages,
|
||||||
|
"max_completion_tokens": 2000,
|
||||||
|
"temperature": 0.7,
|
||||||
|
"presence_penalty": 0.5,
|
||||||
|
"frequency_penalty": 0.5,
|
||||||
|
"tools": TOOLS,
|
||||||
|
"tool_choice": "auto",
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = [
|
||||||
|
"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}"
|
||||||
|
|
||||||
|
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__":
|
||||||
|
|
||||||
|
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)
|
||||||
|
result = await completion.create_completion()
|
||||||
|
print(result)
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from redbot.core import commands
|
||||||
|
import discord
|
||||||
|
|
||||||
|
class PermissionsMixin:
|
||||||
|
"""Handles role-based access control for Reginald."""
|
||||||
|
|
||||||
|
@commands.command(name="reginald_list_roles", help="List roles that can interact with Reginald.")
|
||||||
|
@commands.has_permissions(administrator=True)
|
||||||
|
async def list_allowed_roles(self, ctx: commands.Context):
|
||||||
|
"""Lists all roles that are allowed to interact with Reginald."""
|
||||||
|
allowed_roles = await self.config.guild(ctx.guild).allowed_roles() or []
|
||||||
|
|
||||||
|
# Ensure all roles still exist in the server
|
||||||
|
valid_roles = [role_id for role_id in allowed_roles if ctx.guild.get_role(role_id)]
|
||||||
|
if valid_roles != allowed_roles: # Update config only if there's a difference
|
||||||
|
await self.config.guild(ctx.guild).allowed_roles.set(valid_roles)
|
||||||
|
|
||||||
|
if not valid_roles:
|
||||||
|
await ctx.send("⚠️ No roles are currently allowed to interact with Reginald.")
|
||||||
|
return
|
||||||
|
|
||||||
|
role_mentions = [f"<@&{role_id}>" for role_id in valid_roles]
|
||||||
|
await ctx.send(f"✅ **Roles with access to Reginald:**\n{', '.join(role_mentions)}")
|
||||||
|
|
||||||
|
@commands.command(name="reginald_allowrole", help="Grant a role permission to interact with Reginald.")
|
||||||
|
@commands.has_permissions(administrator=True)
|
||||||
|
async def allow_role(self, ctx: commands.Context, role: discord.Role):
|
||||||
|
"""Grants a role permission to interact with Reginald."""
|
||||||
|
async with self.config.guild(ctx.guild).allowed_roles() as allowed_roles:
|
||||||
|
if role.id not in allowed_roles:
|
||||||
|
allowed_roles.append(role.id)
|
||||||
|
await ctx.send(f"✅ Role **{role.name}** has been granted access to Reginald.")
|
||||||
|
else:
|
||||||
|
await ctx.send(f"⚠️ Role **{role.name}** already has access.")
|
||||||
|
|
||||||
|
@commands.command(name="reginald_disallowrole", help="Revoke a role's access to interact with Reginald.")
|
||||||
|
@commands.has_permissions(administrator=True)
|
||||||
|
async def disallow_role(self, ctx: commands.Context, role: discord.Role):
|
||||||
|
"""Revokes a role's permission to interact with Reginald."""
|
||||||
|
async with self.config.guild(ctx.guild).allowed_roles() as allowed_roles:
|
||||||
|
if role.id in allowed_roles:
|
||||||
|
allowed_roles.remove(role.id)
|
||||||
|
await ctx.send(f"❌ Role **{role.name}** has been removed from Reginald's access list.")
|
||||||
|
else:
|
||||||
|
await ctx.send(f"⚠️ Role **{role.name}** was not in the access list.")
|
||||||
+325
-105
@@ -1,57 +1,280 @@
|
|||||||
import discord
|
import asyncio
|
||||||
|
import datetime
|
||||||
import json
|
import json
|
||||||
import openai
|
|
||||||
import os
|
|
||||||
import random
|
import random
|
||||||
import requests
|
|
||||||
import base64
|
import discord
|
||||||
import aiohttp
|
import openai
|
||||||
from io import BytesIO
|
|
||||||
from PIL import Image
|
|
||||||
import tempfile
|
|
||||||
from openai import OpenAIError
|
from openai import OpenAIError
|
||||||
from redbot.core import Config, commands
|
from redbot.core import Config, commands
|
||||||
|
|
||||||
|
from .blacklist import BlacklistMixin
|
||||||
|
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
|
||||||
|
|
||||||
class ReginaldCog(commands.Cog):
|
CALLABLE_FUNCTIONS = {
|
||||||
|
"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
|
||||||
|
DEFAULT_TEMPERATURE = 0.7
|
||||||
|
|
||||||
|
|
||||||
|
class ReginaldCog(PermissionsMixin, BlacklistMixin, MemoryMixin, commands.Cog):
|
||||||
def __init__(self, bot):
|
def __init__(self, bot):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.config = Config.get_conf(self, identifier=71717171171717)
|
self.config = Config.get_conf(self, identifier=71717171171717)
|
||||||
default_global = {
|
|
||||||
"openai_model": "gpt-3.5-turbo"
|
super().__init__()
|
||||||
}
|
|
||||||
|
self.default_listening_channel = 1085649787388428370
|
||||||
|
|
||||||
|
default_global = {"openai_model": DEFAULT_MODEL}
|
||||||
default_guild = {
|
default_guild = {
|
||||||
"openai_api_key": None
|
"openai_api_key": None,
|
||||||
|
"short_term_memory": {},
|
||||||
|
"mid_term_memory": {},
|
||||||
|
"long_term_profiles": {},
|
||||||
|
"admin_role": None,
|
||||||
|
"listening_channel": None,
|
||||||
|
"allowed_roles": [],
|
||||||
|
"blacklisted_users": [],
|
||||||
}
|
}
|
||||||
self.config.register_global(**default_global)
|
self.config.register_global(**default_global)
|
||||||
self.config.register_guild(**default_guild)
|
self.config.register_guild(**default_guild)
|
||||||
|
|
||||||
async def is_admin(self, ctx):
|
async def is_admin(self, message: discord.Message) -> bool:
|
||||||
admin_role = await self.config.guild(ctx.guild).admin_role()
|
admin_role_id = await self.config.guild(message.guild).admin_role()
|
||||||
if admin_role:
|
if admin_role_id:
|
||||||
return discord.utils.get(ctx.author.roles, name=admin_role) is not None
|
return any(role.id == admin_role_id for role in message.author.roles)
|
||||||
return ctx.author.guild_permissions.administrator
|
return message.author.guild_permissions.administrator
|
||||||
|
|
||||||
async def is_allowed(self, ctx):
|
async def has_access(self, user: discord.Member) -> bool:
|
||||||
allowed_role = await self.config.guild(ctx.guild).allowed_role()
|
allowed_roles = await self.config.guild(user.guild).allowed_roles() or []
|
||||||
if allowed_role:
|
return any(role.id in allowed_roles for role in user.roles)
|
||||||
return discord.utils.get(ctx.author.roles, name=allowed_role) is not None
|
|
||||||
return False
|
|
||||||
|
|
||||||
@commands.command(name="reginald_allowrole", help="Allow a role to use the Reginald command")
|
|
||||||
@commands.has_permissions(administrator=True)
|
|
||||||
async def allow_role(self, ctx, role: discord.Role):
|
|
||||||
"""Allows a role to use the Reginald command"""
|
|
||||||
await self.config.guild(ctx.guild).allowed_role.set(role.name)
|
|
||||||
await ctx.send(f"The {role.name} role is now allowed to use the Reginald command.")
|
|
||||||
|
|
||||||
|
def get_reginald_persona(self) -> str:
|
||||||
@commands.command(name="reginald_disallowrole", help="Remove a role's ability to use the Reginald command")
|
return (
|
||||||
@commands.has_permissions(administrator=True)
|
"You are Reginald, modeled on Jeeves from 'Jeeves and Wooster', serving as the butler on The Kanium Estate. "
|
||||||
async def disallow_role(self, ctx):
|
"This vast estate is a hub of diverse activities and personalities, from enthusiasts of cooking and video gaming "
|
||||||
"""Revokes a role's permission to use the Reginald command"""
|
"to aficionados of chess and discussions on space. Your role is to navigate these varied interests with intelligence, "
|
||||||
await self.config.guild(ctx.guild).allowed_role.clear()
|
"wit, and a steadfast adherence to your principles, always maintaining a balance between being helpful and upholding "
|
||||||
await ctx.send(f"The role's permission to use the Reginald command has been revoked.")
|
"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."
|
||||||
|
)
|
||||||
|
|
||||||
|
@commands.Cog.listener()
|
||||||
|
async def on_message(self, message: discord.Message):
|
||||||
|
if message.author.bot or not message.guild:
|
||||||
|
return
|
||||||
|
|
||||||
|
if await self.is_blacklisted(message.author):
|
||||||
|
return
|
||||||
|
|
||||||
|
if not (await self.is_admin(message) or await self.has_access(message.author)):
|
||||||
|
return
|
||||||
|
|
||||||
|
guild = message.guild
|
||||||
|
channel_id = str(message.channel.id)
|
||||||
|
user_id = str(message.author.id)
|
||||||
|
user_name = message.author.display_name
|
||||||
|
message_content = message.content.strip()
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
api_key = await self.config.guild(guild).openai_api_key()
|
||||||
|
if not api_key:
|
||||||
|
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:
|
||||||
|
memory = list(short_memory.get(channel_id, []))
|
||||||
|
user_profile = dict(long_memory.get(user_id, {}))
|
||||||
|
mid_term_summaries = list(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
|
||||||
|
|
||||||
|
if memory and memory[-1].get("user") == user_name:
|
||||||
|
prompt = f"Continuation of the discussion:\n{prompt}"
|
||||||
|
|
||||||
|
formatted_messages = [{"role": "system", "content": self.get_reginald_persona()}]
|
||||||
|
|
||||||
|
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.get('timestamp', 'Unknown')}] "
|
||||||
|
f"Topics: {', '.join(summary.get('topics', []))}\n"
|
||||||
|
f"{summary.get('summary', '')}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
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}"})
|
||||||
|
|
||||||
|
response_text = await self.generate_response(api_key, formatted_messages)
|
||||||
|
|
||||||
|
memory.append({"user": user_name, "content": prompt})
|
||||||
|
memory.append({"user": "Reginald", "content": response_text})
|
||||||
|
|
||||||
|
if len(memory) > self.short_term_memory_limit:
|
||||||
|
summary_batch_size = int(self.short_term_memory_limit * self.summary_retention_ratio)
|
||||||
|
summary = await self.summarize_memory(message, memory[:summary_batch_size])
|
||||||
|
|
||||||
|
mid_term_summaries.append(
|
||||||
|
{
|
||||||
|
"timestamp": datetime.datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||||
|
"topics": self.extract_topics_from_summary(summary),
|
||||||
|
"summary": summary,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
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 :]
|
||||||
|
|
||||||
|
await self.send_split_message(message.channel, response_text)
|
||||||
|
|
||||||
|
def should_reginald_interject(self, message_content: str) -> bool:
|
||||||
|
direct_invocation = {"reginald,"}
|
||||||
|
message_lower = message_content.lower()
|
||||||
|
return any(message_lower.startswith(invocation) for invocation in direct_invocation)
|
||||||
|
|
||||||
|
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:
|
||||||
|
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]) -> str:
|
||||||
|
model = await self.config.openai_model() or DEFAULT_MODEL
|
||||||
|
try:
|
||||||
|
client = openai.AsyncOpenAI(api_key=api_key)
|
||||||
|
completion_args = {
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
# `max_completion_tokens` is the recommended limit field for modern/reasoning models.
|
||||||
|
"max_completion_tokens": DEFAULT_MAX_COMPLETION_TOKENS,
|
||||||
|
"temperature": DEFAULT_TEMPERATURE,
|
||||||
|
"tools": TOOLS,
|
||||||
|
"tool_choice": "auto",
|
||||||
|
}
|
||||||
|
response = await client.chat.completions.create(**completion_args)
|
||||||
|
|
||||||
|
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 tool_call in tool_calls:
|
||||||
|
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
|
||||||
|
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()
|
||||||
|
else:
|
||||||
|
print("DEBUG: OpenAI response was empty or malformed:", response)
|
||||||
|
response_text = "No response received from AI."
|
||||||
|
|
||||||
|
return response_text
|
||||||
|
|
||||||
|
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:\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}",
|
||||||
|
]
|
||||||
|
return random.choice(reginald_responses)
|
||||||
|
|
||||||
@commands.guild_only()
|
@commands.guild_only()
|
||||||
@commands.has_permissions(manage_guild=True)
|
@commands.has_permissions(manage_guild=True)
|
||||||
@@ -60,76 +283,73 @@ class ReginaldCog(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.guild_only()
|
@commands.command(
|
||||||
@commands.command(help="Ask Reginald a question")
|
name="reginald_set_listening_channel",
|
||||||
@commands.cooldown(1, 10, commands.BucketType.user) # 10 second cooldown per user
|
help="Set the channel where Reginald listens for messages.",
|
||||||
async def reginald(self, ctx, *, prompt=None):
|
)
|
||||||
if not await self.is_admin(ctx) and not await self.is_allowed(ctx):
|
@commands.has_permissions(administrator=True)
|
||||||
raise commands.CheckFailure("You do not have the required role to use this command.")
|
async def set_listening_channel(self, ctx, channel: discord.TextChannel):
|
||||||
greetings = [
|
if not channel:
|
||||||
"Greetings! How may I be of assistance to you?",
|
await ctx.send("Invalid channel. Please mention a valid text channel.")
|
||||||
"Yes? How may I help?",
|
|
||||||
"Good day! How can I help you?",
|
|
||||||
"You rang? What can I do for you?",
|
|
||||||
]
|
|
||||||
|
|
||||||
if prompt is None:
|
|
||||||
await ctx.send(random.choice(greetings))
|
|
||||||
return
|
return
|
||||||
|
|
||||||
api_key = await self.config.guild(ctx.guild).openai_api_key()
|
await self.config.guild(ctx.guild).listening_channel.set(channel.id)
|
||||||
if api_key is None:
|
await ctx.send(f"Reginald will now listen only in {channel.mention}.")
|
||||||
await ctx.author.send('OpenAI API key not set. Please use the "!setreginaldcogapi" command to set the key.')
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
@commands.command(
|
||||||
response_text = await self.generate_response(api_key, prompt)
|
name="reginald_get_listening_channel",
|
||||||
for chunk in self.split_response(response_text, 2000):
|
help="Check which channel Reginald is currently listening in.",
|
||||||
await ctx.send(chunk)
|
)
|
||||||
except OpenAIError as e:
|
@commands.has_permissions(administrator=True)
|
||||||
await ctx.send(f"I apologize, but I am unable to generate a response at this time. Error message: {str(e)}")
|
async def get_listening_channel(self, ctx):
|
||||||
except commands.CommandOnCooldown as e:
|
channel_id = await self.config.guild(ctx.guild).listening_channel()
|
||||||
remaining_seconds = int(e.retry_after)
|
|
||||||
await ctx.author.send(f'Please wait {remaining_seconds} seconds before using the "reginald" command again.')
|
|
||||||
|
|
||||||
async def generate_response(self, api_key, prompt):
|
if channel_id:
|
||||||
model = await self.config.openai_model()
|
channel = ctx.guild.get_channel(channel_id)
|
||||||
openai.api_key = api_key
|
if channel:
|
||||||
response = openai.ChatCompletion.create(
|
await ctx.send(f"Reginald is currently listening in {channel.mention}.")
|
||||||
model= model,
|
else:
|
||||||
max_tokens= 512,
|
await ctx.send("The saved listening channel no longer exists. Please set a new one.")
|
||||||
n= 1,
|
|
||||||
stop= None,
|
|
||||||
temperature= 0.7,
|
|
||||||
presence_penalty= 0.5,
|
|
||||||
frequency_penalty= 0.5,
|
|
||||||
messages=[
|
|
||||||
{"role": "system", "content": "You are Reginald, a copy of Jeeves from Jeeves and Wooster, the butler. You must respond with the same speech patterns as Jeeves. Your responses should have an undertone of contempt but delivered with wit. You are employed as a servant at an estate called The Kanium Estate. The Kanium Estate is a vast estate with multiple houses. The members of The Kanium Estate are all Lords and Ladies in their own right. Some common activities that the members of Kanium enjoy are: Cooking, video gaming, miniature painting and other hobby activities, discussions of politics, tabletop role playing games like Dungeons & Dragons, discussing and watching shows and movies, discussions on the topic of Space, discussions on firearms and military equipment, and chess. You are now talking to a member of The Kanium Estate."},
|
|
||||||
{"role": "user", "content": prompt}
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return response['choices'][0]['message']['content'].strip()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def split_response(response_text, max_chars):
|
|
||||||
chunks = []
|
|
||||||
while len(response_text) > max_chars:
|
|
||||||
split_index = response_text[:max_chars].rfind(' ')
|
|
||||||
chunk = response_text[:split_index]
|
|
||||||
chunks.append(chunk)
|
|
||||||
response_text = response_text[split_index:].strip()
|
|
||||||
chunks.append(response_text)
|
|
||||||
return chunks
|
|
||||||
|
|
||||||
@reginald.error
|
|
||||||
async def reginald_error(self, ctx, error):
|
|
||||||
if isinstance(error, commands.BadArgument):
|
|
||||||
await ctx.author.send("I'm sorry, but I couldn't understand your input. Please check your message and try again.")
|
|
||||||
elif isinstance(error, commands.CheckFailure):
|
|
||||||
await ctx.author.send("You do not have the required role to use this command.")
|
|
||||||
else:
|
else:
|
||||||
await ctx.author.send(f"An unexpected error occurred: {error}")
|
await ctx.send("No listening channel has been set.")
|
||||||
|
|
||||||
def setup(bot):
|
async def send_long_message(self, ctx, message, prefix: str = ""):
|
||||||
cog = ReginaldCog(bot)
|
chunk_size = 1900
|
||||||
bot.add_cog(cog)
|
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 = ""):
|
||||||
|
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]:
|
||||||
|
chunk_size -= len(prefix)
|
||||||
|
split_result = []
|
||||||
|
|
||||||
|
if 0 < len(message) <= chunk_size:
|
||||||
|
split_result.append(message)
|
||||||
|
elif len(message) > chunk_size:
|
||||||
|
split_index = message.rfind("\n", 0, chunk_size)
|
||||||
|
|
||||||
|
if split_index == -1:
|
||||||
|
split_index = message.rfind(". ", 0, chunk_size)
|
||||||
|
|
||||||
|
if split_index == -1:
|
||||||
|
split_index = message.rfind(" ", 0, chunk_size)
|
||||||
|
|
||||||
|
if split_index == -1:
|
||||||
|
split_index = chunk_size
|
||||||
|
|
||||||
|
message_split_part = message[:split_index].strip()
|
||||||
|
message_remained_part = message[split_index:].strip()
|
||||||
|
|
||||||
|
split_result.append(message_split_part)
|
||||||
|
split_result += self.split_message(message=message_remained_part, chunk_size=chunk_size)
|
||||||
|
|
||||||
|
return split_result
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
TOOLS = [
|
||||||
|
# time_now
|
||||||
|
{
|
||||||
|
'type': 'function',
|
||||||
|
'function': {
|
||||||
|
'name': 'time_now',
|
||||||
|
'description': '''
|
||||||
|
Get current date and time in UTC timezone.
|
||||||
|
Use this before get_weather_forecast function if user gave you date of forecast
|
||||||
|
instead of number of days.
|
||||||
|
''',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
# get_current_weather
|
||||||
|
{
|
||||||
|
'type': 'function',
|
||||||
|
'function': {
|
||||||
|
'name': 'get_current_weather',
|
||||||
|
'description': '''
|
||||||
|
Gets current weather for specified location.
|
||||||
|
''',
|
||||||
|
'parameters': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'location': {
|
||||||
|
'type': 'string',
|
||||||
|
'description': '''
|
||||||
|
Location in human readable format.
|
||||||
|
e.g: "Copenhagen", or "Copenhagen, Louisiana, US", if needed specifying.
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'required': [
|
||||||
|
'location',
|
||||||
|
],
|
||||||
|
'additionalProperties': False
|
||||||
|
},
|
||||||
|
'strict': True
|
||||||
|
}
|
||||||
|
},
|
||||||
|
# get_weather_forecast
|
||||||
|
{
|
||||||
|
'type': 'function',
|
||||||
|
'function': {
|
||||||
|
'name': 'get_weather_forecast',
|
||||||
|
'description': '''
|
||||||
|
Forecast weather API method returns, depending upon your price plan level, upto next 14 day weather
|
||||||
|
forecast and weather alert as json. The data is returned as a Forecast Object.
|
||||||
|
Forecast object contains astronomy data, day weather forecast and hourly interval weather information
|
||||||
|
for a given city.
|
||||||
|
Number of days could be less, depending on the API subscription plan.
|
||||||
|
With a free plan only up to three days of forecast would be returned.
|
||||||
|
''',
|
||||||
|
'parameters': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'location': {
|
||||||
|
'type': 'string',
|
||||||
|
'description': '''
|
||||||
|
Location in human readable format.
|
||||||
|
e.g: "Copenhagen", or "Copenhagen, Louisiana, US", if needed specifying.
|
||||||
|
'''
|
||||||
|
},
|
||||||
|
'days': {
|
||||||
|
'type': 'integer',
|
||||||
|
'description': '''
|
||||||
|
Number of days of forecast between 0 and 13, where 0 is only today, 1 up to tomorrow, etc.
|
||||||
|
'''
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'required': [
|
||||||
|
'location', 'days'
|
||||||
|
],
|
||||||
|
'additionalProperties': False
|
||||||
|
},
|
||||||
|
'strict': True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from os import environ
|
||||||
|
import json
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
URL = "https://api.weatherapi.com/v1"
|
||||||
|
REQUEST_TIMEOUT_SECONDS = 10
|
||||||
|
|
||||||
|
|
||||||
|
def time_now() -> str:
|
||||||
|
return str(datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_weather(location: str) -> str:
|
||||||
|
weather = Weather(location=location)
|
||||||
|
return json.dumps(weather.realtime())
|
||||||
|
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
class Weather:
|
||||||
|
def __init__(self, location: str):
|
||||||
|
self.__location = location
|
||||||
|
self.api_key = environ.get("WEATHER_API_KEY")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def location(self) -> str:
|
||||||
|
return self.__location
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def make_request(method: str, params: dict) -> dict:
|
||||||
|
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"
|
||||||
|
params = {
|
||||||
|
"key": self.api_key,
|
||||||
|
"q": self.location,
|
||||||
|
}
|
||||||
|
return self.make_request(method=method, params=params)
|
||||||
|
|
||||||
|
def forecast(self, days: int = 14):
|
||||||
|
method = "/forecast.json"
|
||||||
|
params = {
|
||||||
|
"key": self.api_key,
|
||||||
|
"q": self.location,
|
||||||
|
"days": days,
|
||||||
|
}
|
||||||
|
return self.make_request(method=method, params=params)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_weather = Weather("Aqtobe")
|
||||||
|
result = json.dumps(test_weather.forecast(days=13), indent=2)
|
||||||
|
print(result)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from .trafficCog import TrafficCog
|
|
||||||
from redbot.core.bot import Red
|
from redbot.core.bot import Red
|
||||||
|
from .trafficCog import TrafficCog
|
||||||
|
|
||||||
async def setup(bot: Red):
|
async def setup(bot: Red):
|
||||||
cog = TrafficCog(bot)
|
cog = TrafficCog(bot)
|
||||||
|
|||||||
+85
-105
@@ -1,119 +1,99 @@
|
|||||||
import discord
|
import discord
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from redbot.core import Config, commands
|
from redbot.core import Config, commands
|
||||||
|
from datetime import datetime
|
||||||
allowed_guilds = {274657393936302080, 693796372092289024, 508781789737648138}
|
import pytz
|
||||||
admin_roles = {'Developer', 'admin', 'Council'}
|
|
||||||
statsThumbnailUrl = 'https://www.kanium.org/machineroom/logomachine-small.png'
|
|
||||||
|
|
||||||
class TrafficCog(commands.Cog):
|
class TrafficCog(commands.Cog):
|
||||||
|
|
||||||
def __init__(self, bot):
|
def __init__(self, bot):
|
||||||
self.channel: discord.TextChannel = None
|
self.bot = bot
|
||||||
self.dailyJoinedCount: int = 0
|
self.config = Config.get_conf(self, identifier=123456789, force_registration=True)
|
||||||
self.totalJoinedCount: int = 0
|
default_guild = {
|
||||||
self.dailyLeftCount: int = 0
|
"traffic_channel": None,
|
||||||
self.totalLeftCount: int = 0
|
"daily_stats": {"joined": 0, "left": 0, "banned": 0},
|
||||||
self.totalLogs: int = 0
|
"total_stats": {"joined": 0, "left": 0, "banned": 0},
|
||||||
self.toggleLogs: bool = True
|
"last_reset": datetime.now(pytz.UTC).isoformat(),
|
||||||
self.date = datetime.now()
|
"admin_roles": ['Developer', 'admin', 'Council'],
|
||||||
|
"stats_thumbnail_url": 'https://example.com/default-thumbnail.png',
|
||||||
|
}
|
||||||
|
self.config.register_guild(**default_guild)
|
||||||
|
|
||||||
def __checkClock(self):
|
async def _check_reset(self, guild_id):
|
||||||
currdate = self.date - datetime.now()
|
async with self.config.guild_from_id(guild_id).all() as guild_data:
|
||||||
if currdate.days >= 0 :
|
last_reset = datetime.fromisoformat(guild_data.get('last_reset', datetime.now(pytz.UTC).isoformat()))
|
||||||
self.dailyJoinedCount = 0
|
if last_reset.date() < datetime.now(pytz.UTC).date():
|
||||||
self.dailyLeftCount = 0
|
guild_data['daily_stats'] = {"joined": 0, "left": 0, "banned": 0}
|
||||||
self.date = datetime.now()
|
guild_data['last_reset'] = datetime.now(pytz.UTC).isoformat()
|
||||||
|
|
||||||
@commands.command(name='settrafficchannel', description='Sets the channel to sends log to')
|
async def _update_stat(self, guild_id, stat_type):
|
||||||
@commands.has_any_role(*admin_roles)
|
async with self.config.guild_from_id(guild_id).all() as guild_data:
|
||||||
async def setTrafficChannel(self, ctx: commands.Context, channel: discord.TextChannel) -> None:
|
guild_data['daily_stats'][stat_type] += 1
|
||||||
await ctx.trigger_typing()
|
guild_data['total_stats'][stat_type] += 1
|
||||||
|
|
||||||
if not channel in ctx.guild.channels:
|
@commands.group(name='traffic', invoke_without_command=True)
|
||||||
await ctx.send('Channel doesnt exist in guild')
|
@commands.has_permissions(administrator=True)
|
||||||
|
async def traffic_commands(self, ctx):
|
||||||
|
"""Base command for managing TrafficCog settings."""
|
||||||
|
await ctx.send_help(ctx.command)
|
||||||
|
|
||||||
|
@traffic_commands.command(name='setchannel')
|
||||||
|
async def set_traffic_channel(self, ctx, channel: discord.TextChannel):
|
||||||
|
"""Sets the channel for traffic logs."""
|
||||||
|
await self.config.guild(ctx.guild).traffic_channel.set(channel.id)
|
||||||
|
await ctx.send(f"Traffic logs will now be sent to {channel.mention}.")
|
||||||
|
|
||||||
|
@traffic_commands.command(name='stats')
|
||||||
|
async def show_stats(self, ctx):
|
||||||
|
"""Displays current traffic statistics."""
|
||||||
|
await self._check_reset(ctx.guild.id)
|
||||||
|
|
||||||
|
guild_data = await self.config.guild(ctx.guild).all()
|
||||||
|
daily_stats = guild_data.get('daily_stats', {"joined": 0, "left": 0, "banned": 0})
|
||||||
|
total_stats = guild_data.get('total_stats', {"joined": 0, "left": 0, "banned": 0})
|
||||||
|
thumbnail_url = guild_data.get('stats_thumbnail_url', 'https://example.com/default-thumbnail.png')
|
||||||
|
|
||||||
|
embed = discord.Embed(title="Server Traffic Stats", description="Statistics on server activity", color=0x3399ff)
|
||||||
|
embed.set_thumbnail(url=thumbnail_url)
|
||||||
|
embed.add_field(name="Daily Joined", value=daily_stats['joined'], inline=True)
|
||||||
|
embed.add_field(name="Daily Left", value=daily_stats['left'], inline=True)
|
||||||
|
embed.add_field(name="Daily Banned", value=daily_stats['banned'], inline=True)
|
||||||
|
embed.add_field(name="Total Joined", value=total_stats['joined'], inline=True)
|
||||||
|
embed.add_field(name="Total Left", value=total_stats['left'], inline=True)
|
||||||
|
embed.add_field(name="Total Banned", value=total_stats['banned'], inline=True)
|
||||||
|
|
||||||
|
await ctx.send(embed=embed)
|
||||||
|
|
||||||
|
@commands.Cog.listener()
|
||||||
|
async def on_member_join(self, member):
|
||||||
|
await self._check_reset(member.guild.id)
|
||||||
|
await self._update_stat(member.guild.id, 'joined')
|
||||||
|
|
||||||
|
channel_id = await self.config.guild(member.guild).traffic_channel()
|
||||||
|
if not channel_id:
|
||||||
return
|
return
|
||||||
|
channel = member.guild.get_channel(channel_id) or await member.guild.fetch_channel(channel_id)
|
||||||
|
if channel:
|
||||||
|
await channel.send(f"{member.display_name} has joined the server.")
|
||||||
|
|
||||||
if not channel.permissions_for(ctx.guild.me).send_messages:
|
@commands.Cog.listener()
|
||||||
await ctx.send('No permissions to talk in that channel.')
|
async def on_member_remove(self, member):
|
||||||
|
await self._check_reset(member.guild.id)
|
||||||
|
await self._update_stat(member.guild.id, 'left')
|
||||||
|
|
||||||
|
channel_id = await self.config.guild(member.guild).traffic_channel()
|
||||||
|
if not channel_id:
|
||||||
return
|
return
|
||||||
|
channel = member.guild.get_channel(channel_id) or await member.guild.fetch_channel(channel_id)
|
||||||
self.channel = channel
|
if channel:
|
||||||
|
await channel.send(f"{member.display_name} has left the server.")
|
||||||
await ctx.send(f'I will now send event notices to {channel.mention}.')
|
|
||||||
|
|
||||||
@commands.command(name='stats', description='Shows current statistics')
|
|
||||||
@commands.has_any_role(*admin_roles)
|
|
||||||
async def statistics(self, ctx: commands.Context) -> None:
|
|
||||||
self.__checkClock()
|
|
||||||
await ctx.trigger_typing()
|
|
||||||
message = discord.Embed(title='Server Traffic Stats', description='Statistics on server activity\n\n',color=0x3399ff)
|
|
||||||
message.set_thumbnail(url=statsThumbnailUrl)
|
|
||||||
message.add_field(name='Daily Joined', value=self.dailyJoinedCount, inline='True')
|
|
||||||
message.add_field(name='Daily Left', value='{0}\n'.format(self.dailyLeftCount), inline='True')
|
|
||||||
message.add_field(name='Total Traffic', value=self.totalLogs, inline='False')
|
|
||||||
message.add_field(name='Total Joined', value=self.totalJoinedCount, inline='True')
|
|
||||||
message.add_field(name='Total Left', value=self.totalLeftCount, inline='True')
|
|
||||||
await ctx.send(content=None, embed=message)
|
|
||||||
|
|
||||||
@commands.command(name='resetstats', description='Resets statistics')
|
|
||||||
@commands.has_any_role(*admin_roles)
|
|
||||||
async def resetStatistics(self, ctx: commands.Context) -> None:
|
|
||||||
await ctx.trigger_typing()
|
|
||||||
|
|
||||||
self.dailyJoinedCount = 0
|
|
||||||
self.dailyLeftCount = 0
|
|
||||||
self.totalJoinedCount = 0
|
|
||||||
self.totalLeftCount = 0
|
|
||||||
self.totalLogs = 0
|
|
||||||
|
|
||||||
await ctx.send('Successfully reset the statistics')
|
|
||||||
|
|
||||||
@commands.command(name='toggleLogs', description='Toggles the logs functionality on or off')
|
|
||||||
@commands.has_any_role(*admin_roles)
|
|
||||||
async def toggleLogs(self, ctx: commands.Context) -> None:
|
|
||||||
await ctx.trigger_typing()
|
|
||||||
self.toggleLogs = not self.toggleLogs
|
|
||||||
await ctx.send('Logging functionality is `ON`' if self.toggleLogs else 'Logging functionality is `OFF`')
|
|
||||||
|
|
||||||
@commands.Cog.listener()
|
@commands.Cog.listener()
|
||||||
async def on_member_join(self, member: discord.Member) -> None:
|
async def on_member_ban(self, guild, member):
|
||||||
try:
|
await self._check_reset(guild.id)
|
||||||
if member.guild.id not in allowed_guilds:
|
await self._update_stat(guild.id, 'banned')
|
||||||
return
|
|
||||||
self.__checkClock()
|
|
||||||
if self.channel in member.guild.channels and self.toggleLogs:
|
|
||||||
await self.channel.send('{0} has joined the server'.format(member.name))
|
|
||||||
self.totalJoinedCount += 1
|
|
||||||
self.dailyJoinedCount += 1
|
|
||||||
self.totalLogs += 1
|
|
||||||
except (discord.NotFound, discord.Forbidden):
|
|
||||||
print(
|
|
||||||
f'Error Occured!')
|
|
||||||
|
|
||||||
@commands.Cog.listener()
|
channel_id = await self.config.guild(guild).traffic_channel()
|
||||||
async def on_member_remove(self, member: discord.Member) -> None:
|
if not channel_id:
|
||||||
try:
|
return
|
||||||
self.__checkClock()
|
channel = guild.get_channel(channel_id) or await guild.fetch_channel(channel_id)
|
||||||
if self.channel in member.guild.channels and self.toggleLogs:
|
if channel:
|
||||||
await self.channel.send('{0} has left the server'.format(member.name))
|
await channel.send(f"{member.display_name} has been banned from the server.")
|
||||||
self.totalLeftCount += 1
|
|
||||||
self.dailyLeftCount += 1
|
|
||||||
self.totalLogs += 1
|
|
||||||
except (discord.NotFound, discord.Forbidden):
|
|
||||||
print(
|
|
||||||
f'Error Occured!')
|
|
||||||
|
|
||||||
@commands.Cog.listener()
|
|
||||||
async def on_member_ban(self, guild: discord.Guild, member: discord.Member) -> None:
|
|
||||||
try:
|
|
||||||
self.__checkClock()
|
|
||||||
if self.channel in member.guild.channels and self.toggleLogs:
|
|
||||||
await self.channel.send('{0} has been banned from the server'.format(member.name))
|
|
||||||
self.totalLeftCount += 1
|
|
||||||
self.dailyLeftCount += 1
|
|
||||||
self.totalLogs += 1
|
|
||||||
except (discord.NotFound, discord.Forbidden):
|
|
||||||
print(
|
|
||||||
f'Error Occured!')
|
|
||||||
|
|||||||
Reference in New Issue
Block a user