Spaces:
Running
Running
Chandima Prabhath
commited on
Commit
·
e15e135
1
Parent(s):
7ac9a06
Refactor LLM integration and function call handling; update default model and streamline message processing
Browse files
app.py
CHANGED
@@ -4,40 +4,27 @@ import requests
|
|
4 |
import logging
|
5 |
import queue
|
6 |
import json
|
7 |
-
from typing import List, Optional
|
8 |
-
from collections import defaultdict, deque
|
9 |
-
from concurrent.futures import ThreadPoolExecutor
|
10 |
-
|
11 |
from fastapi import FastAPI, Request, HTTPException
|
12 |
from fastapi.responses import PlainTextResponse
|
13 |
-
from pydantic import BaseModel, Field
|
14 |
|
15 |
from FLUX import generate_image
|
16 |
from VoiceReply import generate_voice_reply
|
17 |
-
from polLLM import
|
18 |
-
|
19 |
-
# --- Logging Setup ---------------------------------------------------------
|
20 |
|
|
|
21 |
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
22 |
logger = logging.getLogger("eve_bot")
|
23 |
logger.setLevel(LOG_LEVEL)
|
24 |
-
|
25 |
handler = logging.StreamHandler()
|
26 |
formatter = logging.Formatter(
|
27 |
"%(asctime)s [%(levelname)s] [%(message_id)s/%(sender)s] %(message)s"
|
28 |
)
|
29 |
handler.setFormatter(formatter)
|
30 |
-
|
31 |
-
class ContextFilter(logging.Filter):
|
32 |
-
def filter(self, record):
|
33 |
-
record.message_id = getattr(record, "message_id", "-")
|
34 |
-
record.sender = getattr(record, "sender", "-")
|
35 |
-
return True
|
36 |
-
|
37 |
-
handler.addFilter(ContextFilter())
|
38 |
logger.handlers = [handler]
|
39 |
|
40 |
-
# Thread‐local to carry context
|
41 |
_thread_ctx = threading.local()
|
42 |
def set_thread_context(chat_id, sender, message_id):
|
43 |
_thread_ctx.chat_id = chat_id
|
@@ -51,31 +38,20 @@ def get_thread_context():
|
|
51 |
getattr(_thread_ctx, "message_id", None),
|
52 |
)
|
53 |
|
54 |
-
#
|
55 |
-
|
56 |
history = defaultdict(lambda: deque(maxlen=20))
|
|
|
|
|
57 |
|
58 |
-
|
59 |
-
history[(chat_id, sender)].append(f"User: {message}")
|
60 |
-
|
61 |
-
def record_bot_message(chat_id, sender, message):
|
62 |
-
history[(chat_id, sender)].append(f"Assistant: {message}")
|
63 |
-
|
64 |
-
def get_history_text(chat_id, sender):
|
65 |
-
return "\n".join(history[(chat_id, sender)])
|
66 |
-
|
67 |
-
def clear_history(chat_id, sender):
|
68 |
-
history[(chat_id, sender)].clear()
|
69 |
-
|
70 |
-
# --- Bot Config & Client --------------------------------------------------
|
71 |
-
|
72 |
class BotConfig:
|
73 |
GREEN_API_URL = os.getenv("GREEN_API_URL")
|
74 |
GREEN_API_MEDIA_URL = os.getenv("GREEN_API_MEDIA_URL", "https://api.green-api.com")
|
75 |
GREEN_API_TOKEN = os.getenv("GREEN_API_TOKEN")
|
76 |
GREEN_API_ID_INSTANCE = os.getenv("GREEN_API_ID_INSTANCE")
|
77 |
WEBHOOK_AUTH_TOKEN = os.getenv("WEBHOOK_AUTH_TOKEN")
|
78 |
-
BOT_GROUP_CHAT =
|
79 |
BOT_JID = os.getenv("BOT_JID")
|
80 |
IMAGE_DIR = "/tmp/images"
|
81 |
AUDIO_DIR = "/tmp/audio"
|
@@ -85,7 +61,7 @@ class BotConfig:
|
|
85 |
def validate(cls):
|
86 |
missing = [n for n in (
|
87 |
"GREEN_API_URL","GREEN_API_TOKEN",
|
88 |
-
"GREEN_API_ID_INSTANCE","WEBHOOK_AUTH_TOKEN","BOT_JID"
|
89 |
) if not getattr(cls, n)]
|
90 |
if missing:
|
91 |
raise ValueError(f"Missing env vars: {', '.join(missing)}")
|
@@ -122,12 +98,6 @@ class BotClient:
|
|
122 |
"quotedMessageId": message_id
|
123 |
})
|
124 |
|
125 |
-
def send_message_to(self, chat_id, text):
|
126 |
-
return self.send("sendMessage", {
|
127 |
-
"chatId": chat_id,
|
128 |
-
"message": text
|
129 |
-
})
|
130 |
-
|
131 |
def send_media(self, message_id, chat_id, file_path, caption, media_type):
|
132 |
endpoint = "sendFileByUpload"
|
133 |
payload = {
|
@@ -143,12 +113,8 @@ class BotClient:
|
|
143 |
BotConfig.validate()
|
144 |
client = BotClient(BotConfig)
|
145 |
|
146 |
-
#
|
147 |
-
|
148 |
task_queue = queue.Queue()
|
149 |
-
polls = {}
|
150 |
-
executor = ThreadPoolExecutor(max_workers=4)
|
151 |
-
|
152 |
def worker():
|
153 |
while True:
|
154 |
task = task_queue.get()
|
@@ -161,17 +127,15 @@ def worker():
|
|
161 |
logger.error(f"Worker error {task}: {e}")
|
162 |
finally:
|
163 |
task_queue.task_done()
|
164 |
-
|
165 |
for _ in range(4):
|
166 |
threading.Thread(target=worker, daemon=True).start()
|
167 |
|
168 |
-
#
|
169 |
-
|
170 |
def _fn_send_text(mid, cid, message):
|
171 |
client.send_message(mid, cid, message)
|
172 |
chat_id, sender, _ = get_thread_context()
|
173 |
if chat_id and sender:
|
174 |
-
|
175 |
task_queue.put({
|
176 |
"type": "audio",
|
177 |
"message_id": mid,
|
@@ -179,120 +143,26 @@ def _fn_send_text(mid, cid, message):
|
|
179 |
"prompt": message
|
180 |
})
|
181 |
|
182 |
-
|
183 |
-
|
184 |
-
|
185 |
-
|
186 |
-
record_bot_message(chat_id, sender, message)
|
187 |
-
|
188 |
-
def _fn_summarize(mid, cid, text):
|
189 |
-
summary = generate_llm(f"Summarize:\n\n{text}")
|
190 |
-
_fn_send_text(mid, cid, summary)
|
191 |
-
|
192 |
-
def _fn_translate(mid, cid, lang, text):
|
193 |
-
resp = generate_llm(f"Translate to {lang}:\n\n{text}")
|
194 |
-
_fn_send_text(mid, cid, resp)
|
195 |
-
|
196 |
-
def _fn_joke(mid, cid):
|
197 |
-
try:
|
198 |
-
j = requests.get(
|
199 |
-
"https://official-joke-api.appspot.com/random_joke",
|
200 |
-
timeout=5
|
201 |
-
).json()
|
202 |
-
joke = f"{j['setup']}\n\n{j['punchline']}"
|
203 |
-
except:
|
204 |
-
joke = generate_llm("Tell me a short joke.")
|
205 |
-
_fn_send_text(mid, cid, joke)
|
206 |
-
|
207 |
-
def _fn_weather(mid, cid, loc):
|
208 |
-
raw = requests.get(f"http://sl.wttr.in/{loc}?format=4", timeout=5).text
|
209 |
-
report = generate_llm(f"Give a weather report in °C:\n\n{raw}")
|
210 |
-
_fn_send_text(mid, cid, report)
|
211 |
-
|
212 |
-
def _fn_inspire(mid, cid):
|
213 |
-
quote = generate_llm("Give me a unique, random short inspirational quote.")
|
214 |
-
_fn_send_text(mid, cid, f"✨ {quote}")
|
215 |
-
|
216 |
-
def _fn_meme(mid, cid, txt):
|
217 |
-
_fn_send_accept(mid, cid, "🎨 Generating meme…")
|
218 |
-
task_queue.put({
|
219 |
-
"type": "image",
|
220 |
-
"message_id": mid,
|
221 |
-
"chat_id": cid,
|
222 |
-
"prompt": f"meme: {txt}"
|
223 |
-
})
|
224 |
-
|
225 |
-
def _fn_poll_create(mid, cid, question, options):
|
226 |
-
votes = {i+1:0 for i in range(len(options))}
|
227 |
-
polls[cid] = {"question": question, "options": options, "votes": votes, "voters": {}}
|
228 |
-
text = f"📊 *Poll:* {question}\n" + "\n".join(f"{i+1}. {o}" for i,o in enumerate(options))
|
229 |
-
_fn_send_text(mid, cid, text)
|
230 |
-
|
231 |
-
def _fn_poll_vote(mid, cid, voter, choice):
|
232 |
-
poll = polls.get(cid)
|
233 |
-
if not poll or choice<1 or choice>len(poll["options"]):
|
234 |
-
return
|
235 |
-
prev = poll["voters"].get(voter)
|
236 |
-
if prev:
|
237 |
-
poll["votes"][prev] -= 1
|
238 |
-
poll["votes"][choice] += 1
|
239 |
-
poll["voters"][voter] = choice
|
240 |
-
_fn_send_text(mid, cid, f"✅ Voted for {poll['options'][choice-1]}")
|
241 |
-
|
242 |
-
def _fn_poll_results(mid, cid):
|
243 |
-
poll = polls.get(cid)
|
244 |
-
if not poll:
|
245 |
-
_fn_send_text(mid, cid, "No active poll.")
|
246 |
-
return
|
247 |
-
txt = f"📊 *Results:* {poll['question']}\n" + "\n".join(
|
248 |
-
f"{i}. {o}: {poll['votes'][i]}" for i,o in enumerate(poll["options"],1)
|
249 |
-
)
|
250 |
-
_fn_send_text(mid, cid, txt)
|
251 |
-
|
252 |
-
def _fn_poll_end(mid, cid):
|
253 |
-
poll = polls.pop(cid, None)
|
254 |
-
if not poll:
|
255 |
-
_fn_send_text(mid, cid, "No active poll.")
|
256 |
-
return
|
257 |
-
txt = f"📊 *Final Results:* {poll['question']}\n" + "\n".join(
|
258 |
-
f"{i}. {o}: {poll['votes'][i]}" for i,o in enumerate(poll["options"],1)
|
259 |
-
)
|
260 |
-
_fn_send_text(mid, cid, txt)
|
261 |
-
|
262 |
-
def _fn_generate_images(
|
263 |
-
message_id: str,
|
264 |
-
chat_id: str,
|
265 |
-
prompt: str,
|
266 |
-
count: int = 1,
|
267 |
-
width: Optional[int] = None,
|
268 |
-
height: Optional[int] = None,
|
269 |
-
**_
|
270 |
-
):
|
271 |
-
_fn_send_accept(message_id, chat_id, f"✨ Generating {count} image(s)…")
|
272 |
-
for i in range(1, count+1):
|
273 |
try:
|
274 |
img, path, ret_p, url = generate_image(
|
275 |
prompt, message_id, message_id, BotConfig.IMAGE_DIR,
|
276 |
width=width, height=height
|
277 |
)
|
278 |
formatted = "\n\n".join(f"_{p.strip()}_" for p in ret_p.split("\n\n") if p.strip())
|
279 |
-
cap = f"✨ Image {i}/{count}: {url}\n>{chr(8203)} {formatted}"
|
280 |
client.send_media(message_id, chat_id, path, cap, media_type="image")
|
281 |
os.remove(path)
|
282 |
except Exception as e:
|
283 |
-
logger.warning(f"Img {i}/{count} failed: {e}")
|
284 |
-
_fn_send_text(message_id, chat_id, f"😢 Failed to generate image {i}/{count}.")
|
285 |
|
286 |
-
|
287 |
-
|
288 |
-
|
289 |
-
prompt: str,
|
290 |
-
**_
|
291 |
-
):
|
292 |
-
proc = (
|
293 |
-
f"Just say this exactly as written in a friendly, playful, "
|
294 |
-
f"happy and helpful but a little bit clumsy-cute way: {prompt}"
|
295 |
-
)
|
296 |
res = generate_voice_reply(proc, model="openai-audio", voice="coral", audio_dir=BotConfig.AUDIO_DIR)
|
297 |
if res and res[0]:
|
298 |
path, _ = res
|
@@ -301,318 +171,83 @@ def _fn_voice_reply(
|
|
301 |
else:
|
302 |
_fn_send_text(message_id, chat_id, prompt)
|
303 |
|
304 |
-
#
|
305 |
-
|
306 |
-
|
307 |
-
|
308 |
-
|
309 |
-
|
310 |
-
|
311 |
-
|
312 |
-
|
313 |
-
|
314 |
-
|
315 |
-
|
316 |
-
|
317 |
-
|
318 |
-
|
319 |
-
|
320 |
-
|
321 |
-
|
322 |
-
|
323 |
-
|
324 |
-
|
325 |
-
|
326 |
-
|
327 |
-
|
328 |
-
|
329 |
-
|
330 |
-
text: str
|
331 |
-
|
332 |
-
class PollCreateIntent(BaseIntent):
|
333 |
-
action: Literal["poll_create"]
|
334 |
-
question: str
|
335 |
-
options: List[str]
|
336 |
-
|
337 |
-
class PollVoteIntent(BaseIntent):
|
338 |
-
action: Literal["poll_vote"]
|
339 |
-
voter: str
|
340 |
-
choice: int
|
341 |
-
|
342 |
-
class PollResultsIntent(BaseIntent):
|
343 |
-
action: Literal["poll_results"]
|
344 |
-
|
345 |
-
class PollEndIntent(BaseIntent):
|
346 |
-
action: Literal["poll_end"]
|
347 |
-
|
348 |
-
class GenerateImageIntent(BaseModel):
|
349 |
-
action: Literal["generate_image"]
|
350 |
-
prompt: str
|
351 |
-
count: int = Field(default=1, ge=1)
|
352 |
-
width: Optional[int]
|
353 |
-
height: Optional[int]
|
354 |
-
|
355 |
-
class SendTextIntent(BaseIntent):
|
356 |
-
action: Literal["send_text"]
|
357 |
-
message: str
|
358 |
-
|
359 |
-
# list of all intent models
|
360 |
-
INTENT_MODELS = [
|
361 |
-
SummarizeIntent, TranslateIntent, JokeIntent, WeatherIntent,
|
362 |
-
InspireIntent, MemeIntent, PollCreateIntent, PollVoteIntent,
|
363 |
-
PollResultsIntent, PollEndIntent, GenerateImageIntent, SendTextIntent
|
364 |
]
|
365 |
|
366 |
-
|
367 |
-
|
368 |
-
|
369 |
-
"
|
370 |
-
|
371 |
-
"
|
372 |
-
|
373 |
-
|
374 |
-
"poll_vote": lambda mid,cid,**i: _fn_poll_vote(mid,cid,i["voter"],i["choice"]),
|
375 |
-
"poll_results": lambda mid,cid,**i: _fn_poll_results(mid,cid),
|
376 |
-
"poll_end": lambda mid,cid,**i: _fn_poll_end(mid,cid),
|
377 |
-
"generate_image": _fn_generate_images,
|
378 |
-
"send_text": lambda mid,cid,**i: _fn_send_text(mid,cid,i["message"]),
|
379 |
-
}
|
380 |
-
|
381 |
-
# --- Intent Routing with Fallback & History‐Reset on 400 -------------------
|
382 |
-
|
383 |
-
def route_intent(user_input: str, chat_id: str, sender: str):
|
384 |
-
history_text = get_history_text(chat_id, sender)
|
385 |
-
sys_prompt = (
|
386 |
-
"You are Eve, a sweet, innocent, and helpful assistant. "
|
387 |
-
"You never perform work yourself—you only invoke one of the available functions. "
|
388 |
-
"When the user asks for something that matches a function signature, you must return exactly one JSON object matching that function’s parameters—and nothing else. "
|
389 |
-
"Do not wrap it in markdown, do not add extra text, and do not show the JSON to the user. "
|
390 |
-
"If the user’s request does not match any function, reply in plain text, and never mention JSON or internal logic.\n\n"
|
391 |
-
"Functions you can call:\n"
|
392 |
-
" summarize(text)\n"
|
393 |
-
" translate(lang, text)\n"
|
394 |
-
" joke()\n"
|
395 |
-
" weather(location)\n"
|
396 |
-
" inspire()\n"
|
397 |
-
" meme(text)\n"
|
398 |
-
" poll_create(question, options)\n"
|
399 |
-
" poll_vote(voter, choice)\n"
|
400 |
-
" poll_results()\n"
|
401 |
-
" poll_end()\n"
|
402 |
-
" generate_image(prompt, count, width, height)\n"
|
403 |
-
" send_text(message)\n\n"
|
404 |
-
"Conversation so far:\n"
|
405 |
-
f"{history_text}\n\n"
|
406 |
-
"Current user message:\n"
|
407 |
-
f"User: {user_input}"
|
408 |
-
)
|
409 |
-
|
410 |
-
#prompt = f"{sys_prompt}\nConversation so far:\n{history_text}\n\n current message: User: {user_input}"
|
411 |
-
|
412 |
-
try:
|
413 |
-
raw = generate_llm(sys_prompt)
|
414 |
-
except LLMBadRequestError:
|
415 |
-
# Clear history on HTTP 400 from the LLM
|
416 |
-
clear_history(chat_id, sender)
|
417 |
-
return SendTextIntent(action="send_text", message="Oops, I lost my train of thought—let’s start fresh!")
|
418 |
-
|
419 |
-
logger.debug(f"LLM raw response: {raw}")
|
420 |
-
|
421 |
-
# 1) Strict: try each Pydantic model
|
422 |
-
try:
|
423 |
-
parsed = json.loads(raw)
|
424 |
-
logger.debug(f"Parsed JSON: {parsed}")
|
425 |
-
except json.JSONDecodeError:
|
426 |
-
return SendTextIntent(action="send_text", message=raw)
|
427 |
-
|
428 |
-
for M in INTENT_MODELS:
|
429 |
-
try:
|
430 |
-
intent = M.model_validate(parsed)
|
431 |
-
logger.debug(f"Matched intent model: {M.__name__} with data {parsed}")
|
432 |
-
return intent
|
433 |
-
except ValidationError:
|
434 |
-
continue
|
435 |
-
|
436 |
-
logger.warning("Strict parse failed for all models, falling back to lenient")
|
437 |
-
|
438 |
-
# 2) Lenient JSON get
|
439 |
-
action = parsed.get("action")
|
440 |
-
if action in ACTION_HANDLERS:
|
441 |
-
data = parsed
|
442 |
-
kwargs = {}
|
443 |
-
if action == "generate_image":
|
444 |
-
kwargs["prompt"] = data.get("prompt","")
|
445 |
-
kwargs["count"] = int(data.get("count", BotConfig.DEFAULT_IMAGE_COUNT))
|
446 |
-
kwargs["width"] = data.get("width")
|
447 |
-
kwargs["height"] = data.get("height")
|
448 |
-
elif action == "send_text":
|
449 |
-
kwargs["message"] = data.get("message","")
|
450 |
-
elif action == "translate":
|
451 |
-
kwargs["lang"] = data.get("lang","")
|
452 |
-
kwargs["text"] = data.get("text","")
|
453 |
-
elif action == "summarize":
|
454 |
-
kwargs["text"] = data.get("text","")
|
455 |
-
elif action == "weather":
|
456 |
-
kwargs["location"] = data.get("location","")
|
457 |
-
elif action == "meme":
|
458 |
-
kwargs["text"] = data.get("text","")
|
459 |
-
elif action == "poll_create":
|
460 |
-
kwargs["question"] = data.get("question","")
|
461 |
-
kwargs["options"] = data.get("options",[])
|
462 |
-
elif action == "poll_vote":
|
463 |
-
kwargs["voter"] = sender
|
464 |
-
kwargs["choice"] = int(data.get("choice",0))
|
465 |
-
try:
|
466 |
-
# coerce into Pydantic for uniform interface
|
467 |
-
model = next(
|
468 |
-
m for m in INTENT_MODELS
|
469 |
-
if getattr(m, "__fields__", {}).get("action").default == action
|
470 |
-
)
|
471 |
-
intent = model.model_validate({"action":action, **kwargs})
|
472 |
-
logger.debug(f"Leniently matched intent model: {model.__name__} with kwargs {kwargs}")
|
473 |
-
return intent
|
474 |
-
except Exception as e:
|
475 |
-
logger.error(f"Lenient parsing into Pydantic failed: {e}")
|
476 |
-
return SendTextIntent(action="send_text", message=raw)
|
477 |
-
|
478 |
-
return SendTextIntent(action="send_text", message=raw)
|
479 |
-
|
480 |
-
# --- FastAPI & Webhook ----------------------------------------------------
|
481 |
|
|
|
482 |
app = FastAPI()
|
483 |
-
help_text = (
|
484 |
-
"🤖 *Eve* commands:\n"
|
485 |
-
"• /help\n"
|
486 |
-
"• /summarize <text>\n"
|
487 |
-
"• /translate <lang>|<text>\n"
|
488 |
-
"• /joke\n"
|
489 |
-
"• /weather <loc>\n"
|
490 |
-
"• /inspire\n"
|
491 |
-
"• /meme <text>\n"
|
492 |
-
"• /poll <Q>|… / /results / /endpoll\n"
|
493 |
-
"• /gen <prompt>|<count>|<width>|<height>\n"
|
494 |
-
"Otherwise chat or reply to my message to invoke tools."
|
495 |
-
)
|
496 |
|
497 |
@app.post("/whatsapp")
|
498 |
async def whatsapp_webhook(request: Request):
|
499 |
data = await request.json()
|
500 |
-
if request.headers.get("Authorization") != f"Bearer {
|
501 |
raise HTTPException(403, "Unauthorized")
|
502 |
|
503 |
chat_id = data["senderData"]["chatId"]
|
504 |
sender = data["senderData"]["sender"]
|
505 |
mid = data["idMessage"]
|
506 |
set_thread_context(chat_id, sender, mid)
|
507 |
-
logger.debug(f"Received webhook for message {mid} from {sender}")
|
508 |
-
|
509 |
-
if chat_id != BotConfig.BOT_GROUP_CHAT or data["typeWebhook"] != "incomingMessageReceived":
|
510 |
-
return {"success": True}
|
511 |
|
512 |
-
md
|
513 |
tmd = md.get("textMessageData") or md.get("extendedTextMessageData")
|
514 |
-
if not tmd:
|
515 |
return {"success": True}
|
516 |
|
517 |
body = (tmd.get("textMessage") or tmd.get("text","")).strip()
|
518 |
-
|
519 |
-
logger.debug(f"User message: {body}")
|
520 |
-
|
521 |
-
low = body.lower()
|
522 |
-
# Slash commands...
|
523 |
-
if low == "/help":
|
524 |
-
_fn_send_text(mid, chat_id, help_text)
|
525 |
-
return {"success": True}
|
526 |
-
if low.startswith("/summarize "):
|
527 |
-
_fn_summarize(mid, chat_id, body[11:].strip())
|
528 |
-
return {"success": True}
|
529 |
-
if low.startswith("/translate "):
|
530 |
-
lang, txt = body[11:].split("|", 1)
|
531 |
-
_fn_translate(mid, chat_id, lang.strip(), txt.strip())
|
532 |
-
return {"success": True}
|
533 |
-
if low == "/joke":
|
534 |
-
_fn_joke(mid, chat_id)
|
535 |
-
return {"success": True}
|
536 |
-
if low.startswith("/weather "):
|
537 |
-
_fn_weather(mid, chat_id, body[9:].strip().replace(" ","+"))
|
538 |
-
return {"success": True}
|
539 |
-
if low == "/inspire":
|
540 |
-
_fn_inspire(mid, chat_id)
|
541 |
-
return {"success": True}
|
542 |
-
if low.startswith("/meme "):
|
543 |
-
_fn_meme(mid, chat_id, body[6:].strip())
|
544 |
-
return {"success": True}
|
545 |
-
if low.startswith("/poll "):
|
546 |
-
parts = [p.strip() for p in body[6:].split("|")]
|
547 |
-
_fn_poll_create(mid, chat_id, parts[0], parts[1:])
|
548 |
-
return {"success": True}
|
549 |
-
if chat_id in polls and low.isdigit():
|
550 |
-
_fn_poll_vote(mid, chat_id, sender, int(low))
|
551 |
-
return {"success": True}
|
552 |
-
if low == "/results":
|
553 |
-
_fn_poll_results(mid, chat_id)
|
554 |
-
return {"success": True}
|
555 |
-
if low == "/endpoll":
|
556 |
-
_fn_poll_end(mid, chat_id)
|
557 |
-
return {"success": True}
|
558 |
-
if low.startswith("/gen"):
|
559 |
-
parts = body[4:].split("|")
|
560 |
-
pr = parts[0].strip()
|
561 |
-
ct = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else BotConfig.DEFAULT_IMAGE_COUNT
|
562 |
-
width = int(parts[2]) if len(parts) > 2 and parts[2].isdigit() else None
|
563 |
-
height = int(parts[3]) if len(parts) > 3 and parts[3].isdigit() else None
|
564 |
-
_fn_send_accept(mid, chat_id, f"✨ Generating {ct} image(s)…")
|
565 |
-
task_queue.put({
|
566 |
-
"type": "image",
|
567 |
-
"message_id": mid,
|
568 |
-
"chat_id": chat_id,
|
569 |
-
"prompt": pr,
|
570 |
-
"count": ct,
|
571 |
-
"width": width,
|
572 |
-
"height": height
|
573 |
-
})
|
574 |
-
return {"success": True}
|
575 |
|
576 |
-
|
577 |
-
|
|
|
|
|
|
|
578 |
return {"success": True}
|
579 |
|
580 |
-
|
581 |
-
|
582 |
-
ext = md["extendedTextMessageData"]
|
583 |
-
quoted = md["quotedMessage"]
|
584 |
-
if ext.get("participant") == BotConfig.BOT_JID:
|
585 |
-
effective = (
|
586 |
-
f"Quoted: {quoted.get('textMessage','')}\n"
|
587 |
-
f"User: {ext.get('text','')}"
|
588 |
-
)
|
589 |
-
else:
|
590 |
-
effective = body
|
591 |
-
else:
|
592 |
-
effective = body
|
593 |
-
|
594 |
-
# Route intent & dispatch
|
595 |
-
intent = route_intent(effective, chat_id, sender)
|
596 |
-
logger.debug(f"Final intent: {intent}")
|
597 |
-
handler = ACTION_HANDLERS.get(intent.action)
|
598 |
-
if handler:
|
599 |
-
kwargs = intent.model_dump(exclude={"action"})
|
600 |
-
logger.debug(f"Dispatching action '{intent.action}' with args {kwargs}")
|
601 |
-
handler(mid, chat_id, **kwargs)
|
602 |
else:
|
603 |
-
|
604 |
-
_fn_send_text(mid, chat_id, "Sorry, I didn't understand that.")
|
605 |
|
606 |
return {"success": True}
|
607 |
|
608 |
@app.get("/", response_class=PlainTextResponse)
|
609 |
def index():
|
610 |
-
return "
|
611 |
|
612 |
if __name__ == "__main__":
|
613 |
-
client.send_message_to(
|
614 |
-
BotConfig.BOT_GROUP_CHAT,
|
615 |
-
"🌟 Eve is online! Type /help to see commands."
|
616 |
-
)
|
617 |
import uvicorn
|
618 |
-
uvicorn.run(app, host="0.0.0.0", port=7860)
|
|
|
4 |
import logging
|
5 |
import queue
|
6 |
import json
|
7 |
+
from typing import List, Optional
|
|
|
|
|
|
|
8 |
from fastapi import FastAPI, Request, HTTPException
|
9 |
from fastapi.responses import PlainTextResponse
|
10 |
+
from pydantic import BaseModel, Field
|
11 |
|
12 |
from FLUX import generate_image
|
13 |
from VoiceReply import generate_voice_reply
|
14 |
+
from polLLM import call_llm_function, LLMBadRequestError
|
|
|
|
|
15 |
|
16 |
+
# --- Logging Setup ---
|
17 |
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
18 |
logger = logging.getLogger("eve_bot")
|
19 |
logger.setLevel(LOG_LEVEL)
|
|
|
20 |
handler = logging.StreamHandler()
|
21 |
formatter = logging.Formatter(
|
22 |
"%(asctime)s [%(levelname)s] [%(message_id)s/%(sender)s] %(message)s"
|
23 |
)
|
24 |
handler.setFormatter(formatter)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
25 |
logger.handlers = [handler]
|
26 |
|
27 |
+
# Thread‐local to carry context
|
28 |
_thread_ctx = threading.local()
|
29 |
def set_thread_context(chat_id, sender, message_id):
|
30 |
_thread_ctx.chat_id = chat_id
|
|
|
38 |
getattr(_thread_ctx, "message_id", None),
|
39 |
)
|
40 |
|
41 |
+
# Conversation history
|
42 |
+
from collections import defaultdict, deque
|
43 |
history = defaultdict(lambda: deque(maxlen=20))
|
44 |
+
def record(role, chat_id, sender, content):
|
45 |
+
history[(chat_id, sender)].append({"role": role, "content": content})
|
46 |
|
47 |
+
# BotClient & helpers (send_text, send_media, _fn_generate_images, etc.)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
48 |
class BotConfig:
|
49 |
GREEN_API_URL = os.getenv("GREEN_API_URL")
|
50 |
GREEN_API_MEDIA_URL = os.getenv("GREEN_API_MEDIA_URL", "https://api.green-api.com")
|
51 |
GREEN_API_TOKEN = os.getenv("GREEN_API_TOKEN")
|
52 |
GREEN_API_ID_INSTANCE = os.getenv("GREEN_API_ID_INSTANCE")
|
53 |
WEBHOOK_AUTH_TOKEN = os.getenv("WEBHOOK_AUTH_TOKEN")
|
54 |
+
BOT_GROUP_CHAT = os.getenv("BOT_GROUP_CHAT")
|
55 |
BOT_JID = os.getenv("BOT_JID")
|
56 |
IMAGE_DIR = "/tmp/images"
|
57 |
AUDIO_DIR = "/tmp/audio"
|
|
|
61 |
def validate(cls):
|
62 |
missing = [n for n in (
|
63 |
"GREEN_API_URL","GREEN_API_TOKEN",
|
64 |
+
"GREEN_API_ID_INSTANCE","WEBHOOK_AUTH_TOKEN","BOT_JID","BOT_GROUP_CHAT"
|
65 |
) if not getattr(cls, n)]
|
66 |
if missing:
|
67 |
raise ValueError(f"Missing env vars: {', '.join(missing)}")
|
|
|
98 |
"quotedMessageId": message_id
|
99 |
})
|
100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
101 |
def send_media(self, message_id, chat_id, file_path, caption, media_type):
|
102 |
endpoint = "sendFileByUpload"
|
103 |
payload = {
|
|
|
113 |
BotConfig.validate()
|
114 |
client = BotClient(BotConfig)
|
115 |
|
116 |
+
# Thread pool for images/audio
|
|
|
117 |
task_queue = queue.Queue()
|
|
|
|
|
|
|
118 |
def worker():
|
119 |
while True:
|
120 |
task = task_queue.get()
|
|
|
127 |
logger.error(f"Worker error {task}: {e}")
|
128 |
finally:
|
129 |
task_queue.task_done()
|
|
|
130 |
for _ in range(4):
|
131 |
threading.Thread(target=worker, daemon=True).start()
|
132 |
|
133 |
+
# Basic send_text
|
|
|
134 |
def _fn_send_text(mid, cid, message):
|
135 |
client.send_message(mid, cid, message)
|
136 |
chat_id, sender, _ = get_thread_context()
|
137 |
if chat_id and sender:
|
138 |
+
record("assistant", chat_id, sender, message)
|
139 |
task_queue.put({
|
140 |
"type": "audio",
|
141 |
"message_id": mid,
|
|
|
143 |
"prompt": message
|
144 |
})
|
145 |
|
146 |
+
# Image generation helper
|
147 |
+
def _fn_generate_images(message_id, chat_id, prompt, count=1, width=None, height=None):
|
148 |
+
_fn_send_text(message_id, chat_id, f"✨ Generating {count} image(s)…")
|
149 |
+
for i in range(count):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
150 |
try:
|
151 |
img, path, ret_p, url = generate_image(
|
152 |
prompt, message_id, message_id, BotConfig.IMAGE_DIR,
|
153 |
width=width, height=height
|
154 |
)
|
155 |
formatted = "\n\n".join(f"_{p.strip()}_" for p in ret_p.split("\n\n") if p.strip())
|
156 |
+
cap = f"✨ Image {i+1}/{count}: {url}\n>{chr(8203)} {formatted}"
|
157 |
client.send_media(message_id, chat_id, path, cap, media_type="image")
|
158 |
os.remove(path)
|
159 |
except Exception as e:
|
160 |
+
logger.warning(f"Img {i+1}/{count} failed: {e}")
|
161 |
+
_fn_send_text(message_id, chat_id, f"😢 Failed to generate image {i+1}/{count}.")
|
162 |
|
163 |
+
# Voice reply helper
|
164 |
+
def _fn_voice_reply(message_id, chat_id, prompt, **_):
|
165 |
+
proc = f"Just say this exactly as written in a friendly, playful way: {prompt}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
166 |
res = generate_voice_reply(proc, model="openai-audio", voice="coral", audio_dir=BotConfig.AUDIO_DIR)
|
167 |
if res and res[0]:
|
168 |
path, _ = res
|
|
|
171 |
else:
|
172 |
_fn_send_text(message_id, chat_id, prompt)
|
173 |
|
174 |
+
# Function specs for LLM
|
175 |
+
FUNCTIONS = [
|
176 |
+
{
|
177 |
+
"name": "generate_image",
|
178 |
+
"description": "Generate one or more images",
|
179 |
+
"parameters": {
|
180 |
+
"type": "object",
|
181 |
+
"properties": {
|
182 |
+
"prompt": {"type": "string"},
|
183 |
+
"count": {"type": "integer", "minimum": 1},
|
184 |
+
"width": {"type": "integer"},
|
185 |
+
"height": {"type": "integer"}
|
186 |
+
},
|
187 |
+
"required": ["prompt"]
|
188 |
+
}
|
189 |
+
},
|
190 |
+
{
|
191 |
+
"name": "send_text",
|
192 |
+
"description": "Send a text message",
|
193 |
+
"parameters": {
|
194 |
+
"type": "object",
|
195 |
+
"properties": {"message": {"type": "string"}},
|
196 |
+
"required": ["message"]
|
197 |
+
}
|
198 |
+
}
|
199 |
+
# Add other function specs (summarize, translate, etc.) here...
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
200 |
]
|
201 |
|
202 |
+
def handle_function_call(call, mid, cid):
|
203 |
+
name = call["name"]
|
204 |
+
args = call.get("arguments", {})
|
205 |
+
if name == "generate_image":
|
206 |
+
_fn_generate_images(mid, cid, **args)
|
207 |
+
elif name == "send_text":
|
208 |
+
_fn_send_text(mid, cid, args["message"])
|
209 |
+
# handle other functions similarly...
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
210 |
|
211 |
+
# FastAPI setup
|
212 |
app = FastAPI()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
213 |
|
214 |
@app.post("/whatsapp")
|
215 |
async def whatsapp_webhook(request: Request):
|
216 |
data = await request.json()
|
217 |
+
if request.headers.get("Authorization") != f"Bearer {os.getenv('WEBHOOK_AUTH_TOKEN')}":
|
218 |
raise HTTPException(403, "Unauthorized")
|
219 |
|
220 |
chat_id = data["senderData"]["chatId"]
|
221 |
sender = data["senderData"]["sender"]
|
222 |
mid = data["idMessage"]
|
223 |
set_thread_context(chat_id, sender, mid)
|
|
|
|
|
|
|
|
|
224 |
|
225 |
+
md = data["messageData"]
|
226 |
tmd = md.get("textMessageData") or md.get("extendedTextMessageData")
|
227 |
+
if not tmd or chat_id != os.getenv("BOT_GROUP_CHAT") or data["typeWebhook"] != "incomingMessageReceived":
|
228 |
return {"success": True}
|
229 |
|
230 |
body = (tmd.get("textMessage") or tmd.get("text","")).strip()
|
231 |
+
record("user", chat_id, sender, body)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
232 |
|
233 |
+
try:
|
234 |
+
call = call_llm_function(body, list(history[(chat_id, sender)]), FUNCTIONS)
|
235 |
+
except LLMBadRequestError:
|
236 |
+
history[(chat_id, sender)].clear()
|
237 |
+
_fn_send_text(mid, chat_id, "Oops, let’s start fresh!")
|
238 |
return {"success": True}
|
239 |
|
240 |
+
if "name" in call:
|
241 |
+
handle_function_call(call, mid, chat_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
242 |
else:
|
243 |
+
_fn_send_text(mid, chat_id, call["content"])
|
|
|
244 |
|
245 |
return {"success": True}
|
246 |
|
247 |
@app.get("/", response_class=PlainTextResponse)
|
248 |
def index():
|
249 |
+
return "Eve is running!"
|
250 |
|
251 |
if __name__ == "__main__":
|
|
|
|
|
|
|
|
|
252 |
import uvicorn
|
253 |
+
uvicorn.run("app:app", host="0.0.0.0", port=7860)
|
polLLM.py
CHANGED
@@ -19,10 +19,10 @@ handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"
|
|
19 |
logger.addHandler(handler)
|
20 |
|
21 |
# --- LLM settings from config.yaml ---
|
22 |
-
_DEFAULT_MODEL
|
23 |
-
_SYSTEM_TEMPLATE
|
24 |
-
_CHAR
|
25 |
-
_CHUTES_API_KEY
|
26 |
|
27 |
# --- Custom exception ---
|
28 |
class LLMBadRequestError(Exception):
|
@@ -36,25 +36,19 @@ client = OpenAI(
|
|
36 |
)
|
37 |
|
38 |
def _build_system_prompt() -> str:
|
39 |
-
"""
|
40 |
-
Substitute {char} into the system prompt template.
|
41 |
-
"""
|
42 |
return _SYSTEM_TEMPLATE.replace("{char}", _CHAR)
|
43 |
|
44 |
def generate_llm(
|
45 |
-
|
46 |
-
|
|
|
|
|
47 |
"""
|
48 |
Send a chat-completion request to the LLM, with retries and backoff.
|
49 |
-
|
50 |
"""
|
51 |
model = _DEFAULT_MODEL
|
52 |
-
system_prompt = _build_system_prompt()
|
53 |
-
messages = [
|
54 |
-
{"role": "system", "content": system_prompt},
|
55 |
-
{"role": "user", "content": prompt},
|
56 |
-
]
|
57 |
-
|
58 |
backoff = 1
|
59 |
for attempt in range(1, 6):
|
60 |
try:
|
@@ -63,11 +57,12 @@ def generate_llm(
|
|
63 |
resp = client.chat.completions.create(
|
64 |
model=model,
|
65 |
messages=messages,
|
|
|
|
|
66 |
seed=seed,
|
67 |
)
|
68 |
-
text = resp.choices[0].message.content.strip()
|
69 |
logger.debug("LLM response received")
|
70 |
-
return
|
71 |
except Exception as e:
|
72 |
if getattr(e, "status_code", None) == 400:
|
73 |
logger.error("LLM error 400 (Bad Request): Not retrying.")
|
@@ -80,10 +75,54 @@ def generate_llm(
|
|
80 |
logger.critical("LLM failed after 5 attempts, raising")
|
81 |
raise
|
82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
83 |
# Example local test
|
84 |
if __name__ == "__main__":
|
85 |
logger.info("Testing generate_llm() with a sample prompt")
|
86 |
try:
|
87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
88 |
except LLMBadRequestError as e:
|
89 |
logger.warning(f"Test failed with bad request: {e}")
|
|
|
19 |
logger.addHandler(handler)
|
20 |
|
21 |
# --- LLM settings from config.yaml ---
|
22 |
+
_DEFAULT_MODEL = "chutesai/Llama-4-Maverick-17B-128E-Instruct-FP8"
|
23 |
+
_SYSTEM_TEMPLATE = _config.get("system_prompt", "")
|
24 |
+
_CHAR = _config.get("char", "Eve")
|
25 |
+
_CHUTES_API_KEY = os.getenv("CHUTES_API_KEY")
|
26 |
|
27 |
# --- Custom exception ---
|
28 |
class LLMBadRequestError(Exception):
|
|
|
36 |
)
|
37 |
|
38 |
def _build_system_prompt() -> str:
|
39 |
+
"""Substitute {char} into the system prompt template."""
|
|
|
|
|
40 |
return _SYSTEM_TEMPLATE.replace("{char}", _CHAR)
|
41 |
|
42 |
def generate_llm(
|
43 |
+
messages: list,
|
44 |
+
functions: list = None,
|
45 |
+
function_call: dict | str = "auto"
|
46 |
+
) -> dict:
|
47 |
"""
|
48 |
Send a chat-completion request to the LLM, with retries and backoff.
|
49 |
+
Returns the raw response dict, so we can inspect function calls.
|
50 |
"""
|
51 |
model = _DEFAULT_MODEL
|
|
|
|
|
|
|
|
|
|
|
|
|
52 |
backoff = 1
|
53 |
for attempt in range(1, 6):
|
54 |
try:
|
|
|
57 |
resp = client.chat.completions.create(
|
58 |
model=model,
|
59 |
messages=messages,
|
60 |
+
functions=functions or [],
|
61 |
+
function_call=function_call,
|
62 |
seed=seed,
|
63 |
)
|
|
|
64 |
logger.debug("LLM response received")
|
65 |
+
return resp
|
66 |
except Exception as e:
|
67 |
if getattr(e, "status_code", None) == 400:
|
68 |
logger.error("LLM error 400 (Bad Request): Not retrying.")
|
|
|
75 |
logger.critical("LLM failed after 5 attempts, raising")
|
76 |
raise
|
77 |
|
78 |
+
def call_llm_function(user_input: str, history: list, functions: list):
|
79 |
+
"""
|
80 |
+
Orchestrate a function call. Returns either:
|
81 |
+
- {"name": fn_name, "arguments": {...}} if a function call is requested
|
82 |
+
- {"content": "..." } for a normal chat reply
|
83 |
+
"""
|
84 |
+
system_prompt = _build_system_prompt()
|
85 |
+
messages = [{"role": "system", "content": system_prompt}] + history + [
|
86 |
+
{"role": "user", "content": user_input}
|
87 |
+
]
|
88 |
+
|
89 |
+
response = generate_llm(
|
90 |
+
messages=messages,
|
91 |
+
functions=functions,
|
92 |
+
function_call="auto"
|
93 |
+
)
|
94 |
+
|
95 |
+
choice = response.choices[0]
|
96 |
+
msg = choice.message
|
97 |
+
|
98 |
+
if msg.function_call:
|
99 |
+
return {
|
100 |
+
"name": msg.function_call.name,
|
101 |
+
"arguments": msg.function_call.arguments
|
102 |
+
}
|
103 |
+
else:
|
104 |
+
return {"content": msg.content.strip()}
|
105 |
+
|
106 |
# Example local test
|
107 |
if __name__ == "__main__":
|
108 |
logger.info("Testing generate_llm() with a sample prompt")
|
109 |
try:
|
110 |
+
res = call_llm_function("Generate 2 images of a sunset", [], [
|
111 |
+
{
|
112 |
+
"name": "generate_image",
|
113 |
+
"description": "Generate images",
|
114 |
+
"parameters": {
|
115 |
+
"type": "object",
|
116 |
+
"properties": {
|
117 |
+
"prompt": {"type": "string"},
|
118 |
+
"count": {"type": "integer"},
|
119 |
+
"width": {"type": "integer"},
|
120 |
+
"height": {"type": "integer"}
|
121 |
+
},
|
122 |
+
"required": ["prompt"]
|
123 |
+
}
|
124 |
+
}
|
125 |
+
])
|
126 |
+
print(res)
|
127 |
except LLMBadRequestError as e:
|
128 |
logger.warning(f"Test failed with bad request: {e}")
|