Skip to content

Commit d0100b2

Browse files
committed
v3.0.17
1 parent ebd30ac commit d0100b2

7 files changed

Lines changed: 599 additions & 122 deletions

File tree

.env.example

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,17 @@ BOT_OPENAI_COOLDOWN=10
1717
BOT_OWNER_COOLDOWN=5
1818

1919

20-
# OpenAI API key
20+
# OpenAI (GPT) API key
2121
BOT_OPENAI_MODEL=gpt-5.5
22-
OPENAI_API_KEY=
22+
BOT_OPENAI_API_KEY=
23+
24+
# Anthropic (Claude) API key
25+
BOT_ANTHROPIC_MODEL=claude-opus-4-8
26+
BOT_ANTHROPIC_API_KEY=
27+
28+
# Google (Gemini) API key
29+
BOT_GEMINI_MODEL=gemini-flash-latest
30+
BOT_GEMINI_API_KEY=
2331

2432

2533
# ddcDatabases configs

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "DiscordBot"
3-
version = "3.0.16"
3+
version = "3.0.17"
44
description = "A simple Discord bot with OpenAI support and server administration tools"
55
urls.Repository = "https://github.com/ddc/DiscordBot"
66
urls.Homepage = "https://ddc.github.io/DiscordBot"
@@ -31,10 +31,12 @@ classifiers = [
3131
requires-python = ">=3.14"
3232
dependencies = [
3333
"alembic>=1.18.4",
34+
"anthropic>=0.105.2",
3435
"beautifulsoup4>=4.14.3",
3536
"better-profanity>=0.7.0",
3637
"ddcdatabases[postgres]>=4.0.1",
3738
"discord-py>=2.7.1",
39+
"google-genai>=2.7.0",
3840
"gTTS>=2.5.4",
3941
"openai>=2.38.0",
4042
"PyNaCl>=1.6.2",

src/bot/cogs/open_ai.py

Lines changed: 117 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import discord
22
import time
3+
from anthropic import AsyncAnthropic
34
from discord.ext import commands
5+
from google import genai
6+
from google.genai import types as genai_types
47
from openai import AsyncOpenAI
58
from openai.types.responses import WebSearchToolParam
69
from openai.types.shared import ReasoningEffort
@@ -12,12 +15,14 @@
1215

1316

1417
class OpenAi(commands.Cog):
15-
"""OpenAI-powered commands for AI assistance and text generation."""
18+
"""LLM chat commands (OpenAI / Anthropic Claude / Google Gemini) with optional web search."""
1619

1720
def __init__(self, bot: Bot) -> None:
1821
self.bot: Bot = bot
1922
self._bot_settings: BotSettings = get_bot_settings()
2023
self._openai_client: AsyncOpenAI = AsyncOpenAI(api_key=self._bot_settings.openai_api_key)
24+
self._anthropic_client: AsyncAnthropic = AsyncAnthropic(api_key=self._bot_settings.anthropic_api_key)
25+
self._gemini_client: genai.Client = genai.Client(api_key=self._bot_settings.gemini_api_key)
2126
self._effort: ReasoningEffort = "xhigh"
2227
self._instructions: str = "You are a helpful AI assistant."
2328
self._instructions_web: str = (
@@ -26,33 +31,54 @@ def __init__(self, bot: Bot) -> None:
2631
"numbers, statistics, or breakdowns that the sources do not explicitly state. Cite the source URL(s)."
2732
)
2833

34+
# ─────────────────────────── Commands ───────────────────────────
35+
36+
@commands.command()
37+
@commands.guild_only()
38+
@commands.cooldown(1, CoolDowns.OpenAI.value, commands.BucketType.user)
39+
async def gpt(self, ctx: commands.Context, *, msg_text: str) -> None:
40+
"""Ask OpenAI's GPT model for a direct answer (no web search)."""
41+
await self._run_chat(ctx, msg_text, provider="openai", use_web=False)
42+
43+
@commands.command()
44+
@commands.guild_only()
45+
@commands.cooldown(1, CoolDowns.OpenAI.value, commands.BucketType.user)
46+
async def gptweb(self, ctx: commands.Context, *, msg_text: str) -> None:
47+
"""Ask OpenAI's GPT model with web search enabled — for current/factual info."""
48+
await self._run_chat(ctx, msg_text, provider="openai", use_web=True)
49+
50+
@commands.command()
51+
@commands.guild_only()
52+
@commands.cooldown(1, CoolDowns.OpenAI.value, commands.BucketType.user)
53+
async def claude(self, ctx: commands.Context, *, msg_text: str) -> None:
54+
"""Ask Anthropic's Claude model for a direct answer (no web search)."""
55+
await self._run_chat(ctx, msg_text, provider="anthropic", use_web=False)
56+
2957
@commands.command()
58+
@commands.guild_only()
3059
@commands.cooldown(1, CoolDowns.OpenAI.value, commands.BucketType.user)
31-
async def ai(self, ctx: commands.Context, *, msg_text: str) -> None:
32-
"""Ask OpenAI for a direct answer (no web search).
60+
async def claudeweb(self, ctx: commands.Context, *, msg_text: str) -> None:
61+
"""Ask Anthropic's Claude model with web search enabled — for current/factual info."""
62+
await self._run_chat(ctx, msg_text, provider="anthropic", use_web=True)
3363

34-
Usage:
35-
ai What is Python?
36-
ai Write a haiku about programming
37-
ai Explain quantum computing in simple terms
38-
"""
39-
await self._run_ai(ctx, msg_text, use_web=False)
64+
@commands.command()
65+
@commands.guild_only()
66+
@commands.cooldown(1, CoolDowns.OpenAI.value, commands.BucketType.user)
67+
async def gemini(self, ctx: commands.Context, *, msg_text: str) -> None:
68+
"""Ask Google's Gemini model for a direct answer (no web search)."""
69+
await self._run_chat(ctx, msg_text, provider="gemini", use_web=False)
4070

4171
@commands.command()
72+
@commands.guild_only()
4273
@commands.cooldown(1, CoolDowns.OpenAI.value, commands.BucketType.user)
43-
async def aiweb(self, ctx: commands.Context, *, msg_text: str) -> None:
44-
"""Ask OpenAI to search the web before answering — best for current/factual info.
45-
46-
Usage:
47-
aiweb What's the latest news about <topic>
48-
aiweb How many support gems does Path of Exile 2 have
49-
"""
50-
await self._run_ai(ctx, msg_text, use_web=True)
51-
52-
async def _run_ai(self, ctx: commands.Context, msg_text: str, use_web: bool) -> None:
53-
"""Shared body for the `ai` and `aiweb` commands."""
54-
# Reasoning (and web search) can take a couple of minutes, so show a progress
55-
# message immediately so the user knows the bot is working (not stuck).
74+
async def geminiweb(self, ctx: commands.Context, *, msg_text: str) -> None:
75+
"""Ask Google's Gemini model with Google Search grounding enabled."""
76+
await self._run_chat(ctx, msg_text, provider="gemini", use_web=True)
77+
78+
# ─────────────────────────── Shared flow ───────────────────────────
79+
80+
async def _run_chat(self, ctx: commands.Context, msg_text: str, provider: str, use_web: bool) -> None:
81+
"""Send progress message, dispatch to provider, time, post answer."""
5682
progress_text = (
5783
"Please wait, I'm thinking and searching the web for an accurate answer..."
5884
if use_web
@@ -67,38 +93,50 @@ async def _run_ai(self, ctx: commands.Context, msg_text: str, use_web: bool) ->
6793

6894
start = time.monotonic()
6995
try:
70-
response_text = await self._get_ai_response(msg_text, use_web=use_web)
96+
response_text = await self._dispatch(provider, msg_text, use_web)
7197
color = discord.Color.green()
7298
description = response_text
7399
except Exception as e:
74-
self.bot.log.error(f"OpenAI API error: {e}")
100+
self.bot.log.error(f"{provider} API error: {e}")
75101
color = discord.Color.red()
76102
description = f"Sorry, I encountered an error: {e}"
77103
elapsed = time.monotonic() - start
78104

79-
# Remove the progress message before sending the final answer.
80105
try:
81106
await progress_msg.delete()
82107
except discord.HTTPException:
83108
pass
84109

85-
embeds = self._create_ai_embeds(ctx, description, color, elapsed)
110+
model = self._model_for(provider)
111+
embeds = self._create_ai_embeds(ctx, description, color, elapsed, model)
86112
if len(embeds) == 1:
87113
await bot_utils.send_embed(ctx, embeds[0], False)
88114
else:
89115
view = bot_utils.EmbedPaginatorView(embeds, ctx.author.id)
90116
await view.send_and_save(ctx)
91117

92-
async def _get_ai_response(self, message: str, use_web: bool) -> str:
93-
"""Get response from OpenAI API.
94-
95-
use_web: when True, enables the built-in web_search tool and uses the
96-
web-grounded instructions. When False, the model answers from its own
97-
knowledge with plain instructions.
98-
"""
118+
async def _dispatch(self, provider: str, message: str, use_web: bool) -> str:
119+
if provider == "openai":
120+
return await self._get_openai_response(message, use_web=use_web)
121+
if provider == "anthropic":
122+
return await self._get_claude_response(message, use_web=use_web)
123+
if provider == "gemini":
124+
return await self._get_gemini_response(message, use_web=use_web)
125+
raise ValueError(f"Unknown provider: {provider}")
126+
127+
def _model_for(self, provider: str) -> str:
128+
return {
129+
"openai": self._bot_settings.openai_model,
130+
"anthropic": self._bot_settings.anthropic_model,
131+
"gemini": self._bot_settings.gemini_model,
132+
}[provider]
133+
134+
# ─────────────────────────── Provider calls ───────────────────────────
135+
136+
async def _get_openai_response(self, message: str, use_web: bool) -> str:
137+
"""Call OpenAI's Responses API with optional web_search tool."""
99138
instructions = self._instructions_web if use_web else self._instructions
100139
tools: list[WebSearchToolParam] = [WebSearchToolParam(type="web_search")] if use_web else []
101-
102140
response = await self._openai_client.responses.create(
103141
instructions=instructions,
104142
model=self._bot_settings.openai_model,
@@ -107,10 +145,44 @@ async def _get_ai_response(self, message: str, use_web: bool) -> str:
107145
max_output_tokens=None,
108146
input=message,
109147
)
110-
111148
content = response.output_text
112149
return content.strip() if content else ""
113150

151+
async def _get_claude_response(self, message: str, use_web: bool) -> str:
152+
"""Call Anthropic Messages API with optional web_search server tool."""
153+
instructions = self._instructions_web if use_web else self._instructions
154+
tools = [{"type": "web_search_20250305", "name": "web_search"}] if use_web else []
155+
response = await self._anthropic_client.messages.create(
156+
model=self._bot_settings.anthropic_model,
157+
max_tokens=4096,
158+
system=instructions,
159+
messages=[{"role": "user", "content": message}],
160+
tools=tools,
161+
)
162+
# Concatenate all text blocks (web search may interleave tool-use blocks).
163+
text_parts = [
164+
getattr(block, "text", "") for block in response.content if getattr(block, "type", None) == "text"
165+
]
166+
content = "".join(text_parts).strip()
167+
return content
168+
169+
async def _get_gemini_response(self, message: str, use_web: bool) -> str:
170+
"""Call Google Gemini with optional Google Search grounding."""
171+
instructions = self._instructions_web if use_web else self._instructions
172+
config_kwargs: dict = {"system_instruction": instructions}
173+
if use_web:
174+
config_kwargs["tools"] = [genai_types.Tool(google_search=genai_types.GoogleSearch())]
175+
config = genai_types.GenerateContentConfig(**config_kwargs)
176+
response = await self._gemini_client.aio.models.generate_content(
177+
model=self._bot_settings.gemini_model,
178+
contents=message,
179+
config=config,
180+
)
181+
content = response.text or ""
182+
return content.strip()
183+
184+
# ─────────────────────────── Embed formatting ───────────────────────────
185+
114186
@staticmethod
115187
def _format_duration(seconds: float) -> str:
116188
"""Format an elapsed duration as e.g. '5ms' (sub-second) or '20s'."""
@@ -119,13 +191,19 @@ def _format_duration(seconds: float) -> str:
119191
return f"{round(seconds)}s"
120192

121193
def _create_ai_embeds(
122-
self, ctx: commands.Context, description: str, color: discord.Color, elapsed: float = 0.0
194+
self,
195+
ctx: commands.Context,
196+
description: str,
197+
color: discord.Color,
198+
elapsed: float = 0.0,
199+
model: str | None = None,
123200
) -> list[discord.Embed]:
124-
"""Create formatted embed(s) for AI response, paginating if needed."""
125-
model = self._bot_settings.openai_model
201+
"""Create formatted embed(s) for an AI response, paginating if needed."""
202+
if model is None:
203+
model = self._bot_settings.openai_model
126204
duration = self._format_duration(elapsed)
127205
max_length = 2000
128-
chunks = []
206+
chunks: list[str] = []
129207

130208
while description:
131209
if len(description) <= max_length:

src/bot/constants/settings.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@ class BotSettings(BaseSettings):
2626
openai_model: str = Field(default="gpt-5.5", description="https://developers.openai.com/api/docs/models")
2727
openai_api_key: str | None = Field(default=None)
2828

29+
# Anthropic (Claude)
30+
anthropic_model: str = Field(
31+
default="claude-opus-4-8", description="https://docs.anthropic.com/en/docs/about-claude/models"
32+
)
33+
anthropic_api_key: str | None = Field(default=None)
34+
35+
# Google (Gemini)
36+
gemini_model: str = Field(default="gemini-flash-latest", description="https://ai.google.dev/gemini-api/docs/models")
37+
gemini_api_key: str | None = Field(default=None)
38+
2939
# Cooldowns
3040
admin_cooldown: int = Field(default=20)
3141
config_cooldown: int = Field(default=20)

0 commit comments

Comments
 (0)