Skip to content

Commit ddf4653

Browse files
Add mention handling with LLM-based selection (v1.1.0)
- Add MentionHandler with polling-based mention monitoring - LLM evaluates all pending mentions and picks one to reply to - Structured output: selected_tweet_id, text, include_picture, reasoning - Add get_me() and get_mentions() to Twitter client - Add mention_exists() and get_recent_mentions_formatted() to database - Add MENTIONS_INTERVAL_MINUTES setting (default: 20) - Update README with mention handler documentation
1 parent 5650001 commit ddf4653

File tree

7 files changed

+457
-25
lines changed

7 files changed

+457
-25
lines changed

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ DATABASE_URL=postgresql://user:pass@host:5432/dbname
1414

1515
# Bot configuration
1616
POST_INTERVAL_MINUTES=30
17+
MENTIONS_INTERVAL_MINUTES=20
1718
ENABLE_IMAGE_GENERATION=true

README.md

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -115,12 +115,12 @@ Each layer feeds into the next. Your agent behaves consistently across thousands
115115

116116
The system operates on **two triggers**:
117117

118-
| Scheduled Posts | Reactive Engagement |
119-
|-----------------|---------------------|
120-
| Cron-based (configurable interval) | Webhook-triggered |
118+
| Scheduled Posts | Mention Responses |
119+
|-----------------|-------------------|
120+
| Cron-based (configurable interval) | Polling-based (configurable interval) |
121121
| Generates original content | Handles mentions & replies |
122-
| Creates matching images | LLM decides: respond or ignore |
123-
| Posts to Twitter | Can use tools (web search, etc.) |
122+
| Creates matching images | LLM chooses which mention to reply to |
123+
| Posts to Twitter | LLM generates response + optional image |
124124

125125
This separation keeps the codebase simple while enabling both proactive and reactive behavior.
126126

@@ -253,6 +253,25 @@ The bot automatically generates and posts tweets at configurable intervals using
253253
- `POST_INTERVAL_MINUTES` — Time between auto-posts (default: 30)
254254
- `ENABLE_IMAGE_GENERATION` — Set to `false` to disable all image generation
255255

256+
### Mention Handler (`services/mentions.py`)
257+
258+
Monitors Twitter mentions and generates contextual replies using polling.
259+
260+
**How it works:**
261+
1. Polls Twitter API for new mentions every 20 minutes (configurable)
262+
2. Filters out already-processed mentions using database
263+
3. Sends all new mentions to LLM in a single request
264+
4. LLM selects which mention to reply to (or none if not worth replying)
265+
5. LLM returns structured response: `{selected_tweet_id, text, include_picture, reasoning}`
266+
6. If a mention is selected, generates optional image and posts reply
267+
7. Saves mention interaction to database for history
268+
269+
**Why single-call selection:** Instead of replying to every mention, the LLM evaluates all pending mentions and picks the most interesting one. This creates more authentic engagement — your agent chooses conversations worth having, just like a real person would.
270+
271+
**Configuration:**
272+
- `MENTIONS_INTERVAL_MINUTES` — Time between mention checks (default: 20)
273+
- Requires Twitter API Basic tier or higher for mention access
274+
256275
### Image Generation (`services/image_gen.py`)
257276

258277
Generates images using Gemini 3 Pro via OpenRouter, with support for reference images.
@@ -282,13 +301,15 @@ The more detailed your personality prompt, the more consistent and authentic you
282301

283302
### Database (`services/database.py`)
284303

285-
PostgreSQL storage for post history, enabling context-aware generation.
304+
PostgreSQL storage for post history and mention tracking, enabling context-aware generation.
286305

287306
**Tables:**
288307
- `posts` — Stores all posted tweets (text, tweet_id, include_picture, created_at)
289-
- `mentions` — Stores mention interactions (prepared for future mention handling)
308+
- `mentions` — Stores mention interactions (tweet_id, author_handle, author_text, our_reply, action)
290309

291-
**Why it matters:** By storing post history, the bot can reference its previous tweets and avoid repetition. The LLM sees the last 50 posts as context when generating new content.
310+
**Why it matters:**
311+
- Post history lets the bot reference previous tweets and avoid repetition. The LLM sees the last 50 posts as context.
312+
- Mention history prevents double-replying and provides conversation context for future interactions.
292313

293314
### LLM Client (`services/llm.py`)
294315

