feat: Add manual trigger bypass for web UI autonomous engagement

- Added manual_trigger parameter to /autonomous/engage endpoint to bypass 12h cooldown
- Updated miku_engage_random_user_for_server() and miku_engage_random_user() to accept manual_trigger flag
- Modified Web UI to always send manual_trigger=true when engaging users from the UI
- Users can now manually engage the same user multiple times from web UI without cooldown restriction
- Regular autonomous schedules still respect the 12h cooldown between engagements to the same user

Changes:
- bot/api.py: Added manual_trigger parameter with string-to-boolean conversion
- bot/static/index.html: Added manual_trigger=true to engage user request
- bot/utils/autonomous_v1_legacy.py: Added manual_trigger parameter and cooldown bypass logic
This commit is contained in:
2026-02-20 00:53:42 +02:00
parent 9972edb06d
commit 2f0d430c35
3 changed files with 277 additions and 30 deletions

View File

@@ -869,16 +869,25 @@ async def trigger_autonomous_general(guild_id: int = None):
return {"status": "error", "message": "Bot not ready"} return {"status": "error", "message": "Bot not ready"}
@app.post("/autonomous/engage") @app.post("/autonomous/engage")
async def trigger_autonomous_engage_user(guild_id: int = None, user_id: str = None, engagement_type: str = None): async def trigger_autonomous_engage_user(
guild_id: int = None,
user_id: str = None,
engagement_type: str = None,
manual_trigger: str = "false"
):
# If guild_id is provided, send autonomous engagement only to that server # If guild_id is provided, send autonomous engagement only to that server
# If no guild_id, send to all servers (legacy behavior) # If no guild_id, send to all servers (legacy behavior)
# user_id: Optional specific user to engage (Discord user ID as string) # user_id: Optional specific user to engage (Discord user ID as string)
# engagement_type: Optional type - 'activity', 'general', 'status', or None for random # engagement_type: Optional type - 'activity', 'general', 'status', or None for random
# manual_trigger: If True (as string), bypass the "recently engaged" check (for web UI manual triggers)
# Convert manual_trigger string to boolean
manual_trigger_bool = manual_trigger.lower() in ('true', '1', 'yes')
if globals.client and globals.client.loop and globals.client.loop.is_running(): if globals.client and globals.client.loop and globals.client.loop.is_running():
if guild_id is not None: if guild_id is not None:
# Send to specific server only # Send to specific server only
from utils.autonomous import miku_engage_random_user_for_server from utils.autonomous import miku_engage_random_user_for_server
globals.client.loop.create_task(miku_engage_random_user_for_server(guild_id, user_id=user_id, engagement_type=engagement_type)) globals.client.loop.create_task(miku_engage_random_user_for_server(guild_id, user_id=user_id, engagement_type=engagement_type, manual_trigger=manual_trigger_bool))
# Build detailed message # Build detailed message
msg_parts = [f"Autonomous user engagement queued for server {guild_id}"] msg_parts = [f"Autonomous user engagement queued for server {guild_id}"]
@@ -886,38 +895,49 @@ async def trigger_autonomous_engage_user(guild_id: int = None, user_id: str = No
msg_parts.append(f"targeting user {user_id}") msg_parts.append(f"targeting user {user_id}")
if engagement_type: if engagement_type:
msg_parts.append(f"with {engagement_type} engagement") msg_parts.append(f"with {engagement_type} engagement")
if manual_trigger_bool:
msg_parts.append("(manual trigger - bypassing cooldown)")
return {"status": "ok", "message": " ".join(msg_parts)} return {"status": "ok", "message": " ".join(msg_parts)}
else: else:
# Send to all servers (legacy behavior) # Send to all servers (legacy behavior)
from utils.autonomous import miku_engage_random_user from utils.autonomous import miku_engage_random_user
globals.client.loop.create_task(miku_engage_random_user(user_id=user_id, engagement_type=engagement_type)) globals.client.loop.create_task(miku_engage_random_user(user_id=user_id, engagement_type=engagement_type, manual_trigger=manual_trigger_bool))
msg_parts = ["Autonomous user engagement queued for all servers"] msg_parts = ["Autonomous user engagement queued for all servers"]
if user_id: if user_id:
msg_parts.append(f"targeting user {user_id}") msg_parts.append(f"targeting user {user_id}")
if engagement_type: if engagement_type:
msg_parts.append(f"with {engagement_type} engagement") msg_parts.append(f"with {engagement_type} engagement")
if manual_trigger_bool:
msg_parts.append("(manual trigger - bypassing cooldown)")
return {"status": "ok", "message": " ".join(msg_parts)} return {"status": "ok", "message": " ".join(msg_parts)}
else: else:
return {"status": "error", "message": "Bot not ready"} return {"status": "error", "message": "Bot not ready"}
@app.post("/autonomous/tweet") @app.post("/autonomous/tweet")
async def trigger_autonomous_tweet(guild_id: int = None): async def trigger_autonomous_tweet(guild_id: int = None, tweet_url: str = None):
# If guild_id is provided, send tweet only to that server # If guild_id is provided, send tweet only to that server
# If no guild_id, send to all servers (legacy behavior) # If no guild_id, send to all servers (legacy behavior)
# If tweet_url is provided, share that specific tweet; otherwise fetch one
if globals.client and globals.client.loop and globals.client.loop.is_running(): if globals.client and globals.client.loop and globals.client.loop.is_running():
if guild_id is not None: if guild_id is not None:
# Send to specific server only # Send to specific server only
from utils.autonomous import share_miku_tweet_for_server from utils.autonomous import share_miku_tweet_for_server
globals.client.loop.create_task(share_miku_tweet_for_server(guild_id)) globals.client.loop.create_task(share_miku_tweet_for_server(guild_id, tweet_url=tweet_url))
return {"status": "ok", "message": f"Autonomous tweet sharing queued for server {guild_id}"} msg = f"Autonomous tweet sharing queued for server {guild_id}"
if tweet_url:
msg += f" with URL {tweet_url}"
return {"status": "ok", "message": msg}
else: else:
# Send to all servers (legacy behavior) # Send to all servers (legacy behavior)
from utils.autonomous import share_miku_tweet from utils.autonomous import share_miku_tweet
globals.client.loop.create_task(share_miku_tweet()) globals.client.loop.create_task(share_miku_tweet(tweet_url=tweet_url))
return {"status": "ok", "message": "Autonomous tweet sharing queued for all servers"} msg = "Autonomous tweet sharing queued for all servers"
if tweet_url:
msg += f" with URL {tweet_url}"
return {"status": "ok", "message": msg}
else: else:
return {"status": "error", "message": "Bot not ready"} return {"status": "error", "message": "Bot not ready"}
@@ -1538,11 +1558,26 @@ async def trigger_autonomous_general_for_server(guild_id: int):
return {"status": "error", "message": f"Failed to trigger autonomous message: {e}"} return {"status": "error", "message": f"Failed to trigger autonomous message: {e}"}
@app.post("/servers/{guild_id}/autonomous/engage") @app.post("/servers/{guild_id}/autonomous/engage")
async def trigger_autonomous_engage_for_server(guild_id: int, user_id: str = None, engagement_type: str = None): async def trigger_autonomous_engage_for_server(
"""Trigger autonomous user engagement for a specific server""" guild_id: int,
user_id: str = None,
engagement_type: str = None,
manual_trigger: str = "false"
):
"""Trigger autonomous user engagement for a specific server
Args:
guild_id: The server ID to engage in
user_id: Optional specific user to engage (Discord user ID as string)
engagement_type: Optional type - 'activity', 'general', 'status', or None for random
manual_trigger: If True (as string), bypass the "recently engaged" check (for web UI manual triggers)
"""
# Convert manual_trigger string to boolean
manual_trigger_bool = manual_trigger.lower() in ('true', '1', 'yes')
from utils.autonomous import miku_engage_random_user_for_server from utils.autonomous import miku_engage_random_user_for_server
try: try:
await miku_engage_random_user_for_server(guild_id, user_id=user_id, engagement_type=engagement_type) await miku_engage_random_user_for_server(guild_id, user_id=user_id, engagement_type=engagement_type, manual_trigger=manual_trigger_bool)
# Build detailed message # Build detailed message
msg_parts = [f"Autonomous user engagement triggered for server {guild_id}"] msg_parts = [f"Autonomous user engagement triggered for server {guild_id}"]
@@ -1550,6 +1585,8 @@ async def trigger_autonomous_engage_for_server(guild_id: int, user_id: str = Non
msg_parts.append(f"targeting user {user_id}") msg_parts.append(f"targeting user {user_id}")
if engagement_type: if engagement_type:
msg_parts.append(f"with {engagement_type} engagement") msg_parts.append(f"with {engagement_type} engagement")
if manual_trigger_bool:
msg_parts.append("(manual trigger - bypassing cooldown)")
return {"status": "ok", "message": " ".join(msg_parts)} return {"status": "ok", "message": " ".join(msg_parts)}
except Exception as e: except Exception as e:

View File

@@ -807,7 +807,17 @@
</div> </div>
</div> </div>
<button onclick="triggerAutonomous('tweet')">Share Tweet</button> <!-- Share Tweet Submenu -->
<div style="margin-bottom: 1rem;">
<button onclick="toggleTweetSubmenu()">Share Tweet ▼</button>
<div id="tweet-submenu" style="display: none; margin-left: 1rem; margin-top: 0.5rem; padding: 1rem; background: #1e1e1e; border: 1px solid #444; border-radius: 4px;">
<div style="margin-bottom: 0.5rem;">
<label for="tweet-url" style="display: block; margin-bottom: 0.3rem;">Tweet URL (leave empty for auto-fetch):</label>
<input type="text" id="tweet-url" placeholder="https://x.com/... or https://twitter.com/... or https://fxtwitter.com/..." style="width: 100%;">
</div>
<button onclick="triggerShareTweet()">🐦 Share Tweet</button>
</div>
</div>
<button onclick="triggerAutonomous('reaction')">React to Message</button> <button onclick="triggerAutonomous('reaction')">React to Message</button>
<button onclick="triggerAutonomous('join-conversation')">Detect and Join Conversation</button> <button onclick="triggerAutonomous('join-conversation')">Detect and Join Conversation</button>
<button onclick="toggleCustomPrompt()">Custom Prompt</button> <button onclick="toggleCustomPrompt()">Custom Prompt</button>
@@ -3051,6 +3061,9 @@ async function triggerEngageUser() {
params.append('engagement_type', engageType); params.append('engagement_type', engageType);
} }
// Add manual_trigger flag to bypass cooldown checks
params.append('manual_trigger', 'true');
if (params.toString()) { if (params.toString()) {
endpoint += `?${params.toString()}`; endpoint += `?${params.toString()}`;
} }
@@ -3075,6 +3088,76 @@ async function triggerEngageUser() {
} }
} }
// Toggle Share Tweet Submenu
function toggleTweetSubmenu() {
const submenu = document.getElementById('tweet-submenu');
if (submenu.style.display === 'none') {
submenu.style.display = 'block';
} else {
submenu.style.display = 'none';
}
}
// Trigger Share Tweet with optional URL
async function triggerShareTweet() {
const selectedServer = document.getElementById('server-select').value;
const tweetUrl = document.getElementById('tweet-url').value.trim();
// Validate URL if provided
if (tweetUrl) {
const validDomains = ['x.com', 'twitter.com', 'fxtwitter.com'];
let isValid = false;
try {
const urlObj = new URL(tweetUrl);
const hostname = urlObj.hostname.toLowerCase();
isValid = validDomains.some(domain => hostname === domain || hostname.endsWith('.' + domain));
} catch (e) {
// Invalid URL format
}
if (!isValid) {
showNotification('Invalid tweet URL. Must be from x.com, twitter.com, or fxtwitter.com', 'error');
return;
}
}
try {
let endpoint = '/autonomous/tweet';
const params = new URLSearchParams();
// Add guild_id if a specific server is selected
if (selectedServer !== 'all') {
params.append('guild_id', selectedServer);
}
// Add tweet_url if specified
if (tweetUrl) {
params.append('tweet_url', tweetUrl);
}
if (params.toString()) {
endpoint += `?${params.toString()}`;
}
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const result = await response.json();
if (response.ok) {
showNotification(result.message || 'Tweet share triggered successfully');
} else {
throw new Error(result.message || 'Failed to trigger tweet share');
}
} catch (error) {
console.error('Failed to trigger tweet share:', error);
showNotification(error.message || 'Failed to trigger tweet share', 'error');
}
}
// Profile Picture Management // Profile Picture Management
async function changeProfilePicture() { async function changeProfilePicture() {
const selectedServer = document.getElementById('server-select').value; const selectedServer = document.getElementById('server-select').value;

View File

@@ -4,6 +4,7 @@ import random
import time import time
import json import json
import os import os
import re
from datetime import datetime from datetime import datetime
import discord import discord
from discord import Status from discord import Status
@@ -27,6 +28,107 @@ from utils.logger import get_logger
logger = get_logger('autonomous') logger = get_logger('autonomous')
async def fetch_tweet_by_url(tweet_url: str):
"""Fetch a specific tweet by its URL using twscrape.
Args:
tweet_url: URL of the tweet to fetch (x.com, twitter.com, or fxtwitter.com)
Returns:
Dictionary with tweet data or None if fetch fails
"""
try:
# Extract tweet ID from URL
# Handle various URL formats:
# https://twitter.com/username/status/1234567890
# https://x.com/username/status/1234567890
# https://fxtwitter.com/username/status/1234567890
match = re.search(r'/status/(\d+)', tweet_url)
if not match:
logger.error(f"Could not extract tweet ID from URL: {tweet_url}")
return None
tweet_id = int(match.group(1))
from twscrape import API
# Load cookies from JSON file
from pathlib import Path
COOKIE_PATH = Path(__file__).parent / "x.com.cookies.json"
if not COOKIE_PATH.exists():
logger.error(f"Cookie file not found: {COOKIE_PATH}")
return None
import json
with open(COOKIE_PATH, "r", encoding="utf-8") as f:
cookie_list = json.load(f)
cookie_header = "; ".join(f"{c['name']}={c['value']}" for c in cookie_list)
api = API()
await api.pool.add_account(
username="HSankyuu39",
password="x",
email="x",
email_password="x",
cookies=cookie_header
)
await api.pool.login_all()
# Fetch the specific tweet using search (same approach as figurine_notifier.py)
from twscrape import gather
logger.debug(f"Searching for tweet with ID {tweet_id}")
search_results = await gather(api.search(f"{tweet_id}", limit=1))
logger.debug(f"Search returned {len(search_results)} results")
# Check if we found the tweet
tweet = None
for search_tweet in search_results:
if str(search_tweet.id) == str(tweet_id):
tweet = search_tweet
logger.debug(f"Found matching tweet with ID {tweet.id}")
break
if not tweet and search_results:
# If no exact match but we have results, use the first one
tweet = search_results[0]
logger.debug(f"Using first search result with ID {tweet.id}")
if not tweet:
logger.error(f"Failed to fetch tweet ID {tweet_id}")
return None
# Extract media URLs if present
media_urls = []
if hasattr(tweet, 'media') and tweet.media:
if hasattr(tweet.media, 'photos'):
for photo in tweet.media.photos:
if hasattr(photo, 'url'):
media_url = photo.url
if '?' in media_url:
media_url = media_url.split('?')[0]
media_url += '?name=large'
media_urls.append(media_url)
# Extract username and build URL
username = tweet.user.username
tweet_url = f"https://twitter.com/{username}/status/{tweet.id}"
result = {
"username": username,
"text": tweet.rawContent if hasattr(tweet, 'rawContent') else "",
"url": tweet_url,
"media": media_urls if media_urls else []
}
logger.info(f"Successfully fetched tweet {tweet_id} from @{username}")
return result
except Exception as e:
logger.error(f"Error fetching tweet by URL {tweet_url}: {e}")
return None
# Server-specific memory storage # Server-specific memory storage
_server_autonomous_messages = {} # guild_id -> rotating buffer of last general messages _server_autonomous_messages = {} # guild_id -> rotating buffer of last general messages
_server_user_engagements = {} # guild_id -> user_id -> timestamp _server_user_engagements = {} # guild_id -> user_id -> timestamp
@@ -138,13 +240,14 @@ async def miku_say_something_general_for_server(guild_id: int):
except Exception as e: except Exception as e:
logger.error(f"Failed to send autonomous message: {e}") logger.error(f"Failed to send autonomous message: {e}")
async def miku_engage_random_user_for_server(guild_id: int, user_id: str = None, engagement_type: str = None): async def miku_engage_random_user_for_server(guild_id: int, user_id: str = None, engagement_type: str = None, manual_trigger: bool = False):
"""Miku engages a random user in a specific server """Miku engages a random user in a specific server
Args: Args:
guild_id: The server ID guild_id: The server ID
user_id: Optional specific user ID to engage (as string). If None, picks random user user_id: Optional specific user ID to engage (as string). If None, picks random user
engagement_type: Optional engagement style - 'activity', 'general', 'status', or None for auto-detect engagement_type: Optional engagement style - 'activity', 'general', 'status', or None for auto-detect
manual_trigger: If True, bypass cooldown checks (for web UI manual triggers)
""" """
server_config = server_manager.get_server_config(guild_id) server_config = server_manager.get_server_config(guild_id)
if not server_config: if not server_config:
@@ -198,11 +301,16 @@ async def miku_engage_random_user_for_server(guild_id: int, user_id: str = None,
now = time.time() now = time.time()
last_time = _server_user_engagements[guild_id].get(target.id, 0) last_time = _server_user_engagements[guild_id].get(target.id, 0)
if now - last_time < 43200: # 12 hours in seconds
# Skip cooldown check if this is a manual trigger from web UI
if not manual_trigger and now - last_time < 43200: # 12 hours in seconds
logger.info(f"Recently engaged {target.display_name} in server {guild_id}, switching to general message.") logger.info(f"Recently engaged {target.display_name} in server {guild_id}, switching to general message.")
await miku_say_something_general_for_server(guild_id) await miku_say_something_general_for_server(guild_id)
return return
if manual_trigger:
logger.info(f"Manual trigger - bypassing cooldown for {target.display_name} in server {guild_id}")
activity_name = None activity_name = None
if target.activities: if target.activities:
for a in target.activities: for a in target.activities:
@@ -393,14 +501,28 @@ async def miku_detect_and_join_conversation_for_server(guild_id: int, force: boo
except Exception as e: except Exception as e:
logger.error(f"Failed to interject in conversation: {e}") logger.error(f"Failed to interject in conversation: {e}")
async def share_miku_tweet_for_server(guild_id: int): async def share_miku_tweet_for_server(guild_id: int, tweet_url: str = None):
"""Share a Miku tweet in a specific server""" """Share a Miku tweet in a specific server
Args:
guild_id: The server ID to share the tweet to
tweet_url: Optional URL of a specific tweet to share. If None, fetches a random tweet.
"""
server_config = server_manager.get_server_config(guild_id) server_config = server_manager.get_server_config(guild_id)
if not server_config: if not server_config:
logger.warning(f"No config found for server {guild_id}") logger.warning(f"No config found for server {guild_id}")
return return
channel = globals.client.get_channel(server_config.autonomous_channel_id) channel = globals.client.get_channel(server_config.autonomous_channel_id)
# If a specific tweet URL is provided, fetch that tweet
if tweet_url:
tweet = await fetch_tweet_by_url(tweet_url)
if not tweet:
logger.error(f"Failed to fetch tweet from URL: {tweet_url}")
return
else:
# Fetch random tweets as usual
tweets = await fetch_miku_tweets(limit=5) tweets = await fetch_miku_tweets(limit=5)
if not tweets: if not tweets:
logger.warning(f"No good tweets found for server {guild_id}") logger.warning(f"No good tweets found for server {guild_id}")
@@ -506,15 +628,16 @@ async def miku_say_something_general():
for guild_id in server_manager.servers: for guild_id in server_manager.servers:
await miku_say_something_general_for_server(guild_id) await miku_say_something_general_for_server(guild_id)
async def miku_engage_random_user(user_id: str = None, engagement_type: str = None): async def miku_engage_random_user(user_id: str = None, engagement_type: str = None, manual_trigger: bool = False):
"""Legacy function - now runs for all servers """Legacy function - now runs for all servers
Args: Args:
user_id: Optional specific user ID to engage user_id: Optional specific user ID to engage
engagement_type: Optional engagement style engagement_type: Optional engagement style
manual_trigger: If True, bypass cooldown checks (for web UI manual triggers)
""" """
for guild_id in server_manager.servers: for guild_id in server_manager.servers:
await miku_engage_random_user_for_server(guild_id, user_id=user_id, engagement_type=engagement_type) await miku_engage_random_user_for_server(guild_id, user_id=user_id, engagement_type=engagement_type, manual_trigger=manual_trigger)
async def miku_detect_and_join_conversation(force: bool = False): async def miku_detect_and_join_conversation(force: bool = False):
"""Legacy function - now runs for all servers """Legacy function - now runs for all servers
@@ -525,10 +648,14 @@ async def miku_detect_and_join_conversation(force: bool = False):
for guild_id in server_manager.servers: for guild_id in server_manager.servers:
await miku_detect_and_join_conversation_for_server(guild_id, force=force) await miku_detect_and_join_conversation_for_server(guild_id, force=force)
async def share_miku_tweet(): async def share_miku_tweet(tweet_url: str = None):
"""Legacy function - now runs for all servers""" """Legacy function - now runs for all servers
Args:
tweet_url: Optional URL of a specific tweet to share. If None, fetches a random tweet.
"""
for guild_id in server_manager.servers: for guild_id in server_manager.servers:
await share_miku_tweet_for_server(guild_id) await share_miku_tweet_for_server(guild_id, tweet_url=tweet_url)
async def handle_custom_prompt(user_prompt: str): async def handle_custom_prompt(user_prompt: str):
"""Legacy function - now runs for all servers""" """Legacy function - now runs for all servers"""