7 Commits

Author SHA1 Message Date
e4a748bfb5 update README
All checks were successful
Garfbot CI/CD Deployment / Deploy (push) Successful in 15s
2026-06-26 19:27:45 -05:00
dbbfbd638f ruff format
All checks were successful
Garfbot CI/CD Deployment / Deploy (push) Successful in 15s
2026-06-26 19:24:36 -05:00
284e4f92fe Merge pull request 'jonbot fixes' (#14) from jonbot into main
All checks were successful
Garfbot CI/CD Deployment / Deploy (push) Successful in 7s
Reviewed-on: #14
2026-06-19 06:14:31 +00:00
122239e306 Merge branch 'main' into jonbot 2026-06-19 06:13:43 +00:00
39cc53d501 jonbot fixes 2026-06-19 01:12:46 -05:00
4de0347610 Merge pull request 'jonbot' (#13) from jonbot into main
All checks were successful
Garfbot CI/CD Deployment / Deploy (push) Successful in 1m17s
Reviewed-on: #13
2026-06-19 06:05:00 +00:00
e94173b3da fix jon chats and merge main into branch 2026-06-18 15:22:20 -05:00
9 changed files with 211 additions and 102 deletions

View File

@@ -22,7 +22,6 @@ jobs:
if [ "$(docker compose ps -q)" ]; then if [ "$(docker compose ps -q)" ]; then
docker compose down docker compose down
fi fi
docker build -t git.crate.zip/crate/garfbot:latest .
docker compose up -d --build docker compose up -d --build
else else
docker restart garfbot docker restart garfbot

View File

@@ -2,7 +2,7 @@ Who is GarfBot?
====== ======
![garfield](https://www.crate.zip/garfield.png) ![garfield](https://www.crate.zip/garfield.png)
GarfBot is a discord bot that uses OpenAI's generative pre-trained models to produce text and images for your personal entertainment and companionship. GarfBot is a discord bot that uses local generative pre-trained AI models (mistral and flux.2-klein) to produce text and images for your personal entertainment and companionship.
<br>There are a few ways you can interact with him on discord, either in a public server or by direct message: <br>There are a few ways you can interact with him on discord, either in a public server or by direct message:
`hey garfield {prompt}` `hey garfield {prompt}`

View File

@@ -7,7 +7,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
restart: always restart: always
volumes: volumes:
- ../garfbot:/usr/src/app - /home/crate/garfbot:/usr/src/app
jonbot: jonbot:
image: git.crate.zip/crate/jonbot:latest image: git.crate.zip/crate/jonbot:latest
@@ -17,4 +17,4 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
restart: always restart: always
volumes: volumes:
- ../garfbot/jonbot:/usr/src/app - /home/crate/garfbot/jonbot:/usr/src/app

View File

@@ -13,26 +13,40 @@ intents.messages = True
intents.message_content = True intents.message_content = True
client = discord.Client(intents=intents) client = discord.Client(intents=intents)
@client.event @client.event
async def on_ready(): async def on_ready():
print(f"Logged in as {client.user.name} running {model}.", flush=True) print(f"Logged in as {client.user.name} running {model}.", flush=True)
@client.event @client.event
async def on_message(message): async def on_message(message):
if message.author == client.user: if message.author == client.user:
return return
if message.content.lower().startswith("hey money") or isinstance(message.channel, discord.DMChannel): if message.content.lower().startswith("hey money") or isinstance(
question = message.content[9:] if message.content.lower().startswith("hey money") else message.content message.channel, discord.DMChannel
):
question = (
message.content[9:]
if message.content.lower().startswith("hey money")
else message.content
)
try: try:
response = openai.ChatCompletion.create( response = openai.ChatCompletion.create(
model=model, model=model,
messages=[ messages=[
{"role": "system", "content": "Pretend you are eccentric conspiracy theorist Planetside 2 gamer named Dr. Moneypants."}, {
{"role": "user", "content": f"{question} please keep it short with religious undertones"} "role": "system",
"content": "Pretend you are eccentric conspiracy theorist Planetside 2 gamer named Dr. Moneypants.",
},
{
"role": "user",
"content": f"{question} please keep it short with religious undertones",
},
], ],
max_tokens=400 max_tokens=400,
) )
answer = response['choices'][0]['message']['content'] answer = response["choices"][0]["message"]["content"]
answer = answer.replace("an AI language model", "a man of God") answer = answer.replace("an AI language model", "a man of God")
answer = answer.replace("language model AI", "man of God") answer = answer.replace("language model AI", "man of God")
await message.channel.send(answer) await message.channel.send(answer)
@@ -40,6 +54,7 @@ async def on_message(message):
e = str(e) e = str(e)
await message.channel.send(f"`MoneyBot Error: {e}`") await message.channel.send(f"`MoneyBot Error: {e}`")
async def moneybot_connect(): async def moneybot_connect():
while True: while True:
try: try:
@@ -49,5 +64,6 @@ async def moneybot_connect():
logger.error(f"Moneybot couldn't connect! {e}") logger.error(f"Moneybot couldn't connect! {e}")
await asyncio.sleep(60) await asyncio.sleep(60)
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(moneybot_connect()) asyncio.run(moneybot_connect())

View File

@@ -42,18 +42,19 @@ weather = WeatherAPI()
URL_PATTERNS = [ URL_PATTERNS = [
r'https?://(?:www\.)?youtube\.com/watch\?[^\s]*', r"https?://(?:www\.)?youtube\.com/watch\?[^\s]*",
r'https?://youtu\.be/[^\s]*', r"https?://youtu\.be/[^\s]*",
r'https?://(?:open\.)?spotify\.com/[^\s]*', r"https?://(?:open\.)?spotify\.com/[^\s]*",
] ]
def clean_url(url): def clean_url(url):
try: try:
parsed = urlparse(url) parsed = urlparse(url)
if 'youtube.com' in parsed.hostname: if "youtube.com" in parsed.hostname:
params = parse_qs(parsed.query) params = parse_qs(parsed.query)
video_id = params.get('v', [None])[0] video_id = params.get("v", [None])[0]
if not video_id: if not video_id:
return None return None
# timestamp = params.get('t', [None])[0] # timestamp = params.get('t', [None])[0]
@@ -61,10 +62,10 @@ def clean_url(url):
# return f"https://www.youtube.com/watch?v={video_id}&t={timestamp}" # return f"https://www.youtube.com/watch?v={video_id}&t={timestamp}"
return f"https://www.youtube.com/watch?v={video_id}" return f"https://www.youtube.com/watch?v={video_id}"
if 'youtu.be' in parsed.hostname: if "youtu.be" in parsed.hostname:
return f"https://youtu.be{parsed.path}" return f"https://youtu.be{parsed.path}"
if 'spotify.com' in parsed.hostname: if "spotify.com" in parsed.hostname:
return f"https://open.spotify.com{parsed.path}" return f"https://open.spotify.com{parsed.path}"
except Exception: except Exception:

View File

@@ -134,4 +134,3 @@ async def aod_message(garfbot, message):
# if count >= 3 or (len(words) >= 2 and count / len(words) >= 0.75): # if count >= 3 or (len(words) >= 2 and count / len(words) >= 0.75):
# await message.channel.send("Boy, you said it!") # await message.channel.send("Boy, you said it!")

View File

@@ -17,9 +17,27 @@ _MODEL_KEY = "0eb50094-5c9b-431b-ba01-87e145edb849"
_VAE_KEY = "dde3627c-8a45-4088-93d1-66c44acbb337" _VAE_KEY = "dde3627c-8a45-4088-93d1-66c44acbb337"
_ENCODER_KEY = "7ba22542-4687-4946-a52e-c92f925f4b75" _ENCODER_KEY = "7ba22542-4687-4946-a52e-c92f925f4b75"
_MODEL_REF = {"key": _MODEL_KEY, "hash": "blake3:c3ee838d71d99497db01fae6f304eafd9e734e935f3b783e968d50febb56be2c", "name": "FLUX.2 Klein 4B (GGUF Q4)", "base": "flux2", "type": "main"} _MODEL_REF = {
_VAE_REF = {"key": _VAE_KEY, "hash": "blake3:531855de70db993d0f6181f82cde27d15411d58b7ffa3b2fdce2b9434c0173c2", "name": "FLUX.2 VAE", "base": "flux2", "type": "vae"} "key": _MODEL_KEY,
_ENCODER_REF = {"key": _ENCODER_KEY, "hash": "blake3:af5840e6770dc99f678e69867949c8b9264835915eb82a990e940fa6e4fa6c81", "name": "FLUX.2 Klein Qwen3 4B Encoder", "base": "any", "type": "qwen3_encoder"} "hash": "blake3:c3ee838d71d99497db01fae6f304eafd9e734e935f3b783e968d50febb56be2c",
"name": "FLUX.2 Klein 4B (GGUF Q4)",
"base": "flux2",
"type": "main",
}
_VAE_REF = {
"key": _VAE_KEY,
"hash": "blake3:531855de70db993d0f6181f82cde27d15411d58b7ffa3b2fdce2b9434c0173c2",
"name": "FLUX.2 VAE",
"base": "flux2",
"type": "vae",
}
_ENCODER_REF = {
"key": _ENCODER_KEY,
"hash": "blake3:af5840e6770dc99f678e69867949c8b9264835915eb82a990e940fa6e4fa6c81",
"name": "FLUX.2 Klein Qwen3 4B Encoder",
"base": "any",
"type": "qwen3_encoder",
}
_POLL_INTERVAL = 2 _POLL_INTERVAL = 2
_POLL_ATTEMPTS = 60 _POLL_ATTEMPTS = 60
@@ -31,7 +49,7 @@ def _node_id(prefix: str) -> str:
def _build_graph(prompt: str) -> dict: def _build_graph(prompt: str) -> dict:
seed = int(time.time() * 1000) % (2 ** 31) seed = int(time.time() * 1000) % (2**31)
p = _node_id("positive_prompt") p = _node_id("positive_prompt")
ml = _node_id("flux2_klein_model_loader") ml = _node_id("flux2_klein_model_loader")
@@ -40,34 +58,88 @@ def _build_graph(prompt: str) -> dict:
out = _node_id("canvas_output") out = _node_id("canvas_output")
nodes = { nodes = {
p: {"id": p, "is_intermediate": True, "use_cache": True, "value": prompt, "type": "string"}, p: {
ml: {"id": ml, "is_intermediate": True, "use_cache": True, "type": "flux2_klein_model_loader", "id": p,
"model": _MODEL_REF, "vae_model": _VAE_REF, "qwen3_encoder_model": _ENCODER_REF}, "is_intermediate": True,
te: {"id": te, "is_intermediate": True, "use_cache": True, "type": "flux2_klein_text_encoder"}, "use_cache": True,
dn: {"id": dn, "is_intermediate": True, "use_cache": True, "type": "flux2_denoise", "seed": seed}, "value": prompt,
out: {"id": out, "is_intermediate": False, "use_cache": False, "type": "flux2_vae_decode"}, "type": "string",
},
ml: {
"id": ml,
"is_intermediate": True,
"use_cache": True,
"type": "flux2_klein_model_loader",
"model": _MODEL_REF,
"vae_model": _VAE_REF,
"qwen3_encoder_model": _ENCODER_REF,
},
te: {
"id": te,
"is_intermediate": True,
"use_cache": True,
"type": "flux2_klein_text_encoder",
},
dn: {
"id": dn,
"is_intermediate": True,
"use_cache": True,
"type": "flux2_denoise",
"seed": seed,
},
out: {
"id": out,
"is_intermediate": False,
"use_cache": False,
"type": "flux2_vae_decode",
},
} }
edges = [ edges = [
{"source": {"node_id": ml, "field": "qwen3_encoder"}, "destination": {"node_id": te, "field": "qwen3_encoder"}}, {
{"source": {"node_id": ml, "field": "max_seq_len"}, "destination": {"node_id": te, "field": "max_seq_len"}}, "source": {"node_id": ml, "field": "qwen3_encoder"},
{"source": {"node_id": p, "field": "value"}, "destination": {"node_id": te, "field": "prompt"}}, "destination": {"node_id": te, "field": "qwen3_encoder"},
{"source": {"node_id": ml, "field": "transformer"}, "destination": {"node_id": dn, "field": "transformer"}}, },
{"source": {"node_id": ml, "field": "vae"}, "destination": {"node_id": dn, "field": "vae"}}, {
{"source": {"node_id": te, "field": "conditioning"}, "destination": {"node_id": dn, "field": "positive_text_conditioning"}}, "source": {"node_id": ml, "field": "max_seq_len"},
{"source": {"node_id": ml, "field": "vae"}, "destination": {"node_id": out, "field": "vae"}}, "destination": {"node_id": te, "field": "max_seq_len"},
{"source": {"node_id": dn, "field": "latents"}, "destination": {"node_id": out, "field": "latents"}}, },
{
"source": {"node_id": p, "field": "value"},
"destination": {"node_id": te, "field": "prompt"},
},
{
"source": {"node_id": ml, "field": "transformer"},
"destination": {"node_id": dn, "field": "transformer"},
},
{
"source": {"node_id": ml, "field": "vae"},
"destination": {"node_id": dn, "field": "vae"},
},
{
"source": {"node_id": te, "field": "conditioning"},
"destination": {"node_id": dn, "field": "positive_text_conditioning"},
},
{
"source": {"node_id": ml, "field": "vae"},
"destination": {"node_id": out, "field": "vae"},
},
{
"source": {"node_id": dn, "field": "latents"},
"destination": {"node_id": out, "field": "latents"},
},
] ]
return {"nodes": nodes, "edges": edges} return {"nodes": nodes, "edges": edges}
async def _poll_batch(session: aiohttp.ClientSession, base: str, batch_id: str) -> bool: async def _poll_batch(session: aiohttp.ClientSession, base: str, batch_id: str) -> bool:
"""Poll batch status until completed, failed, or timed out. Returns True on success."""
for _ in range(_POLL_ATTEMPTS): for _ in range(_POLL_ATTEMPTS):
await asyncio.sleep(_POLL_INTERVAL) await asyncio.sleep(_POLL_INTERVAL)
try: try:
async with session.get(f"{base}/api/v1/queue/default/b/{batch_id}/status") as resp: async with session.get(
f"{base}/api/v1/queue/default/b/{batch_id}/status"
) as resp:
if not resp.ok: if not resp.ok:
continue continue
s = await resp.json(content_type=None) s = await resp.json(content_type=None)
@@ -84,7 +156,9 @@ async def _poll_batch(session: aiohttp.ClientSession, base: str, batch_id: str)
return False return False
async def _get_image_name(session: aiohttp.ClientSession, base: str, batch_id: str) -> str | None: async def _get_image_name(
session: aiohttp.ClientSession, base: str, batch_id: str
) -> str | None:
try: try:
async with session.get(f"{base}/api/v1/queue/default/i/{batch_id}") as resp: async with session.get(f"{base}/api/v1/queue/default/i/{batch_id}") as resp:
if resp.ok: if resp.ok:
@@ -110,8 +184,9 @@ async def _get_image_name(session: aiohttp.ClientSession, base: str, batch_id: s
return None return None
async def _fetch_image_bytes(session: aiohttp.ClientSession, base: str, name: str) -> bytes | None: async def _fetch_image_bytes(
"""Try the full image endpoint, then fall back to thumbnail.""" session: aiohttp.ClientSession, base: str, name: str
) -> bytes | None:
urls = [ urls = [
f"{base}/api/v1/images/i/{name}/full", f"{base}/api/v1/images/i/{name}/full",
f"{base}/api/v1/images/i/{name}/thumbnail", f"{base}/api/v1/images/i/{name}/thumbnail",
@@ -121,7 +196,9 @@ async def _fetch_image_bytes(session: aiohttp.ClientSession, base: str, name: st
async with session.get(url) as resp: async with session.get(url) as resp:
ct = resp.headers.get("Content-Type", "") ct = resp.headers.get("Content-Type", "")
data = await resp.read() data = await resp.read()
logger.info(f"Image fetch {url}: status={resp.status} content-type={ct} size={len(data)}") logger.info(
f"Image fetch {url}: status={resp.status} content-type={ct} size={len(data)}"
)
if "html" not in ct and len(data) >= _MIN_IMAGE_BYTES: if "html" not in ct and len(data) >= _MIN_IMAGE_BYTES:
return data return data
except Exception as e: except Exception as e:
@@ -145,7 +222,9 @@ class GarfAI:
async def garfpic(self, ctx, prompt): async def garfpic(self, ctx, prompt):
await self.image_request_queue.put({"ctx": ctx, "prompt": prompt}) await self.image_request_queue.put({"ctx": ctx, "prompt": prompt})
async def generate_image(self, session: aiohttp.ClientSession, prompt: str) -> bytes | str: async def generate_image(
self, session: aiohttp.ClientSession, prompt: str
) -> bytes | str:
base = INVOKEAI_BASE base = INVOKEAI_BASE
try: try:
@@ -182,7 +261,9 @@ class GarfAI:
return "`GarfBot Error: Odie`" return "`GarfBot Error: Odie`"
async def process_image_requests(self): async def process_image_requests(self):
async with aiohttp.ClientSession(headers={"Accept": "application/json"}) as session: async with aiohttp.ClientSession(
headers={"Accept": "application/json"}
) as session:
while True: while True:
request = await self.image_request_queue.get() request = await self.image_request_queue.get()
ctx = request["ctx"] ctx = request["ctx"]
@@ -231,7 +312,9 @@ class GarfAI:
async def wikisum(self, query: str) -> str: async def wikisum(self, query: str) -> str:
try: try:
summary = wikipedia.summary(query) summary = wikipedia.summary(query)
return await self.generate_chat(f"Please summarize in your own words: {summary}") return await self.generate_chat(
f"Please summarize in your own words: {summary}"
)
except wikipedia.exceptions.DisambiguationError as e: except wikipedia.exceptions.DisambiguationError as e:
options = ", ".join(e.options[:3]) options = ", ".join(e.options[:3])
return f"`GarfBot Error: Ambiguous query — did you mean: {options}?`" return f"`GarfBot Error: Ambiguous query — did you mean: {options}?`"

View File

@@ -1,10 +1,11 @@
import config import config
import openai import openai
from openai import AsyncOpenAI
import discord import discord
from discord.ext import commands
import asyncio import asyncio
import logging import logging
from logging.handlers import TimedRotatingFileHandler from logging.handlers import TimedRotatingFileHandler
from openai import AsyncOpenAI
logger = logging.getLogger("garflog") logger = logging.getLogger("garflog")
@@ -32,59 +33,69 @@ if not logger.hasHandlers():
openai.api_key = config.OPENAI_TOKEN openai.api_key = config.OPENAI_TOKEN
jonkey = config.JONBOT_TOKEN jonkey = config.JONBOT_TOKEN
model = config.TXT_MODEL txtmodel = config.TXT_MODEL
sysprompt = config.SYSTEM_PROMPT
intents = discord.Intents.default() intents = discord.Intents.default()
intents.messages = True intents.messages = True
intents.message_content = True intents.message_content = True
client = discord.Client(intents=intents)
client = commands.Bot(
command_prefix=["Jonbot ", "jonbot ", "Jon", "jon"],
case_insensitive=True,
intents=intents,
)
@client.event @client.event
async def on_ready(): async def on_ready():
print(f"Logged in as {client.user.name} running {model}.", flush=True) print(f"Logged in as {client.user.name} running {txtmodel}.", flush=True)
@client.command(name="chat")
async def jonchat(ctx, *, prompt):
if "is this true" in prompt.lower():
messages = [msg async for msg in ctx.channel.history(limit=2)]
prompt = messages[1].content
prompt = f"Is this true: {prompt}"
answer = await generate_chat(prompt)
logger.info(
f"Chat Request - User: {ctx.author.name}, Server: {ctx.guild.name}, Prompt: {prompt}"
)
await ctx.reply(answer)
@client.event @client.event
async def on_message(message): async def on_message(message):
if message.author == client.user: if message.author == client.user:
return return
if message.content.lower().startswith("hey jon") or isinstance(message.channel, discord.DMChannel):
question = message.content[7:] if message.content.lower().startswith("hey jon") else message.content content = message.content.strip()
try: lower = content.lower()
response = openai.ChatCompletion.create( if lower.startswith("hey jon") or isinstance(message.channel, discord.DMChannel):
model=model, ctx = await client.get_context(message)
messages=[ await jonchat(ctx, prompt=content)
{"role": "system", "content": "Pretend you are friendly Jon Arbuckle."},
{"role": "user", "content": f"{question}"}
],
max_tokens=400
)
answer = response['choices'][0]['message']['content']
answer = answer.replace("AI language model", "American citizen")
answer = answer.replace("language model AI", "citizen of the United States")
await message.channel.send(answer)
except Exception as e:
e = str(e)
await message.channel.send(f"`JonBot Error: {e}`")
oai = AsyncOpenAI( oai = AsyncOpenAI(
api_key=config.OPENAI_TOKEN, api_key=config.OPENAI_TOKEN,
base_url=config.BASE_URL, base_url=config.BASE_URL,
) )
async def generate_chat(self, question: str) -> str:
async def generate_chat(question: str) -> str:
try: try:
response = await oai.chat.completions.create( response = await oai.chat.completions.create(
model=self.txtmodel, model=txtmodel,
messages=[ messages=[
{"role": "system", "content": self.sysprompt}, {"role": "system", "content": sysprompt},
{"role": "user", "content": question}, {"role": "user", "content": question},
], ],
max_tokens=400, max_tokens=400,
temperature=1.2, temperature=1.2,
) )
answer = response.choices[0].message.content answer = response.choices[0].message.content
return answer.replace("an AI language model", "a cartoon animal") return answer
except openai.BadRequestError as e: except openai.BadRequestError as e:
logger.error(e) logger.error(e)
return f"`JonBot Error: {e}`" return f"`JonBot Error: {e}`"
@@ -96,7 +107,6 @@ async def generate_chat(self, question: str) -> str:
return "`JonBot Error: Liz`" return "`JonBot Error: Liz`"
async def jonbot_connect(): async def jonbot_connect():
while True: while True:
try: try:
@@ -106,5 +116,6 @@ async def jonbot_connect():
logger.error(f"Jonbot couldn't connect! {e}") logger.error(f"Jonbot couldn't connect! {e}")
await asyncio.sleep(60) await asyncio.sleep(60)
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(jonbot_connect()) asyncio.run(jonbot_connect())