fix(greeter): send delayed welcomes and stop re-arming rollout

The dead-air path marked a new user greeted before the wait, then
skipped the send because that mark looked like a greeting already
delivered. A failed send and a paused channel left the same stuck
mark. Rollout also started again on a later boot, so people who
spoke during it were recorded and never welcomed.
This commit is contained in:
agessaman committed 2026-11-01 08:53:31 -08:00
1 parent b07b7af53e
commit 5c191e4638
4 files changed
+340 -82

No files matched your search

+5
View File
@@ -51,6 +51,11 @@ semantic versioning.
### Fixed
- The greeter's warmup (`rollout_days`) runs once, the first time the greeter starts. A restart more than a day after the previous warmup ended used to open another one, and anyone who spoke during it was marked greeted and never welcomed. Ending a warmup, or it expiring, no longer starts a new one on the next boot.
- A greeting that is not actually sent no longer leaves the person marked greeted. That covers a send that fails, and a dead-air wait that ends while channel replies are paused. The next message tries again. A wait cancelled because someone else mentioned them still counts as greeted. Name fuzziness now catches a typo of the new user's name during that wait; previously it only ran when the exact name was already in the message, and could then reject that exact mention.
- The greeter sends its welcome when `dead_air_delay_seconds` is greater than 0. It marked the new user as greeted before the wait, then the delayed sender saw that mark and skipped the message, so the user was recorded as greeted and never welcomed. The mark still stops a second message during the wait from starting another greeting. A wait cancelled because someone else mentioned them, or because channel replies are paused, still sends nothing. Anyone already recorded as greeted by this bug stays that way until marked ungreeted.
- The web viewer no longer reports an all-clear it can't back up. The dashboard strip showed a green "Online · radio ok" and Real-time a green "Listening" whenever the viewer answered, even with no bot running. Both now show one verdict from the bot's 30-second health heartbeat, its radio link and the offline and zombie flags, and `/api/banner-status` reports `bot_running`, `bot_last_seen`, `bot_started` and `radio_connected`. Uptime shows a dash while the bot isn't running.
- Dashboard change chips no longer show a day the bot recorded nothing as a 100% drop on every tile. When either compared day has no packets, adverts or messages, the chips stay neutral and a note names the day.
- Clearing the radio-offline flag or restarting after a zombie state no longer overwrites the banner, which used to come back with the stale success message if the radio dropped again on the same page.
+1
View File
@@ -1526,6 +1526,7 @@ mesh_info_format = \n\nMesh Info: {total_contacts} contacts, {repeaters} repeate
# to greet new users. This prevents greeting everyone on an established mesh.
# Set to 0 to disable rollout (will greet all new users immediately)
# Note: Use auto_backfill to mark historical users and shorten/eliminate rollout period
# The period runs once. A later restart does not start another one.
rollout_days = 7
# Auto-backfill from historical message_stats data
+98 -82
View File
@@ -8,7 +8,7 @@ import asyncio
import re
import sqlite3
import time
from datetime import datetime, timedelta, timezone
from datetime import datetime, timezone
from typing import Any, Optional
from ..models import MeshMessage
@@ -110,7 +110,8 @@ class GreeterCommand(BaseCommand):
], blank_default=DEFAULT_GREETING, preview="greeting")},
{"key": "rollout_days", "label": "Rollout period", "type": "int",
"min": 0, "default": 7, "unit": "days",
"help": "Days the greeter rollout runs before auto-ending."},
"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.",
@@ -204,45 +205,29 @@ class GreeterCommand(BaseCommand):
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:
# Rollout expired but not marked as completed - mark it and start new one
self.logger.warning(f"Found expired rollout (ID: {rollout_id}) - marking as completed and starting new one")
# 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()
self.logger.info(f"Auto-starting greeter rollout for {self.rollout_days} days")
self.start_rollout(backfill_first=self.auto_backfill)
else:
# No active rollout - check if one was recently completed to prevent immediate restart
# 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, rollout_started_at, rollout_days
FROM greeter_rollout
SELECT id FROM greeter_rollout
WHERE rollout_completed = 1
ORDER BY rollout_started_at DESC
LIMIT 1
''')
recent_rollout = cursor.fetchone()
if recent_rollout:
recent_id, recent_started_at_str, recent_rollout_days = recent_rollout
recent_started_at = datetime.fromisoformat(recent_started_at_str)
# Calculate when this rollout would have ended
recent_end_date = recent_started_at + timedelta(days=recent_rollout_days)
cursor.execute("SELECT datetime('now')")
current_time = datetime.fromisoformat(cursor.fetchone()[0])
# If rollout ended less than 1 day ago, don't auto-start a new one
# (prevents restart loops if there's a bug)
if current_time < recent_end_date + timedelta(days=1):
days_since_end = (current_time - recent_end_date).total_seconds() / 86400
self.logger.info(f"Recent rollout completed {days_since_end:.1f} days ago (ID: {recent_id}) - skipping auto-start to prevent restart loop")
return
# No active rollout and no recent completed 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)
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
@@ -415,7 +400,7 @@ class GreeterCommand(BaseCommand):
WHERE id = ?
''', (rollout_id,))
conn.commit()
self.logger.info(f"Greeter rollout period completed (ended {end_date}, {days_over:.1f} days ago) - will check for auto-restart")
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}")
@@ -1206,6 +1191,56 @@ class GreeterCommand(BaseCommand):
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.
@@ -1236,7 +1271,7 @@ class GreeterCommand(BaseCommand):
cursor.execute('''
SELECT sender_id, content
FROM message_stats
WHERE channel = ?
WHERE lower(channel) = lower(?)
AND timestamp >= ?
AND is_dm = 0
AND sender_id != ?
@@ -1245,25 +1280,10 @@ class GreeterCommand(BaseCommand):
messages = cursor.fetchall()
# Check if any message contains the new user's name
new_user_id_lower = new_user_id.lower()
for sender_id, content in messages:
if content and new_user_id_lower in content.lower():
# Also check with Levenshtein distance if enabled
if self.levenshtein_distance > 0:
# Check if any word in the message is within Levenshtein distance
words = content.lower().split()
for word in words:
# Remove common punctuation
word = word.strip('.,!?;:()[]{}@')
distance = self._levenshtein_distance(new_user_id_lower, word)
if distance <= self.levenshtein_distance:
self.logger.info(f"Human greeting detected: {sender_id} mentioned {new_user_id} in channel {channel}")
return True
else:
# Simple substring match
self.logger.info(f"Human greeting detected: {sender_id} mentioned {new_user_id} in channel {channel}")
return True
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:
@@ -1307,19 +1327,19 @@ class GreeterCommand(BaseCommand):
)
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 (user was already greeted or human responded)
# 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
# Check if we should still greet (user might have been greeted by another process)
if self.has_been_greeted(message.sender_id, message.channel):
self.logger.debug(f"User {message.sender_id} already greeted on {message.channel} - skipping")
if key in self.pending_greetings:
del self.pending_greetings[key]
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)
@@ -1332,8 +1352,10 @@ class GreeterCommand(BaseCommand):
del self.pending_greetings[key]
return
# Send the greeting
await self._send_greeting(message)
# 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:
@@ -1348,6 +1370,7 @@ class GreeterCommand(BaseCommand):
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.
@@ -1445,9 +1468,10 @@ class GreeterCommand(BaseCommand):
if not self.should_execute(message):
return False
# Mark as greeted BEFORE scheduling greeting (to prevent duplicate greetings)
# This ensures we don't greet the same user twice even if there's a delay
# mark_as_greeted uses atomic INSERT OR IGNORE to handle race conditions
# 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")
@@ -1509,7 +1533,10 @@ class GreeterCommand(BaseCommand):
return True
else:
# Send greeting immediately (original behavior)
return await self._send_greeting(message)
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}")
@@ -1530,25 +1557,14 @@ class GreeterCommand(BaseCommand):
# Check all pending greetings for this channel
keys_to_cancel = []
for (sender_id, channel), _task in list(self.pending_greetings.items()):
if channel == message.channel and sender_id != message.sender_id:
# Check if this message mentions the pending user
if message.content and sender_id.lower() in message.content.lower():
# Also check with Levenshtein distance if enabled
should_cancel = False
if self.levenshtein_distance > 0:
words = message.content.lower().split()
for word in words:
word = word.strip('.,!?;:()[]{}@')
distance = self._levenshtein_distance(sender_id.lower(), word)
if distance <= self.levenshtein_distance:
should_cancel = True
break
else:
should_cancel = True
if should_cancel:
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))
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:
+236
View File
@@ -0,0 +1,236 @@
"""Greeter reservation, warmup, and human-mention behavior."""
import asyncio
from unittest.mock import AsyncMock, patch
import pytest
from modules.commands.greeter_command import GreeterCommand
from modules.db_manager import DBManager
from tests.conftest import mock_message
def _greeter(bot, tmp_path, **settings: str) -> GreeterCommand:
bot.db_manager = DBManager(bot, str(tmp_path / "greeter.db"))
bot.channel_responses_enabled = True
bot.config.add_section("Greeter_Command")
values = {
"enabled": "true",
"rollout_days": "0",
"auto_backfill": "false",
"include_mesh_info": "false",
"dead_air_delay_seconds": "15",
"defer_to_human_greeting": "false",
"per_channel_greetings": "false",
"channels": "Public",
}
values.update(settings)
for key, value in values.items():
bot.config.set("Greeter_Command", key, value)
return GreeterCommand(bot)
def _first_message(sender_id: str = "Chemex"):
return mock_message(content=".", channel="Public", sender_id=sender_id, timestamp=1_700_000_000)
async def _finish_delayed(cmd: GreeterCommand, message):
"""Run a scheduled wait without sleeping, and return the send mock."""
send = AsyncMock(return_value=True)
with (
patch.object(cmd, "_send_greeting", send),
patch("modules.commands.greeter_command.asyncio.sleep", new_callable=AsyncMock),
):
scheduled = await cmd.execute(message)
task = cmd.pending_greetings.get((message.sender_id, message.channel))
if task is not None:
await task
return scheduled, send
class TestDeadAirDelay:
@pytest.mark.parametrize("per_channel", ["false", "true"])
async def test_delay_sends_the_greeting_it_reserved(self, command_mock_bot, tmp_path, per_channel):
cmd = _greeter(command_mock_bot, tmp_path, per_channel_greetings=per_channel)
message = _first_message()
scheduled, send = await _finish_delayed(cmd, message)
assert scheduled is True
send.assert_awaited_once_with(message)
assert cmd.has_been_greeted("Chemex", "Public") is True
assert cmd.pending_greetings == {}
async def test_second_message_during_the_wait_does_not_send_again(self, command_mock_bot, tmp_path):
cmd = _greeter(command_mock_bot, tmp_path)
message = _first_message()
send = AsyncMock(return_value=True)
with (
patch.object(cmd, "_send_greeting", send),
patch("modules.commands.greeter_command.asyncio.sleep", new_callable=AsyncMock),
):
assert await cmd.execute(message) is True
assert await cmd.execute(message) is False
await cmd.pending_greetings[(message.sender_id, message.channel)]
send.assert_awaited_once()
async def test_human_mention_cancels_the_wait(self, command_mock_bot, tmp_path):
cmd = _greeter(command_mock_bot, tmp_path, defer_to_human_greeting="true")
message = _first_message()
send = AsyncMock(return_value=True)
with (
patch.object(cmd, "_send_greeting", send),
patch("modules.commands.greeter_command.asyncio.sleep", new_callable=AsyncMock),
):
assert await cmd.execute(message) is True
task = cmd.pending_greetings[(message.sender_id, message.channel)]
cmd.check_message_for_human_greeting(
mock_message(content="welcome Chemex", channel="Public", sender_id="Ada", timestamp=1_700_000_001)
)
with pytest.raises(asyncio.CancelledError):
await task
send.assert_not_awaited()
assert cmd.has_been_greeted("Chemex", "Public") is True
async def test_human_mention_found_after_the_wait_skips_the_send(self, command_mock_bot, tmp_path):
cmd = _greeter(command_mock_bot, tmp_path, defer_to_human_greeting="true")
message = _first_message()
send = AsyncMock(return_value=True)
with (
patch.object(cmd, "_send_greeting", send),
patch.object(cmd, "_check_human_greeting", return_value=True),
patch("modules.commands.greeter_command.asyncio.sleep", new_callable=AsyncMock),
):
assert await cmd.execute(message) is True
await cmd.pending_greetings[(message.sender_id, message.channel)]
send.assert_not_awaited()
assert cmd.has_been_greeted("Chemex", "Public") is True
async def test_already_greeted_user_is_not_scheduled(self, command_mock_bot, tmp_path):
cmd = _greeter(command_mock_bot, tmp_path)
assert cmd.mark_as_greeted("Chemex", "Public") is True
send = AsyncMock(return_value=True)
with patch.object(cmd, "_send_greeting", send):
assert await cmd.execute(_first_message()) is False
send.assert_not_awaited()
assert cmd.pending_greetings == {}
async def test_zero_delay_sends_immediately(self, command_mock_bot, tmp_path):
cmd = _greeter(command_mock_bot, tmp_path, dead_air_delay_seconds="0")
message = _first_message()
send = AsyncMock(return_value=True)
with patch.object(cmd, "_send_greeting", send):
assert await cmd.execute(message) is True
send.assert_awaited_once_with(message)
assert cmd.pending_greetings == {}
class TestReservationRelease:
async def test_paused_replies_clear_the_reservation(self, command_mock_bot, tmp_path):
cmd = _greeter(command_mock_bot, tmp_path)
message = _first_message()
async def pause(_seconds):
cmd.bot.channel_responses_enabled = False
send = AsyncMock(return_value=True)
with (
patch.object(cmd, "_send_greeting", send),
patch("modules.commands.greeter_command.asyncio.sleep", side_effect=pause),
):
assert await cmd.execute(message) is True
await cmd.pending_greetings[(message.sender_id, message.channel)]
send.assert_not_awaited()
assert cmd.has_been_greeted("Chemex", "Public") is False
assert cmd.pending_greetings == {}
async def test_failed_send_can_be_retried(self, command_mock_bot, tmp_path):
cmd = _greeter(command_mock_bot, tmp_path, dead_air_delay_seconds="0")
message = _first_message()
send = AsyncMock(return_value=False)
with patch.object(cmd, "_send_greeting", send):
assert await cmd.execute(message) is False
assert cmd.has_been_greeted("Chemex", "Public") is False
send.return_value = True
assert await cmd.execute(message) is True
assert cmd.has_been_greeted("Chemex", "Public") is True
assert send.await_count == 2
async def test_error_during_the_wait_clears_the_reservation(self, command_mock_bot, tmp_path):
cmd = _greeter(command_mock_bot, tmp_path, defer_to_human_greeting="true")
message = _first_message()
send = AsyncMock(return_value=True)
with (
patch.object(cmd, "_send_greeting", send),
patch.object(cmd, "_check_human_greeting", side_effect=RuntimeError("db")),
patch("modules.commands.greeter_command.asyncio.sleep", new_callable=AsyncMock),
):
assert await cmd.execute(message) is True
await cmd.pending_greetings[(message.sender_id, message.channel)]
send.assert_not_awaited()
assert cmd.has_been_greeted("Chemex", "Public") is False
class TestRolloutOnce:
def test_a_completed_rollout_is_not_started_again(self, command_mock_bot, tmp_path):
command_mock_bot.channel_responses_enabled = True
command_mock_bot.db_manager = DBManager(command_mock_bot, str(tmp_path / "greeter.db"))
command_mock_bot.config.add_section("Greeter_Command")
command_mock_bot.config.set("Greeter_Command", "enabled", "true")
command_mock_bot.config.set("Greeter_Command", "rollout_days", "7")
command_mock_bot.config.set("Greeter_Command", "auto_backfill", "false")
command_mock_bot.config.set("Greeter_Command", "channels", "Public")
GreeterCommand(command_mock_bot)
with command_mock_bot.db_manager.connection() as conn:
started = conn.execute("SELECT rollout_completed FROM greeter_rollout").fetchall()
assert [row[0] for row in started] == [0]
with command_mock_bot.db_manager.connection() as conn:
conn.execute("UPDATE greeter_rollout SET rollout_completed = 1")
conn.commit()
GreeterCommand(command_mock_bot)
with command_mock_bot.db_manager.connection() as conn:
again = conn.execute(
"SELECT rollout_completed FROM greeter_rollout ORDER BY id"
).fetchall()
assert [row[0] for row in again] == [1]
class TestHumanMention:
def test_fuzziness_adds_typos_without_dropping_an_exact_mention(self, command_mock_bot, tmp_path):
cmd = _greeter(command_mock_bot, tmp_path)
cmd.levenshtein_distance = 1
assert cmd._mentions_user("welcome Chemx", "Chemex") is True
assert cmd._mentions_user("welcome Chemex", "Chemex") is True
assert cmd._mentions_user("welcome Bob", "Chemex") is False
cmd.levenshtein_distance = 0
assert cmd._mentions_user("welcome Chemx", "Chemex") is False
assert cmd._mentions_user("welcome Chemex", "Chemex") is True
async def test_realtime_cancel_is_case_insensitive_and_fuzzy(self, command_mock_bot, tmp_path):
cmd = _greeter(command_mock_bot, tmp_path, defer_to_human_greeting="true")
cmd.levenshtein_distance = 1
message = _first_message()
send = AsyncMock(return_value=True)
with (
patch.object(cmd, "_send_greeting", send),
patch("modules.commands.greeter_command.asyncio.sleep", new_callable=AsyncMock),
):
assert await cmd.execute(message) is True
task = cmd.pending_greetings[(message.sender_id, message.channel)]
cmd.check_message_for_human_greeting(
mock_message(content="welcome Chemx", channel="public", sender_id="Ada", timestamp=1_700_000_001)
)
with pytest.raises(asyncio.CancelledError):
await task
send.assert_not_awaited()
assert cmd.has_been_greeted("Chemex", "Public") is True