mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-04-04 22:45:46 +00:00
850 lines
39 KiB
Python
850 lines
39 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Greeter command for the MeshCore Bot
|
|
Greets users on their first public channel message with mesh information
|
|
"""
|
|
|
|
import sqlite3
|
|
import time
|
|
from datetime import datetime, timedelta
|
|
from typing import Optional, Dict, Any, List
|
|
from .base_command import BaseCommand
|
|
from ..models import MeshMessage
|
|
|
|
|
|
class GreeterCommand(BaseCommand):
|
|
"""Handles greeting new users on public channels"""
|
|
|
|
# Plugin metadata
|
|
name = "greeter"
|
|
keywords = [] # No keywords - this command is triggered automatically
|
|
description = "Greets users on their first public channel message (once globally by default, or per-channel if configured)"
|
|
category = "system"
|
|
|
|
def __init__(self, bot):
|
|
super().__init__(bot)
|
|
self._init_greeter_tables()
|
|
self._load_config()
|
|
|
|
# Auto-backfill if enabled
|
|
if self.enabled and self.auto_backfill:
|
|
self.logger.info("Auto-backfill enabled - backfilling greeted users from historical data")
|
|
result = self.backfill_greeted_users(lookback_days=self.backfill_lookback_days)
|
|
if result['success']:
|
|
self.logger.info(f"Auto-backfill completed: {result['marked_count']} users marked")
|
|
else:
|
|
self.logger.warning(f"Auto-backfill failed: {result.get('error', 'Unknown error')}")
|
|
|
|
# Check for existing rollout and mark active users if needed
|
|
self._check_rollout_period()
|
|
|
|
# Auto-start rollout if enabled, rollout_days > 0, and no active rollout exists
|
|
if self.enabled and self.rollout_days > 0:
|
|
try:
|
|
with sqlite3.connect(self.bot.db_manager.db_path) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT id FROM greeter_rollout
|
|
WHERE rollout_completed = 0
|
|
''')
|
|
if not cursor.fetchone():
|
|
# No active rollout - start one automatically
|
|
self.logger.info(f"Auto-starting greeter rollout for {self.rollout_days} days")
|
|
self.start_rollout(backfill_first=self.auto_backfill)
|
|
except Exception as e:
|
|
self.logger.error(f"Error checking for existing rollout: {e}")
|
|
|
|
def _load_config(self):
|
|
"""Load configuration for greeter command"""
|
|
self.enabled = self.get_config_value('Greeter_Command', 'enabled', fallback=False, value_type='bool')
|
|
self.greeting_message = self.get_config_value('Greeter_Command', 'greeting_message',
|
|
fallback='Welcome to the mesh, {sender}!')
|
|
self.rollout_days = self.get_config_value('Greeter_Command', 'rollout_days', fallback=7, value_type='int')
|
|
self.include_mesh_info = self.get_config_value('Greeter_Command', 'include_mesh_info',
|
|
fallback=True, value_type='bool')
|
|
self.mesh_info_format = self.get_config_value('Greeter_Command', 'mesh_info_format',
|
|
fallback='\n\nMesh Info: {total_contacts} contacts, {repeaters} repeaters')
|
|
self.per_channel_greetings = self.get_config_value('Greeter_Command', 'per_channel_greetings',
|
|
fallback=False, value_type='bool')
|
|
self.auto_backfill = self.get_config_value('Greeter_Command', 'auto_backfill',
|
|
fallback=False, value_type='bool')
|
|
self.backfill_lookback_days = self.get_config_value('Greeter_Command', 'backfill_lookback_days',
|
|
fallback=None, value_type='int')
|
|
# Convert 0 to None (all time)
|
|
if self.backfill_lookback_days == 0:
|
|
self.backfill_lookback_days = None
|
|
|
|
# Load greeter-specific channels (if not set, uses monitor_channels from [Channels] section)
|
|
channels_str = self.get_config_value('Greeter_Command', 'channels', fallback='')
|
|
if channels_str:
|
|
# Store both original and lowercase versions for case-insensitive matching
|
|
self.greeter_channels = [ch.strip() for ch in channels_str.split(',') if ch.strip()]
|
|
self.greeter_channels_lower = [ch.lower() for ch in self.greeter_channels]
|
|
else:
|
|
# Fall back to monitor_channels if not specified
|
|
self.greeter_channels = None
|
|
self.greeter_channels_lower = None
|
|
|
|
# Load channel-specific greeting messages
|
|
# Format: channel_name:greeting_message,channel_name2:greeting_message2
|
|
# Example: Public:Welcome to Public, {sender}!|general:Welcome to general, {sender}!
|
|
channel_greetings_str = self.get_config_value('Greeter_Command', 'channel_greetings', fallback='')
|
|
self.channel_greetings = {}
|
|
if channel_greetings_str:
|
|
for entry in channel_greetings_str.split(','):
|
|
entry = entry.strip()
|
|
if ':' in entry:
|
|
channel_name, greeting = entry.split(':', 1)
|
|
channel_name = channel_name.strip()
|
|
greeting = greeting.strip()
|
|
# Store both original and lowercase channel name for case-insensitive matching
|
|
self.channel_greetings[channel_name.lower()] = {
|
|
'channel': channel_name,
|
|
'greeting': greeting
|
|
}
|
|
|
|
# Parse multi-part greetings (pipe-separated)
|
|
# If greeting_message contains '|', split it into multiple parts
|
|
if '|' in self.greeting_message:
|
|
self.greeting_parts = [part.strip() for part in self.greeting_message.split('|') if part.strip()]
|
|
else:
|
|
self.greeting_parts = [self.greeting_message]
|
|
|
|
def _init_greeter_tables(self):
|
|
"""Initialize database tables for greeter tracking"""
|
|
try:
|
|
with sqlite3.connect(self.bot.db_manager.db_path) as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# Create greeted_users table for tracking who has been greeted
|
|
# channel can be NULL for global greetings (default behavior)
|
|
cursor.execute('''
|
|
CREATE TABLE IF NOT EXISTS greeted_users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
sender_id TEXT NOT NULL,
|
|
channel TEXT,
|
|
greeted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
rollout_marked BOOLEAN DEFAULT 0,
|
|
UNIQUE(sender_id, channel)
|
|
)
|
|
''')
|
|
|
|
# Create indexes for better performance
|
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_greeted_sender ON greeted_users(sender_id)')
|
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_greeted_channel ON greeted_users(channel)')
|
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_greeted_at ON greeted_users(greeted_at)')
|
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_greeted_sender_channel ON greeted_users(sender_id, channel)')
|
|
|
|
# Create greeter_rollout table to track rollout period
|
|
cursor.execute('''
|
|
CREATE TABLE IF NOT EXISTS greeter_rollout (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
rollout_started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
rollout_days INTEGER NOT NULL,
|
|
rollout_completed BOOLEAN DEFAULT 0,
|
|
active_users_marked INTEGER DEFAULT 0
|
|
)
|
|
''')
|
|
|
|
conn.commit()
|
|
self.logger.info("Greeter tables initialized successfully")
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Failed to initialize greeter tables: {e}")
|
|
raise
|
|
|
|
def _check_rollout_period(self):
|
|
"""Check if we're in a rollout period and mark active users if needed"""
|
|
if not self.enabled:
|
|
return
|
|
|
|
try:
|
|
with sqlite3.connect(self.bot.db_manager.db_path) as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# Check if there's an active rollout
|
|
cursor.execute('''
|
|
SELECT id, rollout_started_at, rollout_days, rollout_completed
|
|
FROM greeter_rollout
|
|
WHERE rollout_completed = 0
|
|
ORDER BY rollout_started_at DESC
|
|
LIMIT 1
|
|
''')
|
|
|
|
rollout = cursor.fetchone()
|
|
|
|
if rollout:
|
|
rollout_id, started_at_str, rollout_days, completed = rollout
|
|
# Use SQLite's datetime functions to handle timezone correctly
|
|
cursor.execute('''
|
|
SELECT datetime(rollout_started_at, '+' || rollout_days || ' days') as end_date,
|
|
datetime('now') as current_time
|
|
FROM greeter_rollout
|
|
WHERE id = ?
|
|
''', (rollout_id,))
|
|
time_result = cursor.fetchone()
|
|
|
|
if time_result:
|
|
end_date_str, current_time_str = time_result
|
|
end_date = datetime.fromisoformat(end_date_str)
|
|
current_time = datetime.fromisoformat(current_time_str)
|
|
|
|
if current_time < end_date:
|
|
# Still in rollout period - mark active users
|
|
self.logger.info(f"Greeter rollout active: marking active users (ends {end_date})")
|
|
self._mark_active_users_as_greeted(rollout_id)
|
|
else:
|
|
# Rollout period ended - mark as completed
|
|
cursor.execute('''
|
|
UPDATE greeter_rollout
|
|
SET rollout_completed = 1
|
|
WHERE id = ?
|
|
''', (rollout_id,))
|
|
conn.commit()
|
|
self.logger.info(f"Greeter rollout period completed (ended {end_date})")
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error checking rollout period: {e}")
|
|
|
|
def _mark_active_users_as_greeted(self, rollout_id: int):
|
|
"""Mark all users who have posted on public channels during rollout period as greeted"""
|
|
try:
|
|
with sqlite3.connect(self.bot.db_manager.db_path) as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# Get rollout start date
|
|
cursor.execute('''
|
|
SELECT rollout_started_at FROM greeter_rollout WHERE id = ?
|
|
''', (rollout_id,))
|
|
result = cursor.fetchone()
|
|
if not result:
|
|
return
|
|
|
|
rollout_start = datetime.fromisoformat(result[0])
|
|
|
|
# Find all users who posted on public channels since rollout started
|
|
# Only get messages that are NOT DMs (is_dm = 0) and have a channel
|
|
cursor.execute('''
|
|
SELECT DISTINCT sender_id, channel
|
|
FROM message_stats
|
|
WHERE is_dm = 0
|
|
AND channel IS NOT NULL
|
|
AND channel != ''
|
|
AND timestamp >= ?
|
|
''', (int(rollout_start.timestamp()),))
|
|
|
|
active_users = cursor.fetchall()
|
|
marked_count = 0
|
|
|
|
for sender_id, channel in active_users:
|
|
# Mark based on per_channel_greetings setting
|
|
# If per_channel_greetings is False, mark globally (channel = NULL)
|
|
# If per_channel_greetings is True, mark per channel
|
|
if self.per_channel_greetings:
|
|
mark_channel = channel
|
|
# Check if already greeted on this channel
|
|
cursor.execute('''
|
|
SELECT id FROM greeted_users
|
|
WHERE sender_id = ? AND channel = ?
|
|
''', (sender_id, mark_channel))
|
|
else:
|
|
mark_channel = None
|
|
# Check if already greeted globally
|
|
cursor.execute('''
|
|
SELECT id FROM greeted_users
|
|
WHERE sender_id = ? AND channel IS NULL
|
|
''', (sender_id,))
|
|
|
|
if not cursor.fetchone():
|
|
# Mark as greeted with rollout flag
|
|
cursor.execute('''
|
|
INSERT OR IGNORE INTO greeted_users
|
|
(sender_id, channel, rollout_marked, greeted_at)
|
|
VALUES (?, ?, 1, ?)
|
|
''', (sender_id, mark_channel, rollout_start.isoformat()))
|
|
marked_count += 1
|
|
|
|
# Update rollout record
|
|
cursor.execute('''
|
|
UPDATE greeter_rollout
|
|
SET active_users_marked = active_users_marked + ?
|
|
WHERE id = ?
|
|
''', (marked_count, rollout_id))
|
|
|
|
conn.commit()
|
|
|
|
if marked_count > 0:
|
|
self.logger.info(f"Marked {marked_count} active users as greeted during rollout")
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error marking active users as greeted: {e}")
|
|
|
|
def backfill_greeted_users(self, lookback_days: Optional[int] = None) -> Dict[str, Any]:
|
|
"""
|
|
Backfill greeted_users table from historical message_stats data
|
|
|
|
This allows marking all users who have posted on public channels in the past,
|
|
which can shorten or eliminate the rollout period.
|
|
|
|
Args:
|
|
lookback_days: Number of days to look back (None = all time)
|
|
|
|
Returns:
|
|
Dictionary with backfill results (marked_count, total_users, etc.)
|
|
"""
|
|
if not self.enabled:
|
|
self.logger.warning("Greeter is disabled - cannot backfill")
|
|
return {'success': False, 'error': 'Greeter is disabled'}
|
|
|
|
try:
|
|
with sqlite3.connect(self.bot.db_manager.db_path) as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# Check if message_stats table exists
|
|
cursor.execute('''
|
|
SELECT name FROM sqlite_master
|
|
WHERE type='table' AND name='message_stats'
|
|
''')
|
|
if not cursor.fetchone():
|
|
return {
|
|
'success': False,
|
|
'error': 'message_stats table does not exist',
|
|
'marked_count': 0
|
|
}
|
|
|
|
# Build query to find all users who posted on public channels
|
|
if lookback_days:
|
|
cutoff_timestamp = int(time.time()) - (lookback_days * 24 * 60 * 60)
|
|
cursor.execute('''
|
|
SELECT DISTINCT sender_id, channel
|
|
FROM message_stats
|
|
WHERE is_dm = 0
|
|
AND channel IS NOT NULL
|
|
AND channel != ''
|
|
AND timestamp >= ?
|
|
''', (cutoff_timestamp,))
|
|
else:
|
|
# All time
|
|
cursor.execute('''
|
|
SELECT DISTINCT sender_id, channel
|
|
FROM message_stats
|
|
WHERE is_dm = 0
|
|
AND channel IS NOT NULL
|
|
AND channel != ''
|
|
''')
|
|
|
|
historical_users = cursor.fetchall()
|
|
marked_count = 0
|
|
skipped_count = 0
|
|
|
|
for sender_id, channel in historical_users:
|
|
# Mark based on per_channel_greetings setting
|
|
if self.per_channel_greetings:
|
|
mark_channel = channel
|
|
# Check if already greeted on this channel
|
|
cursor.execute('''
|
|
SELECT id FROM greeted_users
|
|
WHERE sender_id = ? AND channel = ?
|
|
''', (sender_id, mark_channel))
|
|
else:
|
|
mark_channel = None
|
|
# Check if already greeted globally
|
|
cursor.execute('''
|
|
SELECT id FROM greeted_users
|
|
WHERE sender_id = ? AND channel IS NULL
|
|
''', (sender_id,))
|
|
|
|
if not cursor.fetchone():
|
|
# Mark as greeted with backfill flag (use current time as greeted_at)
|
|
cursor.execute('''
|
|
INSERT OR IGNORE INTO greeted_users
|
|
(sender_id, channel, rollout_marked, greeted_at)
|
|
VALUES (?, ?, 1, datetime('now'))
|
|
''', (sender_id, mark_channel))
|
|
marked_count += 1
|
|
else:
|
|
skipped_count += 1
|
|
|
|
conn.commit()
|
|
|
|
result = {
|
|
'success': True,
|
|
'marked_count': marked_count,
|
|
'skipped_count': skipped_count,
|
|
'total_users_found': len(historical_users),
|
|
'lookback_days': lookback_days
|
|
}
|
|
|
|
self.logger.info(f"Backfilled {marked_count} users from historical message_stats data "
|
|
f"({skipped_count} already marked, {len(historical_users)} total found)")
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error backfilling greeted users: {e}")
|
|
return {
|
|
'success': False,
|
|
'error': str(e),
|
|
'marked_count': 0
|
|
}
|
|
|
|
def start_rollout(self, days: Optional[int] = None, backfill_first: bool = True) -> bool:
|
|
"""
|
|
Start a rollout period where all active users are marked as greeted
|
|
|
|
Args:
|
|
days: Number of days for rollout period (uses config default if None)
|
|
backfill_first: If True, backfill from historical data before starting rollout
|
|
|
|
Returns:
|
|
True if rollout started successfully
|
|
"""
|
|
if not self.enabled:
|
|
self.logger.warning("Greeter is disabled - cannot start rollout")
|
|
return False
|
|
|
|
try:
|
|
# Backfill from historical data first if requested
|
|
if backfill_first:
|
|
self.logger.info("Backfilling from historical data before starting rollout...")
|
|
backfill_result = self.backfill_greeted_users(lookback_days=None) # All time
|
|
if backfill_result['success']:
|
|
self.logger.info(f"Backfilled {backfill_result['marked_count']} users from history")
|
|
|
|
rollout_days = days or self.rollout_days
|
|
|
|
with sqlite3.connect(self.bot.db_manager.db_path) as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# Check if there's already an active rollout
|
|
cursor.execute('''
|
|
SELECT id FROM greeter_rollout
|
|
WHERE rollout_completed = 0
|
|
''')
|
|
|
|
if cursor.fetchone():
|
|
self.logger.warning("Rollout already in progress")
|
|
return False
|
|
|
|
# Start new rollout
|
|
cursor.execute('''
|
|
INSERT INTO greeter_rollout (rollout_days)
|
|
VALUES (?)
|
|
''', (rollout_days,))
|
|
|
|
rollout_id = cursor.lastrowid
|
|
conn.commit()
|
|
|
|
# Mark active users immediately
|
|
self._mark_active_users_as_greeted(rollout_id)
|
|
|
|
self.logger.info(f"Started greeter rollout for {rollout_days} days (ID: {rollout_id})")
|
|
return True
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error starting rollout: {e}")
|
|
return False
|
|
|
|
def has_been_greeted(self, sender_id: str, channel: str) -> bool:
|
|
"""
|
|
Check if a user has been greeted
|
|
|
|
Args:
|
|
sender_id: The user's ID
|
|
channel: The channel name (used only if per_channel_greetings is True)
|
|
|
|
Returns:
|
|
True if user has been greeted (globally or on this channel, depending on config)
|
|
"""
|
|
try:
|
|
with sqlite3.connect(self.bot.db_manager.db_path) as conn:
|
|
cursor = conn.cursor()
|
|
|
|
if self.per_channel_greetings:
|
|
# Per-channel mode: check if greeted on this specific channel
|
|
cursor.execute('''
|
|
SELECT id FROM greeted_users
|
|
WHERE sender_id = ? AND channel = ?
|
|
''', (sender_id, channel))
|
|
else:
|
|
# Global mode: check if greeted at all (channel = NULL)
|
|
cursor.execute('''
|
|
SELECT id FROM greeted_users
|
|
WHERE sender_id = ? AND channel IS NULL
|
|
''', (sender_id,))
|
|
|
|
return cursor.fetchone() is not None
|
|
except Exception as e:
|
|
self.logger.error(f"Error checking if user has been greeted: {e}")
|
|
return False
|
|
|
|
def mark_as_greeted(self, sender_id: str, channel: str) -> bool:
|
|
"""
|
|
Mark a user as greeted
|
|
|
|
Args:
|
|
sender_id: The user's ID
|
|
channel: The channel name (stored only if per_channel_greetings is True)
|
|
|
|
Returns:
|
|
True if user was marked (or already marked), False on error
|
|
"""
|
|
try:
|
|
db_path = self.bot.db_manager.db_path
|
|
self.logger.debug(f"Marking {sender_id} as greeted (channel: {channel}, db: {db_path})")
|
|
|
|
with sqlite3.connect(db_path) as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# Check if already exists first
|
|
if self.per_channel_greetings:
|
|
cursor.execute('''
|
|
SELECT id FROM greeted_users
|
|
WHERE sender_id = ? AND channel = ?
|
|
''', (sender_id, channel))
|
|
exists = cursor.fetchone() is not None
|
|
|
|
if not exists:
|
|
cursor.execute('''
|
|
INSERT INTO greeted_users (sender_id, channel)
|
|
VALUES (?, ?)
|
|
''', (sender_id, channel))
|
|
conn.commit()
|
|
self.logger.info(f"✅ Saved: Marked {sender_id} as greeted on channel {channel}")
|
|
return True
|
|
else:
|
|
self.logger.debug(f"User {sender_id} already marked as greeted on channel {channel}")
|
|
return True
|
|
else:
|
|
# Global mode: store NULL for channel (greeted once globally)
|
|
cursor.execute('''
|
|
SELECT id FROM greeted_users
|
|
WHERE sender_id = ? AND channel IS NULL
|
|
''', (sender_id,))
|
|
exists = cursor.fetchone() is not None
|
|
|
|
if not exists:
|
|
cursor.execute('''
|
|
INSERT INTO greeted_users (sender_id, channel)
|
|
VALUES (?, NULL)
|
|
''', (sender_id,))
|
|
conn.commit()
|
|
self.logger.info(f"✅ Saved: Marked {sender_id} as greeted globally (all channels)")
|
|
return True
|
|
else:
|
|
self.logger.debug(f"User {sender_id} already marked as greeted globally")
|
|
return True
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"❌ Error marking user as greeted: {e}")
|
|
import traceback
|
|
self.logger.error(traceback.format_exc())
|
|
return False
|
|
|
|
def get_greeted_users_count(self) -> int:
|
|
"""Get count of users who have been greeted (for verification)"""
|
|
try:
|
|
with sqlite3.connect(self.bot.db_manager.db_path) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT COUNT(*) FROM greeted_users')
|
|
count = cursor.fetchone()[0]
|
|
return count
|
|
except Exception as e:
|
|
self.logger.error(f"Error getting greeted users count: {e}")
|
|
return 0
|
|
|
|
def get_recent_greeted_users(self, limit: int = 10) -> List[Dict[str, Any]]:
|
|
"""Get recent greeted users (for verification)"""
|
|
try:
|
|
with sqlite3.connect(self.bot.db_manager.db_path) as conn:
|
|
conn.row_factory = sqlite3.Row
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT sender_id, channel, greeted_at, rollout_marked
|
|
FROM greeted_users
|
|
ORDER BY greeted_at DESC
|
|
LIMIT ?
|
|
''', (limit,))
|
|
rows = cursor.fetchall()
|
|
return [dict(row) for row in rows]
|
|
except Exception as e:
|
|
self.logger.error(f"Error getting recent greeted users: {e}")
|
|
return []
|
|
|
|
async def _get_mesh_info(self) -> Dict[str, Any]:
|
|
"""Get mesh network information for greeting"""
|
|
info = {
|
|
'total_contacts': 0,
|
|
'repeaters': 0,
|
|
'companions': 0,
|
|
'recent_activity_24h': 0
|
|
}
|
|
|
|
try:
|
|
# Get contact statistics from repeater manager if available
|
|
if hasattr(self.bot, 'repeater_manager'):
|
|
try:
|
|
stats = await self.bot.repeater_manager.get_contact_statistics()
|
|
if stats:
|
|
info['total_contacts'] = stats.get('total_heard', 0)
|
|
info['repeaters'] = stats.get('by_role', {}).get('repeater', 0)
|
|
info['companions'] = stats.get('by_role', {}).get('companion', 0)
|
|
info['recent_activity_24h'] = stats.get('recent_activity', 0)
|
|
except Exception as e:
|
|
self.logger.debug(f"Error getting stats from repeater_manager: {e}")
|
|
|
|
# Fallback to device contacts if repeater manager stats not available
|
|
if info['total_contacts'] == 0 and hasattr(self.bot, 'meshcore') and hasattr(self.bot.meshcore, 'contacts'):
|
|
info['total_contacts'] = len(self.bot.meshcore.contacts)
|
|
|
|
# Count repeaters and companions
|
|
if hasattr(self.bot, 'repeater_manager'):
|
|
for contact_data in self.bot.meshcore.contacts.values():
|
|
if self.bot.repeater_manager._is_repeater_device(contact_data):
|
|
info['repeaters'] += 1
|
|
else:
|
|
info['companions'] += 1
|
|
|
|
# Get recent activity from message_stats if available
|
|
if info['recent_activity_24h'] == 0:
|
|
try:
|
|
with sqlite3.connect(self.bot.db_manager.db_path) as conn:
|
|
cursor = conn.cursor()
|
|
# Check if message_stats table exists
|
|
cursor.execute('''
|
|
SELECT name FROM sqlite_master
|
|
WHERE type='table' AND name='message_stats'
|
|
''')
|
|
if cursor.fetchone():
|
|
cutoff_time = int(time.time()) - (24 * 60 * 60)
|
|
cursor.execute('''
|
|
SELECT COUNT(DISTINCT sender_id)
|
|
FROM message_stats
|
|
WHERE timestamp >= ? AND is_dm = 0
|
|
''', (cutoff_time,))
|
|
result = cursor.fetchone()
|
|
if result:
|
|
info['recent_activity_24h'] = result[0]
|
|
except Exception:
|
|
pass
|
|
|
|
except Exception as e:
|
|
self.logger.debug(f"Error getting mesh info: {e}")
|
|
|
|
return info
|
|
|
|
def _get_greeting_for_channel(self, channel: str) -> str:
|
|
"""
|
|
Get greeting message for a specific channel
|
|
|
|
Args:
|
|
channel: Channel name
|
|
|
|
Returns:
|
|
Greeting message template for the channel, or default if not specified
|
|
"""
|
|
if channel and channel.lower() in self.channel_greetings:
|
|
return self.channel_greetings[channel.lower()]['greeting']
|
|
return self.greeting_message
|
|
|
|
async def _format_greeting_parts(self, sender_id: str, channel: str = None, mesh_info: Optional[Dict[str, Any]] = None) -> list:
|
|
"""
|
|
Format greeting message parts with mesh information
|
|
|
|
Args:
|
|
sender_id: The user's ID
|
|
channel: Channel name (for channel-specific greetings)
|
|
mesh_info: Optional mesh info dict (will be fetched if None)
|
|
|
|
Returns:
|
|
List of greeting message strings (for multi-part greetings)
|
|
"""
|
|
if mesh_info is None:
|
|
mesh_info = await self._get_mesh_info()
|
|
|
|
# Get channel-specific greeting if available, otherwise use default
|
|
greeting_template = self._get_greeting_for_channel(channel) if channel else self.greeting_message
|
|
|
|
# Parse multi-part greetings (pipe-separated)
|
|
if '|' in greeting_template:
|
|
greeting_parts = [part.strip() for part in greeting_template.split('|') if part.strip()]
|
|
else:
|
|
greeting_parts = [greeting_template]
|
|
|
|
# Format each greeting part
|
|
formatted_parts = []
|
|
for part in greeting_parts:
|
|
formatted_part = part.format(sender=sender_id)
|
|
formatted_parts.append(formatted_part)
|
|
|
|
# Add mesh info to the last part if enabled
|
|
if self.include_mesh_info:
|
|
mesh_info_text = self.mesh_info_format.format(
|
|
total_contacts=mesh_info.get('total_contacts', 0),
|
|
repeaters=mesh_info.get('repeaters', 0),
|
|
companions=mesh_info.get('companions', 0),
|
|
recent_activity_24h=mesh_info.get('recent_activity_24h', 0)
|
|
)
|
|
# Append mesh info to the last greeting part
|
|
if formatted_parts:
|
|
formatted_parts[-1] += mesh_info_text
|
|
else:
|
|
formatted_parts.append(mesh_info_text)
|
|
|
|
return formatted_parts
|
|
|
|
def matches_keyword(self, message: MeshMessage) -> bool:
|
|
"""Greeter doesn't match keywords - it's triggered automatically"""
|
|
return False
|
|
|
|
def matches_custom_syntax(self, message: MeshMessage) -> bool:
|
|
"""Greeter doesn't match custom syntax"""
|
|
return False
|
|
|
|
def _is_rollout_active(self) -> bool:
|
|
"""Check if there's an active rollout period"""
|
|
try:
|
|
with sqlite3.connect(self.bot.db_manager.db_path) as conn:
|
|
cursor = conn.cursor()
|
|
# Use SQLite's datetime functions to calculate end date and compare with current time
|
|
# This handles timezone issues automatically since both are in UTC
|
|
cursor.execute('''
|
|
SELECT id, rollout_started_at, rollout_days,
|
|
datetime(rollout_started_at, '+' || rollout_days || ' days') as end_date,
|
|
datetime('now') as current_time
|
|
FROM greeter_rollout
|
|
WHERE rollout_completed = 0
|
|
ORDER BY rollout_started_at DESC
|
|
LIMIT 1
|
|
''')
|
|
rollout = cursor.fetchone()
|
|
|
|
if rollout:
|
|
rollout_id, started_at_str, rollout_days, end_date_str, current_time_str = rollout
|
|
|
|
# Parse for logging (both are in UTC from SQLite)
|
|
started_at = datetime.fromisoformat(started_at_str)
|
|
end_date = datetime.fromisoformat(end_date_str)
|
|
current_time = datetime.fromisoformat(current_time_str)
|
|
|
|
if current_time < end_date:
|
|
remaining = (end_date - current_time).total_seconds() / 86400 # days
|
|
self.logger.debug(f"Rollout active: {remaining:.1f} days remaining (started {started_at}, ends {end_date})")
|
|
return True
|
|
else:
|
|
# Rollout period ended - mark as completed
|
|
cursor.execute('''
|
|
UPDATE greeter_rollout
|
|
SET rollout_completed = 1
|
|
WHERE id = ?
|
|
''', (rollout_id,))
|
|
conn.commit()
|
|
self.logger.info(f"Greeter rollout period completed (ended {end_date})")
|
|
return False
|
|
|
|
self.logger.debug("No active rollout found")
|
|
return False
|
|
except Exception as e:
|
|
self.logger.error(f"Error checking rollout status: {e}")
|
|
import traceback
|
|
self.logger.error(traceback.format_exc())
|
|
return False
|
|
|
|
def should_execute(self, message: MeshMessage) -> bool:
|
|
"""
|
|
Check if greeter should execute for this message
|
|
Only executes for public channel messages (not DMs) on monitored channels
|
|
"""
|
|
if not self.enabled:
|
|
return False
|
|
|
|
# Only greet on public channels
|
|
if message.is_dm:
|
|
return False
|
|
|
|
# Must have a channel name
|
|
if not message.channel:
|
|
return False
|
|
|
|
# Check if channel is in greeter-specific channels or fall back to monitor_channels
|
|
if self.greeter_channels is not None:
|
|
# Use greeter-specific channels if configured (case-insensitive matching)
|
|
if message.channel and message.channel.lower() not in self.greeter_channels_lower:
|
|
return False
|
|
else:
|
|
# Fall back to general monitor_channels setting (case-insensitive matching)
|
|
monitor_channels_lower = [ch.lower() for ch in self.bot.command_manager.monitor_channels]
|
|
if message.channel and message.channel.lower() not in monitor_channels_lower:
|
|
return False
|
|
|
|
# Check if we're in an active rollout period
|
|
rollout_active = self._is_rollout_active()
|
|
if rollout_active:
|
|
# During rollout, mark user as greeted but don't actually greet them
|
|
self.logger.info(f"🔄 Rollout active: Marking {message.sender_id} as greeted on {message.channel} (no greeting sent)")
|
|
self.mark_as_greeted(message.sender_id, message.channel)
|
|
return False
|
|
else:
|
|
self.logger.debug(f"Rollout not active - proceeding with greeting check for {message.sender_id}")
|
|
|
|
# Check if user has already been greeted (globally or per-channel, depending on config)
|
|
if self.has_been_greeted(message.sender_id, message.channel):
|
|
return False
|
|
|
|
return True
|
|
|
|
async def execute(self, message: MeshMessage) -> bool:
|
|
"""Execute the greeter command - greet the user on their first public message"""
|
|
try:
|
|
# Double-check we should greet (race condition protection)
|
|
if not self.should_execute(message):
|
|
return False
|
|
|
|
# Mark as greeted BEFORE getting mesh info (to prevent duplicate greetings)
|
|
# This ensures we don't greet the same user twice even if there's a delay
|
|
marked = self.mark_as_greeted(message.sender_id, message.channel)
|
|
if not marked:
|
|
self.logger.warning(f"Failed to mark {message.sender_id} as greeted - aborting greeting")
|
|
return False
|
|
|
|
# Format greeting parts (may be single or multi-part)
|
|
# Pass channel name for channel-specific greetings
|
|
greeting_parts = await self._format_greeting_parts(message.sender_id, message.channel)
|
|
|
|
# Send greeting(s)
|
|
mode_str = "per-channel" if self.per_channel_greetings else "global"
|
|
self.logger.info(f"Greeting {message.sender_id} on channel {message.channel} ({mode_str} mode, {len(greeting_parts)} part(s))")
|
|
|
|
# Log database verification
|
|
total_greeted = self.get_greeted_users_count()
|
|
self.logger.debug(f"Database verification: {total_greeted} total user(s) marked as greeted")
|
|
|
|
# Send all greeting parts
|
|
success = True
|
|
for i, greeting_part in enumerate(greeting_parts):
|
|
if i > 0:
|
|
# Wait for bot TX rate limiter between multi-part messages
|
|
# This ensures we respect the bot's rate limiting configuration
|
|
await self.bot.bot_tx_rate_limiter.wait_for_tx()
|
|
# Additional delay to ensure proper spacing (use configured rate limit)
|
|
import asyncio
|
|
rate_limit = self.bot.config.getfloat('Bot', 'bot_tx_rate_limit_seconds', fallback=1.0)
|
|
# Use a conservative sleep time to avoid rate limiting
|
|
sleep_time = max(rate_limit + 0.5, 1.0) # At least 1 second, or rate_limit + 0.5 seconds
|
|
await asyncio.sleep(sleep_time)
|
|
|
|
result = await self.send_response(message, greeting_part)
|
|
if not result:
|
|
success = False
|
|
|
|
return success
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error executing greeter command: {e}")
|
|
return False
|
|
|
|
def get_help_text(self) -> str:
|
|
mode = "per-channel" if self.per_channel_greetings else "global (once total)"
|
|
return f"Greeter automatically welcomes new users on public channels ({mode} mode). Configure in [Greeter_Command] section."
|
|
|