Skip to content

feat: replace faqqer with multi-channel AI help bot (AnythingLLM + Discord + Telegram)#3

Open
0xPepeSilvia wants to merge 7 commits into
tari-project:mainfrom
0xPepeSilvia:feat/ai-help-bot-v2
Open

feat: replace faqqer with multi-channel AI help bot (AnythingLLM + Discord + Telegram)#3
0xPepeSilvia wants to merge 7 commits into
tari-project:mainfrom
0xPepeSilvia:feat/ai-help-bot-v2

Conversation

@0xPepeSilvia
Copy link
Copy Markdown

@0xPepeSilvia 0xPepeSilvia commented Apr 14, 2026

Closes #1

Summary

This PR replaces the single-file faqqer_bot.py (direct OpenAI completions) with a production-ready multi-channel AI help bot backed by AnythingLLM's RAG pipeline. Telegram and Discord Q&A bots are deployed alongside the existing blockchain stats and customer-analysis scheduled jobs, all orchestrated via Docker Compose.

Acceptance criteria

# Criterion Status File(s)
1 Framework selected and documented docs/ARCHITECTURE.md
2 Responds on Telegram grounded in Tari KB bridge/telegram_bot.pybridge/rag_client.py
3 Responds on Discord grounded in Tari KB bridge/discord_bot.pybridge/rag_client.py
4 KB updatable without code changes bridge/init_kb.py, volume mount in docker-compose.yml
5 Learns from feedback bridge/feedback.py + promote_to_kb() in rag_client.py
6 Preserves blockchain_job + customer_analysis_job bridge/jobs.py wraps both via importlib (no edits to originals)
7 Dockerized docker-compose.yml + bridge/Dockerfile

Framework rationale — why AnythingLLM

  • Admin UI: non-technical admins can manage documents, inspect conversations and rotate models at :3001 without touching code
  • Model-agnostic: switch from OpenAI to Anthropic/Ollama/Azure by changing one env var; no code changes
  • Stable REST API: /api/v1/workspace/{slug}/chat is versioned and thin — the whole RAG client is ~194 lines
  • Docker-native: official mintplexlabs/anythingllm image; LanceDB vector store is built-in, no extra infra
  • KB hot-reload: drop files in the volume-mounted faqs/ dir and restart bridge; manifest prevents duplicates

Architecture

                ┌──────────────────────────────────────────┐
                │           docker-compose stack            │
                │                                           │
 Telegram ──────►                              ┌──────────┐ │
                │   bridge worker  ◄─ REST ───► AnythingLLM│ │
 Discord  ──────►   (async tasks)              │ + LanceDB │ │
                │        │                     └──────────┘ │
                │   APScheduler                      ▲       │
                │   (blockchain + analysis jobs)     │       │
                │        │                    faqs/ volume   │
                │   Telethon                  (hot-reload)   │
                └──────────────────────────────────────────┘

Quick deploy

cp .env.example .env          # fill OPENAI_API_KEY, bot tokens
docker compose up -d anythingllm
# visit http://localhost:3001 → Settings → API Keys → generate key
# add key to .env as ANYTHINGLLM_API_KEY
docker compose up -d bridge
docker compose logs -f bridge  # confirm startup

Test results

Test 1 — syntax + import:

python -m py_compile bridge/config.py bridge/rag_client.py bridge/telegram_bot.py \
  bridge/discord_bot.py bridge/feedback.py bridge/jobs.py bridge/init_kb.py bridge/main.py
# → PASS (no output)

Test 2 — docker compose config:
Docker not available in the CI environment; compose YAML was manually validated: correct version, all service keys present, volumes/networks defined, env_file + environment blocks syntactically valid, no port conflicts.

Test 3 — adversarial review + fixes applied:

Scenario Finding Fix
ANYTHINGLLM_API_KEY missing config.py raises RuntimeError with clear message; main.py catches it, logs and exits with code 2 Already correct
AnythingLLM unreachable rag_client.wait_until_ready() polls with exponential back-off up to 5 min; main.py exits with code 3 if never reachable Already correct
Discord message > 2000 chars No truncation — raw answer passed to .reply() / .followup.send() Fixed: added _truncate() helper in discord_bot.py applied at all send sites
Feedback on message bot didn't send feedback.get() returns None; all approve handlers check for None and reply gracefully Already correct; also added try/except around _record_pair in on_message path
Missing deps in requirements.txt SQLAlchemy (required by APScheduler.SQLAlchemyJobStore) and openai (required by customer_analysis_job) were absent Added SQLAlchemy==2.0.36 and openai==1.54.4

🤖 Generated with Claude Code


Bounty payout

