3 Commits
jonbot ... main

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
7 changed files with 177 additions and 76 deletions

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

@@ -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.",
max_tokens=400 },
{
"role": "user",
"content": f"{question} please keep it short with religious undertones",
},
],
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,14 +54,16 @@ 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:
await client.start(moneykey) await client.start(moneykey)
except Exception as e: except Exception as e:
e = str(e) e = str(e)
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

@@ -13,13 +13,31 @@ from garfpy import logger
INVOKEAI_BASE = config.INVOKEAI_URL INVOKEAI_BASE = config.INVOKEAI_URL
_MODEL_KEY = "0eb50094-5c9b-431b-ba01-87e145edb849" _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,49 +49,103 @@ 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")
te = _node_id("flux2_klein_text_encoder") te = _node_id("flux2_klein_text_encoder")
dn = _node_id("flux2_denoise") dn = _node_id("flux2_denoise")
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)
total = s.get("total", 0) total = s.get("total", 0)
completed = s.get("completed", 0) completed = s.get("completed", 0)
failed = s.get("failed", 0) failed = s.get("failed", 0)
if total > 0 and failed >= total: if total > 0 and failed >= total:
logger.error(f"Batch {batch_id} failed") logger.error(f"Batch {batch_id} failed")
return False return False
@@ -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",
@@ -119,9 +194,11 @@ async def _fetch_image_bytes(session: aiohttp.ClientSession, base: str, name: st
for url in urls: for url in urls:
try: try:
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:
@@ -131,10 +208,10 @@ async def _fetch_image_bytes(session: aiohttp.ClientSession, base: str, name: st
class GarfAI: class GarfAI:
def __init__(self): def __init__(self):
self.baseurl = config.BASE_URL self.baseurl = config.BASE_URL
self.sysprompt = config.SYSTEM_PROMPT self.sysprompt = config.SYSTEM_PROMPT
self.txtmodel = config.TXT_MODEL self.txtmodel = config.TXT_MODEL
self.imgmodel = config.IMG_MODEL self.imgmodel = config.IMG_MODEL
self._oai = AsyncOpenAI( self._oai = AsyncOpenAI(
api_key=config.OPENAI_TOKEN, api_key=config.OPENAI_TOKEN,
@@ -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:
@@ -157,7 +236,7 @@ class GarfAI:
text = await resp.text() text = await resp.text()
logger.error(f"InvokeAI enqueue failed {resp.status}: {text}") logger.error(f"InvokeAI enqueue failed {resp.status}: {text}")
return "`GarfBot Error: InvokeAI rejected the request`" return "`GarfBot Error: InvokeAI rejected the request`"
data = await resp.json(content_type=None) data = await resp.json(content_type=None)
batch_id = data["batch"]["batch_id"] batch_id = data["batch"]["batch_id"]
except Exception as e: except Exception as e:
logger.error(f"InvokeAI enqueue error: {e}") logger.error(f"InvokeAI enqueue error: {e}")
@@ -182,19 +261,21 @@ 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"]
prompt = request["prompt"] prompt = request["prompt"]
result = await self.generate_image(session, prompt) result = await self.generate_image(session, prompt)
if isinstance(result, bytes): if isinstance(result, bytes):
logger.info("Sending image...") logger.info("Sending image...")
image = io.BytesIO(result) image = io.BytesIO(result)
timestamp = ctx.message.created_at.strftime("%Y%m%d%H%M%S") timestamp = ctx.message.created_at.strftime("%Y%m%d%H%M%S")
filename = f"{timestamp}_generated_image.png" filename = f"{timestamp}_generated_image.png"
try: try:
await ctx.reply(file=discord.File(fp=image, filename=filename)) await ctx.reply(file=discord.File(fp=image, filename=filename))
except Exception as e: except Exception as e:
@@ -211,7 +292,7 @@ class GarfAI:
model=self.txtmodel, model=self.txtmodel,
messages=[ messages=[
{"role": "system", "content": self.sysprompt}, {"role": "system", "content": self.sysprompt},
{"role": "user", "content": question}, {"role": "user", "content": question},
], ],
max_tokens=400, max_tokens=400,
temperature=1.2, temperature=1.2,
@@ -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

@@ -47,7 +47,7 @@ async def generate_qr(text):
qr = qrcode.QRCode( qr = qrcode.QRCode(
version=version, version=version,
error_correction=qrcode.constants.ERROR_CORRECT_L, # type: ignore error_correction=qrcode.constants.ERROR_CORRECT_L, # type: ignore
box_size=box_size, box_size=box_size,
border=4, border=4,
) )
@@ -58,7 +58,7 @@ async def generate_qr(text):
qr_image = qr.make_image(fill_color="black", back_color="white") qr_image = qr.make_image(fill_color="black", back_color="white")
img_buffer = BytesIO() img_buffer = BytesIO()
qr_image.save(img_buffer, format="PNG") # type: ignore qr_image.save(img_buffer, format="PNG") # type: ignore
img_buffer.seek(0) img_buffer.seek(0)
return img_buffer return img_buffer

View File

@@ -46,10 +46,12 @@ client = commands.Bot(
intents=intents, intents=intents,
) )
@client.event @client.event
async def on_ready(): async def on_ready():
print(f"Logged in as {client.user.name} running {txtmodel}.", flush=True) print(f"Logged in as {client.user.name} running {txtmodel}.", flush=True)
@client.command(name="chat") @client.command(name="chat")
async def jonchat(ctx, *, prompt): async def jonchat(ctx, *, prompt):
if "is this true" in prompt.lower(): if "is this true" in prompt.lower():
@@ -62,6 +64,7 @@ async def jonchat(ctx, *, prompt):
) )
await ctx.reply(answer) 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:
@@ -69,9 +72,7 @@ async def on_message(message):
content = message.content.strip() content = message.content.strip()
lower = content.lower() lower = content.lower()
if lower.startswith("hey jon") or isinstance( if lower.startswith("hey jon") or isinstance(message.channel, discord.DMChannel):
message.channel, discord.DMChannel
):
ctx = await client.get_context(message) ctx = await client.get_context(message)
await jonchat(ctx, prompt=content) await jonchat(ctx, prompt=content)
@@ -81,13 +82,14 @@ oai = AsyncOpenAI(
base_url=config.BASE_URL, base_url=config.BASE_URL,
) )
async def generate_chat(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=txtmodel, model=txtmodel,
messages=[ messages=[
{"role": "system", "content": 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,
@@ -105,15 +107,15 @@ async def generate_chat(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:
await client.start(jonkey) await client.start(jonkey)
except Exception as e: except Exception as e:
e = str(e) e = str(e)
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())