@@ -306,7 +327,9 @@ Handles all Twitter API interactions using tweepy.
306327
**Capabilities:**
307328
- Post tweets (API v2)
308329
- Upload media (API v1.1 — required for images)
309-
- Reply to tweets (prepared for mention handling)
330+
- Reply to tweets
331+
- Fetch mentions (polling-based)
332+
- Get authenticated user info
310333
- Automatic rate limit handling
311334

312335
---

config/settings.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@ class Settings(BaseSettings):
3030
database_url: str
3131

3232
# Bot configuration
33-
post_interval_minutes: int
34-
enable_image_generation: bool
33+
post_interval_minutes: int = 30
34+
mentions_interval_minutes: int = 20
35+
enable_image_generation: bool = True
3536

3637

3738
# Global settings instance

main.py

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
"""
22
Twitter Bot with Auto-posting and Mention Handling.
33
4-
FastAPI application with APScheduler for scheduled posts.
4+
FastAPI application with APScheduler for scheduled posts and mention responses.
55
"""
66

77
import logging
88
from contextlib import asynccontextmanager
99

10-
from fastapi import FastAPI, HTTPException, Request
10+
from fastapi import FastAPI, HTTPException
1111
from apscheduler.schedulers.asyncio import AsyncIOScheduler
1212

1313
from config.settings import settings
1414
from services.database import Database
1515
from services.autopost import AutoPostService
16+
from services.mentions import MentionHandler
1617

1718
# Configure logging
1819
logging.basicConfig(
@@ -25,12 +26,13 @@
2526
db = Database()
2627
scheduler = AsyncIOScheduler()
2728
autopost_service: AutoPostService | None = None
29+
mention_handler: MentionHandler | None = None
2830

2931

3032
@asynccontextmanager
3133
async def lifespan(app: FastAPI):
3234
"""Manage application startup and shutdown."""
33-
global autopost_service
35+
global autopost_service, mention_handler
3436

3537
# Startup
3638
logger.info("Starting application...")
@@ -41,6 +43,7 @@ async def lifespan(app: FastAPI):
4143

4244
# Initialize services
4345
autopost_service = AutoPostService(db)
46+
mention_handler = MentionHandler(db)
4447

4548
# Schedule autopost job
4649
scheduler.add_job(
@@ -50,8 +53,18 @@ async def lifespan(app: FastAPI):
5053
id="autopost_job",
5154
replace_existing=True
5255
)
56+
57+
# Schedule mentions job (every 20 minutes)
58+
scheduler.add_job(
59+
run_mentions_job,
60+
"interval",
61+
minutes=settings.mentions_interval_minutes,
62+
id="mentions_job",
63+
replace_existing=True
64+
)
65+
5366
scheduler.start()
54-
logger.info(f"Scheduler started, posting every {settings.post_interval_minutes} minutes")
67+
logger.info(f"Scheduler started: autopost every {settings.post_interval_minutes} min, mentions every {settings.mentions_interval_minutes} min")
5568

5669
yield
5770

@@ -62,18 +75,36 @@ async def lifespan(app: FastAPI):
6275
logger.info("Application shutdown complete")
6376

6477

78+
async def run_mentions_job():
79+
"""Scheduled job to process mentions."""
80+
if mention_handler is None:
81+
logger.warning("Mention handler not initialized")
82+
return
83+
84+
try:
85+
logger.info("Running scheduled mentions check...")
86+
result = await mention_handler.process_mentions_batch()
87+
logger.info(f"Mentions job result: {result}")
88+
except Exception as e:
89+
logger.error(f"Error in mentions job: {e}")
90+
91+
6592
app = FastAPI(
6693
title="Twitter Bot",
6794
description="Auto-posting Twitter bot with mention handling",
68-
version="1.0.0",
95+
version="1.1.0",
6996
lifespan=lifespan
7097
)
7198

7299

73100
@app.get("/health")
74101
async def health_check():
75102
"""Health check endpoint."""
76-
return {"status": "healthy", "scheduler_running": scheduler.running}
103+
return {
104+
"status": "healthy",
105+
"scheduler_running": scheduler.running,
106+
"version": "1.1.0"
107+
}
77108

78109

79110
@app.get("/callback")
@@ -82,10 +113,7 @@ async def oauth_callback(oauth_token: str = None, oauth_verifier: str = None):
82113
OAuth callback endpoint for Twitter authentication.
83114
84115
Required for Twitter API Read+Write access.
85-
This is called after user authorizes the app.
86116
"""
87-
# For a bot that uses its own credentials, this just needs to exist
88-
# The actual auth is done via Access Token in .env
89117
return {
90118
"status": "ok",
91119
"message": "OAuth callback received",
@@ -96,7 +124,7 @@ async def oauth_callback(oauth_token: str = None, oauth_verifier: str = None):
96124

97125
@app.post("/trigger-post")
98126
async def trigger_post():
99-
"""Manually trigger an autopost (for testing)."""
127+
"""Manually trigger an autopost."""
100128
if autopost_service is None:
101129
raise HTTPException(status_code=503, detail="Service not initialized")
102130

@@ -108,6 +136,20 @@ async def trigger_post():
108136
raise HTTPException(status_code=500, detail=str(e))
109137

110138

139+
@app.post("/trigger-mentions")
140+
async def trigger_mentions():
141+
"""Manually trigger mentions processing."""
142+
if mention_handler is None:
143+
raise HTTPException(status_code=503, detail="Service not initialized")
144+
145+
try:
146+
result = await mention_handler.process_mentions_batch()
147+
return result
148+
except Exception as e:
149+
logger.error(f"Error processing mentions: {e}")
150+
raise HTTPException(status_code=500, detail=str(e))
151+
152+
111153
if __name__ == "__main__":
112154
import uvicorn
113155
uvicorn.run(app, host="0.0.0.0", port=8080)

services/database.py

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,11 @@ async def connect(self) -> None:
5656
END $$;
5757
""")
5858