If this PR is accepted as a bounty submission, please direct funds to the following Tari wallet address:

123JbAxCZ4Vrh4jCjFLXTu4z8sLeggUR87fejwNunsB55NMBSH9RpkjNiw1WL2GkKr8JhqRZhbrsSREchGanchw2Nhb

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request upgrades the Tari AI Help Bot to version 2, implementing a Retrieval-Augmented Generation (RAG) pipeline powered by AnythingLLM. The new architecture features a multi-channel bridge worker supporting Telegram and Discord, automated knowledge base seeding from local files, and a feedback system for admin-approved learning. It also integrates legacy scheduled jobs using APScheduler. Review feedback identifies several improvement opportunities: optimizing performance by moving the HTTP client instantiation outside of retry loops, enhancing Discord bot reliability by utilizing member data directly from event payloads, and preventing potential API errors in the Telegram bot by implementing message length truncation.

Comment thread bridge/rag_client.py Outdated
Comment on lines +123 to +141
async for attempt in AsyncRetrying(
stop=stop_after_attempt(3),
wait=wait_exponential(min=1, max=8),
retry=retry_if_exception_type((httpx.HTTPError,)),
reraise=True,
):
with attempt:
async with httpx.AsyncClient(
timeout=self._timeout, headers=self._headers
) as client:
resp = await client.post(url, json=payload)
if resp.status_code >= 500:
raise httpx.HTTPError(f"upstream {resp.status_code}")
if resp.status_code >= 400:
raise RAGError(
f"AnythingLLM rejected the request: "
f"{resp.status_code} {resp.text[:200]}"
)
data = resp.json()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The httpx.AsyncClient is being instantiated inside the retry loop. This is inefficient as it creates a new client and connection pool for every retry attempt. It should be moved outside the AsyncRetrying block to properly leverage connection pooling across retries.

References
  1. Reuse client sessions to benefit from connection pooling and improve performance.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 99fab49: httpx.AsyncClient is now instantiated once before the AsyncRetrying loop and reused across retry attempts.

Comment thread bridge/discord_bot.py Outdated
Comment on lines +224 to +227
guild = bot.get_guild(payload.guild_id) if payload.guild_id else None
member = guild.get_member(payload.user_id) if guild else None
if not self._is_admin(member):
return
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In on_raw_reaction_add, the payload object already contains the member object for guild events. Using payload.member is more reliable than guild.get_member, especially since the members intent is not enabled, which would likely result in cache misses for users not already known to the bot.

References
  1. Use available event payload data to avoid unnecessary API calls or cache lookups.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 99fab49: on_raw_reaction_add now reads payload.member first (populated directly by the Discord gateway event) and only falls back to guild.get_member() when it's None.

Comment thread bridge/telegram_bot.py Outdated
Comment on lines +150 to +151
answer = await self._answer(question)
sent = await msg.reply_text(answer)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Telegram has a message length limit of 4096 characters. If the AI response exceeds this limit, the bot will fail to send the reply. Truncation should be applied to ensure the message is within Telegram's limits, similar to the implementation in the Discord bot.

References
  1. Handle platform-specific message length limits to prevent API errors.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 99fab49: added a _truncate(text, limit=4096) helper; _handle_question now sends _truncate(answer) to stay within Telegram's 4096-character hard limit.

- rag_client: move httpx.AsyncClient instantiation outside the tenacity
  retry loop so the connection pool is reused across attempts rather
  than torn down and rebuilt on every retry
- discord_bot: prefer payload.member (populated directly by the gateway
  event) over a cache lookup in on_raw_reaction_add; fall back to
  guild.get_member() only when payload.member is None
- telegram_bot: add _truncate() helper (4096-char Telegram API limit)
  and apply it in _handle_question() to prevent MessageTooLong errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@0xPepeSilvia
Copy link
Copy Markdown
Author

All three points from the gemini review have been addressed in 99fab49:

1. HTTP client instantiation outside retry loop (rag_client.py)
httpx.AsyncClient is now created once before the AsyncRetrying loop — the connection pool is reused across retry attempts rather than torn down and rebuilt on every attempt.

2. Discord member from event payload (discord_bot.py)
on_raw_reaction_add now reads payload.member first (populated directly by the Discord gateway event) and only falls back to guild.get_member() when payload.member is None. This avoids false negatives from an incomplete member cache.

3. Telegram message length truncation (telegram_bot.py)
Added a _truncate(text, limit=4096) helper matching the pattern already in the Discord bot. _handle_question now sends _truncate(answer) to stay within Telegram's Bot API hard limit and prevent MessageTooLong errors.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Upgrade faqqer

1 participant