mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-11-11 15:07:32 +00:00
5c191e4 dropped the now-unused timedelta import, which the public-API characterization test counts as a removed name, so dev's suite was red. It is back with a noqa, as the other modules keep theirs.
1610 lines
75 KiB
Python
1610 lines
75 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Greeter command for the MeshCore Bot
|
|
Greets users on their first public channel message with mesh information
|
|
"""
|
|
|
|
import asyncio
|
|
import re
|
|
import sqlite3
|
|
import time
|
|
from datetime import datetime, timedelta, timezone # noqa: F401 timedelta importable from this module on dev
|
|
from typing import Any, Optional
|
|
|
|
from ..models import MeshMessage
|
|
from ..template_reference import template_spec
|
|
from ..utils import decode_escape_sequences
|
|
from .base_command import BaseCommand
|
|
|
|
# What the greeter uses when the keys are missing; the settings form shows the same.
|
|
DEFAULT_GREETING = 'Welcome to the mesh, {sender}!'
|
|
# As written in config.ini: decode_escape_sequences turns the \\n into newlines.
|
|
DEFAULT_MESH_INFO_FORMAT = '\\n\\nMesh Info: {total_contacts} contacts, {repeaters} repeaters'
|
|
|
|
# A plausible channel name at the head of a ``channel_greetings`` entry: no
|
|
# whitespace, no punctuation beyond the ones channels actually use.
|
|
_CHANNEL_KEY_RE = re.compile(r"^#?[A-Za-z0-9][A-Za-z0-9_-]{0,31}$")
|
|
|
|
|
|
def _starts_new_channel_greeting(fragment: str) -> bool:
|
|
"""Whether a comma-separated fragment begins a new ``channel:greeting`` entry.
|
|
|
|
``channel_greetings`` splits entries on commas, but greeting text contains
|
|
commas ("Welcome to the mesh, {sender}!") and colons (URLs, clock times).
|
|
Requiring the part before the first colon to look like a channel name keeps
|
|
``, see https://…`` and ``, 3:30pm`` attached to the greeting they belong to
|
|
instead of registering ``see https`` / ``3`` as phantom channels.
|
|
"""
|
|
head, sep, _ = fragment.partition(':')
|
|
if not sep:
|
|
return False
|
|
head = head.strip()
|
|
if head.isdigit(): # a clock time, not a channel
|
|
return False
|
|
return bool(_CHANNEL_KEY_RE.match(head))
|
|
|
|
|
|
|
|
def parse_channel_greetings(raw: str) -> tuple[dict[str, dict[str, str]], list[str]]:
|
|
"""Parse ``channel_greetings`` into ``{channel_lower: {channel, greeting}}``.
|
|
|
|
Entries are comma-separated, but greeting text routinely contains commas
|
|
("Welcome to the mesh, {sender}!"). A bare split would cut such a greeting in
|
|
half and silently drop the tail (placeholder and all), so a fragment with no
|
|
"channel:" of its own is re-attached to the entry before it. Returns the
|
|
greetings and the leading fragments that had no entry to join.
|
|
"""
|
|
greetings: dict[str, dict[str, str]] = {}
|
|
ignored: list[str] = []
|
|
if not raw:
|
|
return greetings, ignored
|
|
entries: list[str] = []
|
|
for fragment in raw.split(','):
|
|
if _starts_new_channel_greeting(fragment):
|
|
entries.append(fragment)
|
|
elif entries:
|
|
entries[-1] = f"{entries[-1]},{fragment}"
|
|
else:
|
|
ignored.append(fragment)
|
|
for entry in entries:
|
|
entry = entry.strip()
|
|
if ':' in entry:
|
|
channel_name, greeting = entry.split(':', 1)
|
|
channel_name = channel_name.strip()
|
|
# Decode escape sequences (e.g., \n for newlines). Keyed lowercase
|
|
# for case-insensitive matching; the original name is kept too.
|
|
greetings[channel_name.lower()] = {
|
|
'channel': channel_name,
|
|
'greeting': decode_escape_sequences(greeting.strip()),
|
|
}
|
|
return greetings, ignored
|
|
|
|
|
|
def split_greeting_parts(template: str) -> list[str]:
|
|
"""Split a greeting into its messages: ``|`` separates them, blanks are dropped."""
|
|
if '|' in template:
|
|
return [part.strip() for part in template.split('|') if part.strip()]
|
|
return [template]
|
|
|
|
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"
|
|
|
|
# Opt-in command: _load_config reads 'enabled' with fallback=False, so the
|
|
# settings UI must also show "off" when the key is absent.
|
|
settings_enabled_default = False
|
|
|
|
# Web-viewer settings schema (see modules/settings_schema.py)
|
|
settings_schema = [
|
|
{"key": "greeting_message", "label": "Greeting message", "type": "str",
|
|
"default": DEFAULT_GREETING,
|
|
"help": "Default greeting. Separate multi-part messages with |.",
|
|
"template": template_spec("format", ("sender",), notes=[
|
|
"| splits the greeting into separate messages.",
|
|
"\\n starts a new line.",
|
|
], blank_default=DEFAULT_GREETING, preview="greeting")},
|
|
{"key": "rollout_days", "label": "Rollout period", "type": "int",
|
|
"min": 0, "default": 7, "unit": "days",
|
|
"help": "Days to mark active users as already greeted before welcoming anyone. "
|
|
"Runs once, the first time the greeter starts. 0 skips it."},
|
|
{"key": "channel_greetings", "label": "Per-channel greetings", "type": "str",
|
|
"default": "",
|
|
"help": "channel:greeting,channel2:greeting2 — overrides the default per channel.",
|
|
"template": template_spec("format", ("sender",), notes=[
|
|
"Entries are channel:greeting, separated by commas.",
|
|
"\\n starts a new line.",
|
|
], preview="channel_greetings")},
|
|
{"key": "per_channel_greetings", "label": "Greet once per channel", "type": "bool",
|
|
"default": False,
|
|
"help": "On: greet each user once per channel. Off: once globally."},
|
|
{"key": "include_mesh_info", "label": "Include mesh info", "type": "bool",
|
|
"default": True,
|
|
"help": "Append mesh statistics to the greeting."},
|
|
{"key": "mesh_info_format", "label": "Mesh info format", "type": "str",
|
|
"default": DEFAULT_MESH_INFO_FORMAT,
|
|
"help": "Template for mesh info, appended to the last greeting part.",
|
|
"template": template_spec("format", {
|
|
"total_contacts": "Contacts ever heard",
|
|
"repeaters": "Repeaters ever heard",
|
|
"companions": "Companions ever heard",
|
|
"recent_activity_24h": "Users active in the last 24 hours",
|
|
}, notes=["\\n starts a new line; start with \\n\\n to set it off from the greeting."],
|
|
preview="mesh_info")},
|
|
{"key": "auto_backfill", "label": "Auto-backfill greeted users", "type": "bool",
|
|
"default": False,
|
|
"help": "On first run, mark recently-seen users as already greeted so they aren't greeted retroactively."},
|
|
{"key": "backfill_lookback_days", "label": "Backfill lookback", "type": "int",
|
|
"min": 0, "default": 0, "unit": "days",
|
|
"help": "How far back to look when auto-backfilling. 0 = all time."},
|
|
{"key": "dead_air_delay_seconds", "label": "Dead-air delay", "type": "int",
|
|
"min": 0, "default": 0, "unit": "s",
|
|
"help": "Wait this long after a new user's first message before greeting. 0 = greet at once."},
|
|
{"key": "defer_to_human_greeting", "label": "Defer to human greeting", "type": "bool",
|
|
"default": False,
|
|
"help": "Skip the bot greeting when someone already mentioned the new user during the dead-air delay."},
|
|
{"key": "levenshtein_distance", "label": "Name fuzziness", "type": "int",
|
|
"min": 0, "max": 3, "default": 0,
|
|
"help": "Edits allowed when matching sender names (greet-once and human-greeting checks). 0 = exact."},
|
|
]
|
|
|
|
def __init__(self, bot: Any):
|
|
"""Initialize the greeter command.
|
|
|
|
Args:
|
|
bot: The bot instance.
|
|
"""
|
|
super().__init__(bot)
|
|
self._init_greeter_tables()
|
|
self._load_config()
|
|
|
|
# Track pending greetings (for dead air delay)
|
|
self.pending_greetings = {} # key: (sender_id, channel), value: asyncio.Task
|
|
|
|
# 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 self.bot.db_manager.connection() as conn:
|
|
cursor = conn.cursor()
|
|
# Check for active rollout (more robust check)
|
|
cursor.execute('''
|
|
SELECT id, rollout_started_at, rollout_days, rollout_completed,
|
|
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
|
|
''')
|
|
active_rollout = cursor.fetchone()
|
|
|
|
if active_rollout:
|
|
# Verify the rollout is actually still active (not expired)
|
|
rollout_id, started_at_str, rollout_days, completed, end_date_str, current_time_str = active_rollout
|
|
end_date = datetime.fromisoformat(end_date_str)
|
|
current_time = datetime.fromisoformat(current_time_str)
|
|
|
|
if current_time < end_date:
|
|
# Rollout is still active - don't start a new one
|
|
remaining = (end_date - current_time).total_seconds() / 86400
|
|
self.logger.info(f"Active rollout found (ID: {rollout_id}, {remaining:.1f} days remaining) - not starting new rollout")
|
|
else:
|
|
# Expired but still flagged active. Close it. A later boot
|
|
# must not open another warmup over the one that just ended.
|
|
self.logger.warning(f"Found expired rollout (ID: {rollout_id}) - marking as completed")
|
|
cursor.execute('''
|
|
UPDATE greeter_rollout
|
|
SET rollout_completed = 1
|
|
WHERE id = ?
|
|
''', (rollout_id,))
|
|
conn.commit()
|
|
else:
|
|
# The warmup is once, when the greeter is first enabled.
|
|
# A completed row means it already ran; starting another on
|
|
# boot marks whoever speaks during it and never greets them.
|
|
cursor.execute('''
|
|
SELECT id FROM greeter_rollout
|
|
WHERE rollout_completed = 1
|
|
LIMIT 1
|
|
''')
|
|
if cursor.fetchone():
|
|
self.logger.info("Greeter rollout already completed - not starting another")
|
|
else:
|
|
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}")
|
|
import traceback
|
|
self.logger.error(traceback.format_exc())
|
|
|
|
def _load_config(self) -> None:
|
|
"""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=DEFAULT_GREETING)
|
|
# Decode escape sequences (e.g., \n for newlines)
|
|
self.greeting_message = decode_escape_sequences(self.greeting_message)
|
|
|
|
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=DEFAULT_MESH_INFO_FORMAT)
|
|
# Decode escape sequences (e.g., \n for newlines)
|
|
self.mesh_info_format = decode_escape_sequences(self.mesh_info_format)
|
|
|
|
# Log configuration for debugging
|
|
self.logger.debug(f"Greeter config loaded: include_mesh_info={self.include_mesh_info}, "
|
|
f"mesh_info_format={repr(self.mesh_info_format)}")
|
|
|
|
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
|
|
|
|
# Note: allowed_channels is now loaded by BaseCommand from config
|
|
# Keep greeter_channels for backward compatibility and case-insensitive matching
|
|
# ``fallback=None`` distinguishes "key absent" (fall back to
|
|
# monitor_channels) from "channels =" (BaseCommand reads that as
|
|
# disabled-on-channels). Collapsing both to '' meant an explicit empty
|
|
# value silently fell through to monitor_channels and kept greeting.
|
|
channels_str = self.get_config_value('Greeter_Command', 'channels', fallback=None)
|
|
if channels_str is None:
|
|
# Not configured — fall back to monitor_channels.
|
|
self.greeter_channels = None
|
|
self.greeter_channels_lower = None
|
|
else:
|
|
# Store both original and lowercase versions for case-insensitive matching.
|
|
# An explicit empty value yields [], i.e. disabled on all channels.
|
|
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]
|
|
|
|
# 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, ignored = parse_channel_greetings(channel_greetings_str)
|
|
for fragment in ignored:
|
|
self.logger.warning(
|
|
"Greeter: ignoring channel_greetings fragment with no 'channel:' prefix: %r",
|
|
fragment,
|
|
)
|
|
|
|
# Parse multi-part greetings (pipe-separated)
|
|
# If greeting_message contains '|', split it into multiple parts
|
|
self.greeting_parts = split_greeting_parts(self.greeting_message)
|
|
|
|
# Dead air delay settings
|
|
self.dead_air_delay_seconds = self.get_config_value('Greeter_Command', 'dead_air_delay_seconds',
|
|
fallback=0, value_type='int')
|
|
self.defer_to_human_greeting = self.get_config_value('Greeter_Command', 'defer_to_human_greeting',
|
|
fallback=False, value_type='bool')
|
|
self.levenshtein_distance = self.get_config_value('Greeter_Command', 'levenshtein_distance',
|
|
fallback=0, value_type='int')
|
|
|
|
def _init_greeter_tables(self) -> None:
|
|
"""Initialize database tables for greeter tracking."""
|
|
try:
|
|
with self.bot.db_manager.connection() 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()
|
|
|
|
# Clean up any existing duplicates (in case they existed before UNIQUE constraint)
|
|
self._cleanup_duplicate_greetings()
|
|
|
|
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) -> None:
|
|
"""Check if we're in a rollout period and mark active users if needed."""
|
|
if not self.enabled:
|
|
return
|
|
|
|
try:
|
|
with self.bot.db_manager.connection() 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
|
|
remaining = (end_date - current_time).total_seconds() / 86400
|
|
self.logger.info(f"Greeter rollout active: marking active users (ends {end_date}, {remaining:.1f} days remaining)")
|
|
self._mark_active_users_as_greeted(rollout_id)
|
|
else:
|
|
# Rollout period ended - mark as completed
|
|
days_over = (current_time - end_date).total_seconds() / 86400
|
|
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}, {days_over:.1f} days ago)")
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error checking rollout period: {e}")
|
|
|
|
def _mark_active_users_as_greeted(self, rollout_id: int) -> None:
|
|
"""Mark all users who have posted on public channels during rollout period as greeted.
|
|
|
|
Args:
|
|
rollout_id: The ID of the active rollout.
|
|
"""
|
|
try:
|
|
with self.bot.db_manager.connection() 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_started_at is written by SQLite CURRENT_TIMESTAMP, i.e.
|
|
# naive UTC. datetime.timestamp() on a naive value assumes local
|
|
# time, which shifted the cutoff by the host's UTC offset and let
|
|
# early active users slip through the "already greeted" marking.
|
|
rollout_start = datetime.fromisoformat(result[0]).replace(tzinfo=timezone.utc)
|
|
|
|
# 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
|
|
# Store greeted_at in SQLite's own CURRENT_TIMESTAMP shape
|
|
# ("YYYY-MM-DD HH:MM:SS" UTC). isoformat()'s "T" separator
|
|
# sorts above a space, so mixing the two corrupts every
|
|
# ORDER BY greeted_at (duplicate cleanup, "recent greeted").
|
|
cursor.execute('''
|
|
INSERT OR IGNORE INTO greeted_users
|
|
(sender_id, channel, rollout_marked, greeted_at)
|
|
VALUES (?, ?, 1, ?)
|
|
''', (sender_id, mark_channel, rollout_start.strftime('%Y-%m-%d %H:%M:%S')))
|
|
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:
|
|
Dict[str, Any]: 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 self.bot.db_manager.connection() 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:
|
|
bool: 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 self.bot.db_manager.connection() 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 _levenshtein_distance(self, s1: str, s2: str) -> int:
|
|
"""Calculate Levenshtein distance between two strings.
|
|
|
|
Args:
|
|
s1: First string.
|
|
s2: Second string.
|
|
|
|
Returns:
|
|
int: Levenshtein distance (number of edits needed).
|
|
"""
|
|
if len(s1) < len(s2):
|
|
return self._levenshtein_distance(s2, s1)
|
|
|
|
if len(s2) == 0:
|
|
return len(s1)
|
|
|
|
previous_row = range(len(s2) + 1)
|
|
for i, c1 in enumerate(s1):
|
|
current_row = [i + 1]
|
|
for j, c2 in enumerate(s2):
|
|
insertions = previous_row[j + 1] + 1
|
|
deletions = current_row[j] + 1
|
|
substitutions = previous_row[j] + (c1 != c2)
|
|
current_row.append(min(insertions, deletions, substitutions))
|
|
previous_row = current_row
|
|
|
|
return previous_row[-1]
|
|
|
|
def _find_similar_greeted_user(self, sender_id: str, channel: str) -> Optional[str]:
|
|
"""Find if a user with a similar name has been greeted.
|
|
|
|
Args:
|
|
sender_id: The user's ID to check.
|
|
channel: The channel name (used only if per_channel_greetings is True).
|
|
|
|
Returns:
|
|
Optional[str]: The greeted sender_id if a similar one is found, None otherwise.
|
|
"""
|
|
if self.levenshtein_distance <= 0:
|
|
return None
|
|
|
|
try:
|
|
with self.bot.db_manager.connection() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
if self.per_channel_greetings:
|
|
# Per-channel mode: check greeted users on this specific channel
|
|
cursor.execute('''
|
|
SELECT DISTINCT sender_id FROM greeted_users
|
|
WHERE channel = ?
|
|
''', (channel,))
|
|
else:
|
|
# Global mode: check all greeted users (channel = NULL)
|
|
cursor.execute('''
|
|
SELECT DISTINCT sender_id FROM greeted_users
|
|
WHERE channel IS NULL
|
|
''')
|
|
|
|
greeted_users = cursor.fetchall()
|
|
|
|
# Check each greeted user for similarity
|
|
for (greeted_id,) in greeted_users:
|
|
distance = self._levenshtein_distance(sender_id.lower(), greeted_id.lower())
|
|
if distance <= self.levenshtein_distance:
|
|
self.logger.debug(f"Found similar user: {greeted_id} (distance: {distance} from {sender_id})")
|
|
return greeted_id
|
|
|
|
return None
|
|
except Exception as e:
|
|
self.logger.error(f"Error checking for similar greeted users: {e}")
|
|
return None
|
|
|
|
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:
|
|
bool: True if user has been greeted (globally or on this channel), False otherwise.
|
|
"""
|
|
try:
|
|
with self.bot.db_manager.connection() 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,))
|
|
|
|
if cursor.fetchone() is not None:
|
|
return True
|
|
|
|
# If exact match not found and Levenshtein distance is enabled, check for similar names
|
|
if self.levenshtein_distance > 0:
|
|
similar_user = self._find_similar_greeted_user(sender_id, channel)
|
|
if similar_user:
|
|
self.logger.info(f"User {sender_id} matches previously greeted user {similar_user} (Levenshtein distance enabled)")
|
|
return True
|
|
|
|
return False
|
|
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 atomically.
|
|
|
|
Uses INSERT OR IGNORE with UNIQUE constraint to handle race conditions.
|
|
|
|
Args:
|
|
sender_id: The user's ID.
|
|
channel: The channel name (stored only if per_channel_greetings is True).
|
|
|
|
Returns:
|
|
bool: True if user was marked (or already marked), False on error.
|
|
"""
|
|
try:
|
|
self.logger.debug(f"Marking {sender_id} as greeted (channel: {channel})")
|
|
|
|
with self.bot.db_manager.connection() as conn:
|
|
# Use WAL mode for better concurrency (if not already enabled)
|
|
# This helps with race conditions
|
|
conn.execute('PRAGMA journal_mode=WAL')
|
|
|
|
cursor = conn.cursor()
|
|
|
|
# Check if user is already greeted first to avoid unnecessary inserts
|
|
# This also helps us detect and handle any existing duplicates
|
|
if self.per_channel_greetings:
|
|
cursor.execute('''
|
|
SELECT id, greeted_at FROM greeted_users
|
|
WHERE sender_id = ? AND channel = ?
|
|
ORDER BY greeted_at ASC
|
|
LIMIT 1
|
|
''', (sender_id, channel))
|
|
existing = cursor.fetchone()
|
|
|
|
if existing:
|
|
# User already greeted - check if there are duplicates
|
|
cursor.execute('''
|
|
SELECT COUNT(*) FROM greeted_users
|
|
WHERE sender_id = ? AND channel = ?
|
|
''', (sender_id, channel))
|
|
count = cursor.fetchone()[0]
|
|
if count > 1:
|
|
# Duplicates exist - clean them up, keeping the earliest (first) greeting
|
|
cursor.execute('''
|
|
SELECT id FROM greeted_users
|
|
WHERE sender_id = ? AND channel = ?
|
|
ORDER BY greeted_at ASC
|
|
''', (sender_id, channel))
|
|
all_ids = [row[0] for row in cursor.fetchall()]
|
|
if len(all_ids) > 1:
|
|
# Delete all but the first (earliest)
|
|
placeholders = ','.join(['?'] * (len(all_ids) - 1))
|
|
cursor.execute(f'''
|
|
DELETE FROM greeted_users
|
|
WHERE id IN ({placeholders})
|
|
''', all_ids[1:])
|
|
conn.commit()
|
|
self.logger.debug(f"Cleaned up {len(all_ids) - 1} duplicate greeting entries for {sender_id} on {channel}, kept earliest")
|
|
self.logger.debug(f"User {sender_id} already greeted on channel {channel}")
|
|
return True
|
|
|
|
# User not greeted yet - insert
|
|
try:
|
|
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
|
|
except sqlite3.IntegrityError:
|
|
# Race condition - another process inserted it between our check and insert
|
|
# This is fine, the user is now greeted
|
|
conn.rollback()
|
|
self.logger.debug(f"User {sender_id} was marked as greeted by another process (race condition)")
|
|
return True
|
|
else:
|
|
# Global mode: store NULL for channel (greeted once globally)
|
|
cursor.execute('''
|
|
SELECT id, greeted_at FROM greeted_users
|
|
WHERE sender_id = ? AND channel IS NULL
|
|
ORDER BY greeted_at ASC
|
|
LIMIT 1
|
|
''', (sender_id,))
|
|
existing = cursor.fetchone()
|
|
|
|
if existing:
|
|
# User already greeted - check if there are duplicates
|
|
cursor.execute('''
|
|
SELECT COUNT(*) FROM greeted_users
|
|
WHERE sender_id = ? AND channel IS NULL
|
|
''', (sender_id,))
|
|
count = cursor.fetchone()[0]
|
|
if count > 1:
|
|
# Duplicates exist - clean them up, keeping the earliest (first) greeting
|
|
cursor.execute('''
|
|
SELECT id FROM greeted_users
|
|
WHERE sender_id = ? AND channel IS NULL
|
|
ORDER BY greeted_at ASC
|
|
''', (sender_id,))
|
|
all_ids = [row[0] for row in cursor.fetchall()]
|
|
if len(all_ids) > 1:
|
|
# Delete all but the first (earliest)
|
|
placeholders = ','.join(['?'] * (len(all_ids) - 1))
|
|
cursor.execute(f'''
|
|
DELETE FROM greeted_users
|
|
WHERE id IN ({placeholders})
|
|
''', all_ids[1:])
|
|
conn.commit()
|
|
self.logger.debug(f"Cleaned up {len(all_ids) - 1} duplicate greeting entries for {sender_id} (global), kept earliest")
|
|
self.logger.debug(f"User {sender_id} already greeted globally")
|
|
return True
|
|
|
|
# User not greeted yet - insert
|
|
try:
|
|
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
|
|
except sqlite3.IntegrityError:
|
|
# Race condition - another process inserted it between our check and insert
|
|
# This is fine, the user is now greeted
|
|
conn.rollback()
|
|
self.logger.debug(f"User {sender_id} was marked as greeted by another process (race condition)")
|
|
return True
|
|
|
|
except sqlite3.IntegrityError as e:
|
|
# UNIQUE constraint violation - should not happen with INSERT OR IGNORE
|
|
# but handle it gracefully if it does (means user already marked)
|
|
self.logger.debug(f"User {sender_id} already marked as greeted (integrity check: {e})")
|
|
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.
|
|
|
|
Returns:
|
|
int: The total count of greeted users.
|
|
"""
|
|
try:
|
|
with self.bot.db_manager.connection() 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 _cleanup_duplicate_greetings(self) -> None:
|
|
"""Remove duplicate entries from greeted_users table."""
|
|
try:
|
|
with self.bot.db_manager.connection() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# Find duplicates - count how many exist per (sender_id, channel)
|
|
cursor.execute('''
|
|
SELECT sender_id, channel, COUNT(*) as count
|
|
FROM greeted_users
|
|
GROUP BY sender_id, channel
|
|
HAVING COUNT(*) > 1
|
|
''')
|
|
duplicates = cursor.fetchall()
|
|
|
|
if duplicates:
|
|
self.logger.info(f"Found {len(duplicates)} duplicate greeting entries, cleaning up...")
|
|
|
|
# For each duplicate, keep only the earliest (first) entry
|
|
for sender_id, channel, _count in duplicates:
|
|
# Get all IDs for this (sender_id, channel) combination, ordered by earliest first
|
|
if channel:
|
|
cursor.execute('''
|
|
SELECT id, greeted_at
|
|
FROM greeted_users
|
|
WHERE sender_id = ? AND channel = ?
|
|
ORDER BY greeted_at ASC
|
|
''', (sender_id, channel))
|
|
else:
|
|
cursor.execute('''
|
|
SELECT id, greeted_at
|
|
FROM greeted_users
|
|
WHERE sender_id = ? AND channel IS NULL
|
|
ORDER BY greeted_at ASC
|
|
''', (sender_id,))
|
|
|
|
rows = cursor.fetchall()
|
|
if len(rows) > 1:
|
|
# Keep the first (earliest) one, delete the rest
|
|
keep_id = rows[0][0]
|
|
delete_ids = [row[0] for row in rows[1:]]
|
|
|
|
# Delete duplicates
|
|
placeholders = ','.join(['?'] * len(delete_ids))
|
|
cursor.execute(f'''
|
|
DELETE FROM greeted_users
|
|
WHERE id IN ({placeholders})
|
|
''', delete_ids)
|
|
|
|
self.logger.debug(f"Kept earliest greeting record {keep_id} for {sender_id} (channel: {channel or 'global'}), deleted {len(delete_ids)} duplicates")
|
|
|
|
conn.commit()
|
|
self.logger.info("Cleaned up duplicate greeting entries")
|
|
else:
|
|
self.logger.debug("No duplicate greeting entries found")
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error cleaning up duplicate greetings: {e}")
|
|
# Don't raise - allow initialization to continue even if cleanup fails
|
|
|
|
async def _get_mesh_info(self) -> dict[str, Any]:
|
|
"""Get mesh network information for greeting.
|
|
|
|
Returns:
|
|
Dict[str, Any]: A dictionary containing mesh statistics.
|
|
"""
|
|
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 self.bot.db_manager.connection() 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:
|
|
str: 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: Optional[str] = None, mesh_info: Optional[dict[str, Any]] = None) -> list[str]:
|
|
"""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[str]: 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)
|
|
greeting_parts = split_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:
|
|
self.logger.debug(f"Including mesh info. Format: {repr(self.mesh_info_format)}, Mesh info: {mesh_info}")
|
|
try:
|
|
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)
|
|
)
|
|
self.logger.debug(f"Formatted mesh info text: {repr(mesh_info_text)}")
|
|
# Append mesh info to the last greeting part
|
|
if formatted_parts:
|
|
formatted_parts[-1] += mesh_info_text
|
|
else:
|
|
formatted_parts.append(mesh_info_text)
|
|
except (KeyError, ValueError) as e:
|
|
self.logger.warning(f"Error formatting mesh info: {e}. Format string: {repr(self.mesh_info_format)}, Mesh info keys: {list(mesh_info.keys())}")
|
|
# Continue without mesh info rather than failing the entire greeting
|
|
except Exception as e:
|
|
self.logger.error(f"Unexpected error formatting mesh info: {e}", exc_info=True)
|
|
# Continue without mesh info rather than failing the entire greeting
|
|
else:
|
|
self.logger.debug("Mesh info not included (include_mesh_info is False)")
|
|
|
|
return formatted_parts
|
|
|
|
def matches_keyword(self, message: MeshMessage) -> bool:
|
|
"""Greeter doesn't match keywords - it's triggered automatically.
|
|
|
|
Args:
|
|
message: The message to check.
|
|
|
|
Returns:
|
|
bool: Always False.
|
|
"""
|
|
return False
|
|
|
|
def matches_custom_syntax(self, message: MeshMessage) -> bool:
|
|
"""Greeter doesn't match custom syntax.
|
|
|
|
Args:
|
|
message: The message to check.
|
|
|
|
Returns:
|
|
bool: Always False.
|
|
"""
|
|
return False
|
|
|
|
def _is_rollout_active(self) -> bool:
|
|
"""Check if there's an active rollout period.
|
|
|
|
Returns:
|
|
bool: True if a rollout is active, False otherwise.
|
|
"""
|
|
try:
|
|
with self.bot.db_manager.connection() 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
|
|
days_over = (current_time - end_date).total_seconds() / 86400
|
|
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}, {days_over:.1f} days ago)")
|
|
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 _mentions_user(self, content: str, sender_id: str) -> bool:
|
|
"""Whether ``content`` mentions ``sender_id``.
|
|
|
|
An exact mention always counts. With name fuzziness on, a whole word
|
|
within the edit distance also counts, so a typo such as "Chemx" for
|
|
"Chemex" is a mention. Fuzziness does not require the exact name to
|
|
already be present.
|
|
"""
|
|
if not content or not sender_id:
|
|
return False
|
|
text = content.lower()
|
|
name = sender_id.lower()
|
|
if name in text:
|
|
return True
|
|
if self.levenshtein_distance <= 0:
|
|
return False
|
|
for word in text.split():
|
|
word = word.strip('.,!?;:()[]{}@')
|
|
if word and self._levenshtein_distance(name, word) <= self.levenshtein_distance:
|
|
return True
|
|
return False
|
|
|
|
def _release_greeting_reservation(self, sender_id: str, channel: str) -> None:
|
|
"""Drop the greeted row when this attempt did not send.
|
|
|
|
``execute`` writes the row before sending so a second message does not
|
|
start another greeting. Leaving it in place after a skipped or failed
|
|
send makes the next message look already greeted.
|
|
"""
|
|
try:
|
|
with self.bot.db_manager.connection() as conn:
|
|
cursor = conn.cursor()
|
|
if self.per_channel_greetings:
|
|
cursor.execute(
|
|
'DELETE FROM greeted_users WHERE sender_id = ? AND channel = ?',
|
|
(sender_id, channel),
|
|
)
|
|
else:
|
|
cursor.execute(
|
|
'DELETE FROM greeted_users WHERE sender_id = ? AND channel IS NULL',
|
|
(sender_id,),
|
|
)
|
|
conn.commit()
|
|
if cursor.rowcount:
|
|
self.logger.info(
|
|
f"Greeting for {sender_id} was not sent; cleared the greeted mark"
|
|
)
|
|
except Exception as e:
|
|
self.logger.error(f"Error clearing greeted mark for {sender_id}: {e}")
|
|
|
|
def _check_human_greeting(self, new_user_id: str, channel: str, since_timestamp: int) -> bool:
|
|
"""Check if a human has greeted the new user.
|
|
|
|
Args:
|
|
new_user_id: The new user's ID to check for.
|
|
channel: The channel to check.
|
|
since_timestamp: Only check messages after this timestamp.
|
|
|
|
Returns:
|
|
bool: True if a human has mentioned the new user, False otherwise.
|
|
"""
|
|
if not self.defer_to_human_greeting:
|
|
return False
|
|
|
|
try:
|
|
with self.bot.db_manager.connection() 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 False
|
|
|
|
# Get recent messages from this channel since the new user posted
|
|
cursor.execute('''
|
|
SELECT sender_id, content
|
|
FROM message_stats
|
|
WHERE lower(channel) = lower(?)
|
|
AND timestamp >= ?
|
|
AND is_dm = 0
|
|
AND sender_id != ?
|
|
ORDER BY timestamp DESC
|
|
''', (channel, since_timestamp, new_user_id))
|
|
|
|
messages = cursor.fetchall()
|
|
|
|
for sender_id, content in messages:
|
|
if self._mentions_user(content, new_user_id):
|
|
self.logger.info(f"Human greeting detected: {sender_id} mentioned {new_user_id} in channel {channel}")
|
|
return True
|
|
|
|
return False
|
|
except Exception as e:
|
|
self.logger.error(f"Error checking for human greeting: {e}")
|
|
return False
|
|
|
|
def _cancel_pending_greeting(self, sender_id: str, channel: str) -> None:
|
|
"""Cancel a pending greeting if it exists.
|
|
|
|
Args:
|
|
sender_id: The user's ID.
|
|
channel: The channel name.
|
|
"""
|
|
key = (sender_id, channel)
|
|
if key in self.pending_greetings:
|
|
task = self.pending_greetings[key]
|
|
if not task.done():
|
|
task.cancel()
|
|
self.logger.info(f"Cancelled pending greeting for {sender_id} on {channel}")
|
|
del self.pending_greetings[key]
|
|
|
|
async def _send_delayed_greeting(self, message: MeshMessage) -> None:
|
|
"""Send a greeting after the dead air delay.
|
|
|
|
Args:
|
|
message: The original message that triggered the greeting.
|
|
"""
|
|
key = (message.sender_id, message.channel)
|
|
original_timestamp = message.timestamp or int(time.time())
|
|
|
|
try:
|
|
# Wait for the dead air delay
|
|
if self.dead_air_delay_seconds > 0:
|
|
self.logger.debug(f"Waiting {self.dead_air_delay_seconds} seconds before greeting {message.sender_id} on {message.channel}")
|
|
await asyncio.sleep(self.dead_air_delay_seconds)
|
|
|
|
if not getattr(self.bot, "channel_responses_enabled", True):
|
|
self.logger.info(
|
|
f"Skipping delayed greeting for {message.sender_id} on {message.channel} "
|
|
"(channel responses paused)"
|
|
)
|
|
if key in self.pending_greetings:
|
|
del self.pending_greetings[key]
|
|
self._release_greeting_reservation(message.sender_id, message.channel)
|
|
return
|
|
|
|
# Check if greeting was cancelled (human responded, or a newer wait replaced this one)
|
|
if key not in self.pending_greetings:
|
|
self.logger.debug(f"Greeting for {message.sender_id} on {message.channel} was cancelled")
|
|
return
|
|
|
|
# execute() writes the greeted row before this wait so a later message
|
|
# does not start a second greeting. That row is this greeting's
|
|
# reservation: has_been_greeted() is already true here, and treating
|
|
# it as "already sent" drops the greeting. A human welcome is checked
|
|
# next.
|
|
|
|
# If defer to human greeting is enabled, check if a human has greeted the user
|
|
# Check messages from the original timestamp onwards (during the delay period)
|
|
if self.defer_to_human_greeting and self.dead_air_delay_seconds > 0:
|
|
if self._check_human_greeting(message.sender_id, message.channel, original_timestamp):
|
|
self.logger.info(f"Deferring to human greeting for {message.sender_id} on {message.channel}")
|
|
# Mark as greeted so we don't greet them later
|
|
self.mark_as_greeted(message.sender_id, message.channel)
|
|
if key in self.pending_greetings:
|
|
del self.pending_greetings[key]
|
|
return
|
|
|
|
# Send the greeting. A failed send drops the reservation so the
|
|
# next message can try again.
|
|
if not await self._send_greeting(message):
|
|
self._release_greeting_reservation(message.sender_id, message.channel)
|
|
|
|
# Clean up
|
|
if key in self.pending_greetings:
|
|
del self.pending_greetings[key]
|
|
|
|
except asyncio.CancelledError:
|
|
self.logger.debug(f"Delayed greeting for {message.sender_id} on {message.channel} was cancelled")
|
|
# Clean up on cancellation
|
|
if key in self.pending_greetings:
|
|
del self.pending_greetings[key]
|
|
except Exception as e:
|
|
self.logger.error(f"Error in delayed greeting for {message.sender_id}: {e}")
|
|
if key in self.pending_greetings:
|
|
del self.pending_greetings[key]
|
|
self._release_greeting_reservation(message.sender_id, message.channel)
|
|
|
|
async def _send_greeting(self, message: MeshMessage) -> bool:
|
|
"""Actually send the greeting message.
|
|
|
|
Args:
|
|
message: The message that triggered the greeting.
|
|
|
|
Returns:
|
|
bool: True if greeting was sent successfully.
|
|
"""
|
|
try:
|
|
# 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 (rate-limit spacing handled by send_response_chunked)
|
|
success = await self.send_response_chunked(message, greeting_parts)
|
|
if not success:
|
|
self.logger.warning("Failed to send one or more greeting parts")
|
|
return success
|
|
except Exception as e:
|
|
self.logger.error(f"Error sending greeting: {e}")
|
|
return False
|
|
|
|
def should_execute(self, message: MeshMessage) -> bool:
|
|
"""Check if greeter should execute for this message.
|
|
|
|
Args:
|
|
message: The message to check.
|
|
|
|
Returns:
|
|
bool: True if the greeter should execute, False otherwise.
|
|
"""
|
|
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 channel access using standardized method (with case-insensitive fallback)
|
|
# First try standardized method (case-sensitive)
|
|
if not self.is_channel_allowed(message):
|
|
# If standardized check fails, try case-insensitive matching for backward compatibility
|
|
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
|
|
# Check if already greeted first to avoid misleading logs
|
|
if not self.has_been_greeted(message.sender_id, message.channel):
|
|
self.logger.info(f"🔄 Rollout active: Marking {message.sender_id} as greeted on {message.channel} (no greeting sent)")
|
|
else:
|
|
self.logger.debug(f"🔄 Rollout active: {message.sender_id} already greeted on {message.channel} (skipping)")
|
|
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)
|
|
return not self.has_been_greeted(message.sender_id, message.channel)
|
|
|
|
async def execute(self, message: MeshMessage) -> bool:
|
|
"""Execute the greeter command.
|
|
|
|
Args:
|
|
message: The message triggering the greeting.
|
|
|
|
Returns:
|
|
bool: True if executed successfully, False otherwise.
|
|
"""
|
|
try:
|
|
# Double-check we should greet (race condition protection)
|
|
if not self.should_execute(message):
|
|
return False
|
|
|
|
# Reserve the greeting before the wait. A second message during the
|
|
# delay then sees the user as greeted and does not schedule another
|
|
# one. The delayed sender still sends; this row is not a greeting
|
|
# already delivered.
|
|
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
|
|
|
|
# Final verification: Double-check that user hasn't been greeted by another process
|
|
# This is a last-ditch check to catch any race conditions
|
|
# The INSERT OR IGNORE in mark_as_greeted() should have handled this, but we verify
|
|
if self.has_been_greeted(message.sender_id, message.channel):
|
|
# User is marked - verify this is a fresh mark (not an old one)
|
|
# If the record was just created (within last 5 seconds), we likely created it
|
|
# If it's older, another process may have created it first
|
|
try:
|
|
with self.bot.db_manager.connection() as conn:
|
|
cursor = conn.cursor()
|
|
if self.per_channel_greetings:
|
|
cursor.execute('''
|
|
SELECT datetime(greeted_at) as greeted_at, datetime('now') as now
|
|
FROM greeted_users
|
|
WHERE sender_id = ? AND channel = ?
|
|
''', (message.sender_id, message.channel))
|
|
else:
|
|
cursor.execute('''
|
|
SELECT datetime(greeted_at) as greeted_at, datetime('now') as now
|
|
FROM greeted_users
|
|
WHERE sender_id = ? AND channel IS NULL
|
|
''', (message.sender_id,))
|
|
result = cursor.fetchone()
|
|
if result:
|
|
greeted_at_str, now_str = result
|
|
greeted_at = datetime.fromisoformat(greeted_at_str)
|
|
now = datetime.fromisoformat(now_str)
|
|
seconds_ago = (now - greeted_at).total_seconds()
|
|
|
|
# If marked more than 5 seconds ago, likely another process did it first
|
|
# (our mark_as_greeted should have just run, so it should be very recent)
|
|
if seconds_ago > 5:
|
|
self.logger.info(f"User {message.sender_id} was already greeted {seconds_ago:.1f}s ago by another process - aborting duplicate greeting")
|
|
return False
|
|
else:
|
|
self.logger.debug(f"User {message.sender_id} marked {seconds_ago:.1f}s ago - proceeding with greeting")
|
|
except Exception as e:
|
|
# If check fails, proceed anyway (better to greet than miss a greeting)
|
|
self.logger.debug(f"Could not verify greeting timestamp (proceeding anyway): {e}")
|
|
|
|
# Check if dead air delay is enabled
|
|
if self.dead_air_delay_seconds > 0:
|
|
# Schedule delayed greeting
|
|
key = (message.sender_id, message.channel)
|
|
|
|
# Cancel any existing pending greeting for this user/channel
|
|
if key in self.pending_greetings:
|
|
self._cancel_pending_greeting(message.sender_id, message.channel)
|
|
|
|
# Schedule new delayed greeting
|
|
task = asyncio.create_task(self._send_delayed_greeting(message))
|
|
self.pending_greetings[key] = task
|
|
self.logger.info(f"Scheduled delayed greeting for {message.sender_id} on {message.channel} (delay: {self.dead_air_delay_seconds}s)")
|
|
return True
|
|
else:
|
|
# Send greeting immediately (original behavior)
|
|
sent = await self._send_greeting(message)
|
|
if not sent:
|
|
self._release_greeting_reservation(message.sender_id, message.channel)
|
|
return sent
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error executing greeter command: {e}")
|
|
return False
|
|
|
|
def check_message_for_human_greeting(self, message: MeshMessage) -> None:
|
|
"""Check if an incoming message should cancel a pending greeting.
|
|
|
|
Args:
|
|
message: The incoming message to check.
|
|
"""
|
|
if not self.defer_to_human_greeting or not self.dead_air_delay_seconds > 0:
|
|
return
|
|
|
|
if message.is_dm or not message.channel:
|
|
return
|
|
|
|
# Check all pending greetings for this channel
|
|
keys_to_cancel = []
|
|
for (sender_id, channel), _task in list(self.pending_greetings.items()):
|
|
if (
|
|
message.channel
|
|
and channel.lower() == message.channel.lower()
|
|
and sender_id != message.sender_id
|
|
and self._mentions_user(message.content, sender_id)
|
|
):
|
|
self.logger.info(f"Human greeting detected in real-time: {message.sender_id} mentioned {sender_id} - cancelling pending greeting")
|
|
keys_to_cancel.append((sender_id, channel))
|
|
|
|
# Cancel the pending greetings
|
|
for key in keys_to_cancel:
|
|
self._cancel_pending_greeting(key[0], key[1])
|
|
# Mark as greeted so we don't greet them later
|
|
self.mark_as_greeted(key[0], key[1])
|
|
|
|
def get_help_text(self, message: MeshMessage | None = None) -> str:
|
|
"""Get help text for the greeter command.
|
|
|
|
Returns:
|
|
str: The help text for this command.
|
|
"""
|
|
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."
|
|
|
|
def get_recent_greeted_users(self, limit: int = 10) -> list[dict[str, Any]]:
|
|
"""Get recent greeted users.
|
|
|
|
Args:
|
|
limit: Maximum number of users to return.
|
|
|
|
Returns:
|
|
List[Dict[str, Any]]: A list of dictionaries containing greeted user info.
|
|
"""
|
|
try:
|
|
with self.bot.db_manager.connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT sender_id, channel, MIN(greeted_at) as greeted_at,
|
|
MAX(rollout_marked) as rollout_marked
|
|
FROM greeted_users
|
|
GROUP BY sender_id, channel
|
|
ORDER BY MIN(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 []
|
|
|