59-
# Mentions table (for future use)
59+
# Mentions table
6060
await conn.execute("""
6161
CREATE TABLE IF NOT EXISTS mentions (
6262
id SERIAL PRIMARY KEY,
63-
tweet_id VARCHAR(50),
63+
tweet_id VARCHAR(50) UNIQUE,
6464
author_handle VARCHAR(50),
6565
author_text TEXT,
6666
our_reply TEXT,
@@ -171,7 +171,7 @@ async def save_mention(
171171
author_handle: Twitter handle of the author.
172172
author_text: Text of the mention.
173173
our_reply: Our reply text (None if ignored).
174-
action: Action taken ('replied', 'ignored', 'tool_used').
174+
action: Action taken ('replied', 'ignored').
175175
176176
Returns:
177177
Database ID of the saved mention.
@@ -194,3 +194,58 @@ async def save_mention(
194194
)
195195
logger.info(f"Saved mention {row['id']} with action '{action}'")
196196
return row["id"]
197+
198+
async def get_recent_mentions_formatted(self, limit: int = 10) -> str:
199+
"""
200+
Get recent mentions formatted for LLM context.
201+
202+
Args:
203+
limit: Maximum number of mentions to retrieve.
204+
205+
Returns:
206+
Formatted string with recent mentions and replies.
207+
"""
208+
if not self.pool:
209+
raise RuntimeError("Database not connected")
210+
211+
async with self.pool.acquire() as conn:
212+
rows = await conn.fetch(
213+
"""
214+
SELECT author_handle, author_text, our_reply
215+
FROM mentions
216+
WHERE our_reply IS NOT NULL
217+
ORDER BY created_at DESC
218+
LIMIT $1
219+
""",
220+
limit
221+
)
222+
223+
if not rows:
224+
return "No previous mention replies."
225+
226+
history = []
227+
for i, row in enumerate(reversed(rows), 1): # Oldest first
228+
history.append(f"{i}. @{row['author_handle']}: {row['author_text']}")
229+
history.append(f" Your reply: {row['our_reply']}")
230+
231+
return "\n".join(history)
232+
233+
async def mention_exists(self, tweet_id: str) -> bool:
234+
"""
235+
Check if a mention has already been processed.
236+
237+
Args:
238+
tweet_id: Tweet ID to check.
239+
240+
Returns:
241+
True if mention exists in database.
242+
"""
243+
if not self.pool:
244+
raise RuntimeError("Database not connected")
245+
246+
async with self.pool.acquire() as conn:
247+
row = await conn.fetchrow(
248+
"SELECT 1 FROM mentions WHERE tweet_id = $1",
249+
tweet_id
250+
)
251+
return row is not None

0 commit comments

Comments
 (0)