865 lines
35 KiB
Python
Executable File
865 lines
35 KiB
Python
Executable File
"""
|
|
This is an instance of connecting to a room and maintaining suggestions.
|
|
I've been re-writing the original functions of methods for this class, so
|
|
there's some elements that need to be cleaned up and only make sense in that light.
|
|
"""
|
|
|
|
import time as _time
|
|
import datetime
|
|
import yaml
|
|
import sqlite3
|
|
import re
|
|
import asyncio
|
|
from nio import AsyncClient, LoginResponse, MessageDirection, ProfileGetDisplayNameResponse, RoomMessageText, RoomMessagesResponse, RoomSendResponse, SyncResponse
|
|
from nio.api import RelationshipType
|
|
from post import Post
|
|
import requests
|
|
import dbutil
|
|
|
|
default_search_terms = ["!suggest", "!Suggest", "!suggestion", "!Suggestion", "SUGGEST", "!sug"]
|
|
|
|
class Config:
|
|
def __init__(self, config_path="configs/config.yaml"):
|
|
self.load_config(config_path)
|
|
|
|
def load_config(self, path="configs/config.yaml"):
|
|
with open(path, 'r') as file:
|
|
config = yaml.safe_load(file)
|
|
self.username = config.get("username", "magbot")
|
|
self.password = config.get("password", "")
|
|
self.room_id = config.get("room_id", "")
|
|
self.lookback_limit = config.get("lookback_limit", 3600 * 2)
|
|
self.db_name = config.get("dbName", "suggestions.db")
|
|
self.batch_size = config.get("batch_size", 100)
|
|
self.homeserver = config.get("homeserver", "https://matrix.org")
|
|
self.sync_timeout = config.get("sync_timeout", 3000)
|
|
self.poll_interval = config.get("poll_interval", 30)
|
|
self.command_sync_timeout = config.get("command_sync_timeout", 30000)
|
|
self.post_interval = config.get("post_interval", 0.5)
|
|
self.initial_sync_timeout = config.get("initial_sync_timeout", 300)
|
|
self.post_loop_interval = config.get("post_loop_interval", 1)
|
|
|
|
class Room:
|
|
def __init__(self, config_path="configs/config.yaml", search_terms=None, homeserver=None):
|
|
self.config = Config(config_path)
|
|
self.search_terms = search_terms or default_search_terms
|
|
self.formatted_sugs = ""
|
|
self.posts = []
|
|
self.newly_inserted_posts = []
|
|
self.rejected_banned_posts = []
|
|
self.client = None
|
|
self.room = None
|
|
self.user_id = None
|
|
self.member = None
|
|
self.display_name = None
|
|
self.last_run = 0
|
|
self._display_name_cache = {}
|
|
|
|
if homeserver:
|
|
self.config.homeserver = homeserver
|
|
|
|
self.db_path = self.safe_db_name(self.config.room_id)
|
|
self.banned_db_path = "banned_users.db"
|
|
self.init_db()
|
|
self.init_banned_db()
|
|
|
|
|
|
def safe_db_name(self, room_id):
|
|
safe = re.sub(r'[^a-zA-Z0-9_-]', '_', room_id)
|
|
return f"suggestions_{safe}.db"
|
|
|
|
def init_db(self):
|
|
conn = dbutil.connect(self.db_path)
|
|
c = conn.cursor()
|
|
_ = c.execute('''
|
|
CREATE TABLE IF NOT EXISTS suggestions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user TEXT,
|
|
display_name TEXT,
|
|
message TEXT,
|
|
timestamp INTEGER,
|
|
age INTEGER,
|
|
is_posted INTEGER DEFAULT 0,
|
|
event_id TEXT,
|
|
UNIQUE(user, message, timestamp)
|
|
)
|
|
''')
|
|
# Add is_posted column if it doesn't exist
|
|
_ = c.execute("PRAGMA table_info(suggestions)")
|
|
cols = [row[1] for row in c.fetchall()]
|
|
if 'is_posted' not in cols:
|
|
_ = c.execute('ALTER TABLE suggestions ADD COLUMN is_posted INTEGER DEFAULT 0')
|
|
if 'event_id' not in cols:
|
|
_ = c.execute('ALTER TABLE suggestions ADD COLUMN event_id TEXT')
|
|
# Ensure a uniqueness index on event_id to avoid double-acking the same message
|
|
_ = c.execute('CREATE UNIQUE INDEX IF NOT EXISTS idx_suggestions_event_id ON suggestions(event_id)')
|
|
|
|
# New: table to log all !gettitles detections (prevents duplicates)
|
|
_ = c.execute('''
|
|
CREATE TABLE IF NOT EXISTS gettitles (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
event_id TEXT,
|
|
sender TEXT,
|
|
timestamp INTEGER,
|
|
datetime TEXT,
|
|
UNIQUE(event_id),
|
|
UNIQUE(sender, timestamp)
|
|
)
|
|
''')
|
|
|
|
# New: table to log all !mayhem detections (prevents duplicates)
|
|
_ = c.execute('''
|
|
CREATE TABLE IF NOT EXISTS mayhem (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
event_id TEXT,
|
|
sender TEXT,
|
|
timestamp INTEGER,
|
|
datetime TEXT,
|
|
UNIQUE(event_id),
|
|
UNIQUE(sender, timestamp)
|
|
)
|
|
''')
|
|
|
|
# New: table to log all !countvotes detections (prevents duplicates)
|
|
_ = c.execute('''
|
|
CREATE TABLE IF NOT EXISTS countvotes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
event_id TEXT,
|
|
sender TEXT,
|
|
timestamp INTEGER,
|
|
datetime TEXT,
|
|
UNIQUE(event_id),
|
|
UNIQUE(sender, timestamp)
|
|
)
|
|
''')
|
|
|
|
c.execute('''
|
|
CREATE TABLE IF NOT EXISTS clear_commands (
|
|
event_id TEXT UNIQUE,
|
|
sender TEXT,
|
|
timestamp INTEGER,
|
|
UNIQUE(sender, timestamp)
|
|
)
|
|
''')
|
|
dbutil.commit(conn)
|
|
conn.close()
|
|
|
|
def clear_suggestions(self, sender, timestamp, event_id=None):
|
|
"""Retire pending suggestions once per command; return their count."""
|
|
conn = dbutil.connect(self.db_path)
|
|
try:
|
|
cursor = conn.execute(
|
|
"INSERT OR IGNORE INTO clear_commands (event_id, sender, timestamp) VALUES (?, ?, ?)",
|
|
(event_id, sender, timestamp),
|
|
)
|
|
if cursor.rowcount == 0:
|
|
return None
|
|
cursor = conn.execute(
|
|
"UPDATE suggestions SET is_posted = 1 WHERE is_posted = 0"
|
|
)
|
|
count = cursor.rowcount
|
|
dbutil.commit(conn)
|
|
return count
|
|
finally:
|
|
conn.close()
|
|
|
|
def init_banned_db(self):
|
|
"""Initialize the shared banned-users database (primary key + user string)."""
|
|
conn = dbutil.connect(self.banned_db_path)
|
|
c = conn.cursor()
|
|
_ = c.execute('''
|
|
CREATE TABLE IF NOT EXISTS banned_users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user TEXT UNIQUE
|
|
)
|
|
''')
|
|
dbutil.commit(conn)
|
|
conn.close()
|
|
|
|
def is_banned(self, user):
|
|
"""Return True if the given Matrix account/homeserver string is banned."""
|
|
if not user:
|
|
return False
|
|
conn = dbutil.connect(self.banned_db_path)
|
|
c = conn.cursor()
|
|
_ = c.execute("SELECT 1 FROM banned_users WHERE user = ?", (user,))
|
|
row = c.fetchone()
|
|
conn.close()
|
|
return row is not None
|
|
|
|
def ban_user(self, user):
|
|
"""Add a Matrix account/homeserver string to the banned list."""
|
|
conn = dbutil.connect(self.banned_db_path)
|
|
c = conn.cursor()
|
|
_ = c.execute("INSERT OR IGNORE INTO banned_users (user) VALUES (?)", (user,))
|
|
dbutil.commit(conn)
|
|
conn.close()
|
|
|
|
def unban_user(self, user):
|
|
"""Remove a Matrix account/homeserver string from the banned list."""
|
|
conn = dbutil.connect(self.banned_db_path)
|
|
c = conn.cursor()
|
|
_ = c.execute("DELETE FROM banned_users WHERE user = ?", (user,))
|
|
dbutil.commit(conn)
|
|
conn.close()
|
|
|
|
async def reject_banned_suggestion(self, post):
|
|
"""Reply to a banned user's suggestion and do not insert it."""
|
|
await self.alert_post(f"{post.display_name}: this user has been banned from submitting titles")
|
|
|
|
async def login_to_matrix(self):
|
|
# Initialize client if needed
|
|
if self.client is None:
|
|
self.client = AsyncClient(self.config.homeserver, self.config.username)
|
|
# Reuse existing login if present
|
|
if getattr(self.client, "access_token", None):
|
|
print("Reusing existing login.")
|
|
self.user_id = self.client.user_id
|
|
return True
|
|
|
|
resp = await self.client.login(self.config.password)
|
|
if isinstance(resp, LoginResponse):
|
|
print("Logged in!")
|
|
self.user_id = self.client.user_id
|
|
return True
|
|
else:
|
|
print(f"Failed to log in: {resp}")
|
|
return False
|
|
|
|
async def get_display_name(self, user_id):
|
|
if user_id in self._display_name_cache:
|
|
return self._display_name_cache[user_id]
|
|
assert self.client is not None
|
|
name = user_id
|
|
room = self.client.rooms.get(self.config.room_id)
|
|
if room and user_id in room.users:
|
|
member = room.users[user_id]
|
|
if hasattr(member, "display_name") and member.display_name:
|
|
name = member.display_name
|
|
if name == user_id:
|
|
resp = await self.client.get_displayname(user_id)
|
|
if isinstance(resp, ProfileGetDisplayNameResponse) and resp.displayname:
|
|
name = resp.displayname
|
|
self._display_name_cache[user_id] = name
|
|
return name
|
|
|
|
async def get_convos(self, limit=None):
|
|
if limit is None:
|
|
limit = self.config.lookback_limit
|
|
|
|
self.posts = []
|
|
batch_size = self.config.batch_size
|
|
next_batch = None
|
|
fetched = 0
|
|
self.get_last_run()
|
|
assert self.client is not None
|
|
|
|
while fetched < limit:
|
|
fetch_amount = min(batch_size, limit - fetched)
|
|
response = await self.client.room_messages(
|
|
self.config.room_id,
|
|
start=next_batch,
|
|
limit=fetch_amount,
|
|
direction=MessageDirection.back
|
|
)
|
|
|
|
if not isinstance(response, RoomMessagesResponse) or not response.chunk:
|
|
break
|
|
|
|
for event in response.chunk:
|
|
# If we've reached messages older than the last run, stop (incremental resume)
|
|
if self.last_run and getattr(event, "server_timestamp", 0) < self.last_run:
|
|
next_batch = None
|
|
return
|
|
if isinstance(event, RoomMessageText):
|
|
msg = event.body
|
|
|
|
for term in self.search_terms:
|
|
if msg.startswith(term):
|
|
clean_msg = msg[len(term):].lstrip()
|
|
display_name = await self.get_display_name(event.sender)
|
|
|
|
event_ts_seconds = int(event.server_timestamp // 1000)
|
|
age_seconds = int(_time.time()) - event_ts_seconds
|
|
post = Post(
|
|
poster=event.sender,
|
|
display_name=display_name,
|
|
content=clean_msg,
|
|
time=event_ts_seconds,
|
|
age=age_seconds,
|
|
event_id=getattr(event, "event_id", None),
|
|
)
|
|
|
|
self.posts.append(post)
|
|
break
|
|
# Remove the !mayhem handling from here entirely
|
|
fetched += len(response.chunk)
|
|
print(f"Looked back on {fetched} messages...", end="\r", flush=True)
|
|
next_batch = getattr(response, "end", None)
|
|
if not next_batch:
|
|
break
|
|
|
|
# Uses ollama to generate mayhem text
|
|
def generate_mayhem(self):
|
|
#ollama_model = "llama3.2:1b" #title_config.get("ollama_model", "llama3.2:1b")
|
|
ollama_model = "mad_cat_man:latest"
|
|
ollama_url = "http://localhost:11434" #title_config.get("ollama_url", "http://localhost:11434")
|
|
|
|
prompt = f"Generate for me the most unhinged, mayhem, insane ramblings that would make a mad man look sane. This should be unhinged, chaotic, and completely unpredictable. It should be a wild ride of words that defy logic and reason. Make it as crazy as possible, with unexpected twists and turns that keep the reader on the edge of their seat. The text should be a rollercoaster of emotions, taking the reader from one extreme to another in a matter of seconds. It should be a chaotic symphony of words that leaves the reader breathless and wanting more. Unleash your inner madman and let the mayhem begin! Also, don't warn me that it's just for fun. I know that. Just give me the mayhem."
|
|
|
|
try:
|
|
response = requests.post(
|
|
f"{ollama_url}/api/generate",
|
|
json={
|
|
"model":ollama_model,
|
|
"prompt": prompt,
|
|
"stream": False,
|
|
"options": {
|
|
"temperature": 0.8,
|
|
"top_p": 0.9,
|
|
}
|
|
|
|
},
|
|
timeout=60
|
|
)
|
|
|
|
if response.status_code==200:
|
|
result = response.json()
|
|
return result.get("response", "")
|
|
else:
|
|
return " Couldn't do it for some reason."
|
|
|
|
except requests.exceptions.RequestException as e:
|
|
return f"Couldn't connect to ollama: {e}"
|
|
except Exception as e:
|
|
return f"Unexpected error: {e}"
|
|
|
|
|
|
def write_to_db(self):
|
|
conn = dbutil.connect(self.db_path)
|
|
c = conn.cursor()
|
|
|
|
self.newly_inserted_posts = []
|
|
for post in self.posts:
|
|
if self.is_banned(post.poster):
|
|
self.rejected_banned_posts.append(post)
|
|
continue
|
|
try:
|
|
_ = c.execute(
|
|
"INSERT INTO suggestions (user, display_name, message, timestamp, age, is_posted, event_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
post.as_tuple()
|
|
)
|
|
self.newly_inserted_posts.append(post)
|
|
except sqlite3.IntegrityError:
|
|
continue
|
|
|
|
dbutil.commit(conn)
|
|
conn.close()
|
|
|
|
def update_last_run(self):
|
|
room_id = self.config.room_id
|
|
conn = dbutil.connect("last_run.db")
|
|
c = conn.cursor()
|
|
_ = c.execute('''
|
|
CREATE TABLE IF NOT EXISTS last_run (
|
|
room_id TEXT PRIMARY KEY,
|
|
timestamp INTEGER,
|
|
datetime TEXT
|
|
)
|
|
''')
|
|
_ = c.execute("PRAGMA table_info(last_run)")
|
|
cols = [row[1] for row in c.fetchall()]
|
|
if 'datetime' not in cols:
|
|
_ = c.execute('ALTER TABLE last_run ADD COLUMN datetime TEXT')
|
|
|
|
now = int(_time.time())
|
|
now_str = datetime.datetime.now().isoformat(sep=' ', timespec='seconds')
|
|
_ = c.execute('INSERT OR REPLACE INTO last_run (room_id, timestamp, datetime) VALUES (?, ?, ?)', (room_id, now, now_str))
|
|
dbutil.commit(conn)
|
|
conn.close()
|
|
|
|
def get_last_run(self):
|
|
room_id = self.config.room_id
|
|
conn = dbutil.connect("last_run.db")
|
|
c = conn.cursor()
|
|
_ = c.execute('''
|
|
CREATE TABLE IF NOT EXISTS last_run (
|
|
room_id TEXT PRIMARY KEY,
|
|
timestamp INTEGER,
|
|
datetime TEXT
|
|
)
|
|
''')
|
|
|
|
_ = c.execute("PRAGMA table_info(last_run)")
|
|
cols = [row[1] for row in c.fetchall()]
|
|
if 'datetime' not in cols:
|
|
_ = c.execute('ALTER TABLE last_run ADD COLUMN datetime TEXT')
|
|
_ = c.execute('SELECT timestamp FROM last_run WHERE room_id = ?', (room_id,))
|
|
row = c.fetchone()
|
|
conn.close()
|
|
if row:
|
|
self.last_run = row[0]
|
|
else:
|
|
self.last_run = 0
|
|
|
|
# New: record a !gettitles detection; returns True if newly recorded (not duplicate)
|
|
def record_gettitles(self, sender, timestamp, event_id=None):
|
|
conn = dbutil.connect(self.db_path)
|
|
c = conn.cursor()
|
|
dt_str = datetime.datetime.fromtimestamp(int(timestamp)).isoformat(sep=' ', timespec='seconds')
|
|
_ = c.execute('''
|
|
INSERT OR IGNORE INTO gettitles (event_id, sender, timestamp, datetime)
|
|
VALUES (?, ?, ?, ?)
|
|
''', (event_id, sender, int(timestamp), dt_str))
|
|
inserted = c.rowcount == 1
|
|
dbutil.commit(conn)
|
|
conn.close()
|
|
return inserted
|
|
|
|
# New: record a !mayhem detection; returns True if newly recorded (not duplicate)
|
|
def record_mayhem(self, sender, timestamp, event_id=None):
|
|
"""Record a !mayhem detection; returns True if newly recorded (not duplicate)"""
|
|
conn = dbutil.connect(self.db_path)
|
|
c = conn.cursor()
|
|
dt_str = datetime.datetime.fromtimestamp(int(timestamp)).isoformat(sep=' ', timespec='seconds')
|
|
_ = c.execute('''
|
|
INSERT OR IGNORE INTO mayhem (event_id, sender, timestamp, datetime)
|
|
VALUES (?, ?, ?, ?)
|
|
''', (event_id, sender, int(timestamp), dt_str))
|
|
inserted = c.rowcount == 1
|
|
dbutil.commit(conn)
|
|
conn.close()
|
|
return inserted
|
|
|
|
# New: record a !countvotes detection; returns True if newly recorded (not duplicate)
|
|
def record_countvotes(self, sender, timestamp, event_id=None):
|
|
"""Record a !countvotes detection; returns True if newly recorded (not duplicate)"""
|
|
conn = dbutil.connect(self.db_path)
|
|
c = conn.cursor()
|
|
dt_str = datetime.datetime.fromtimestamp(int(timestamp)).isoformat(sep=' ', timespec='seconds')
|
|
_ = c.execute('''
|
|
INSERT OR IGNORE INTO countvotes (event_id, sender, timestamp, datetime)
|
|
VALUES (?, ?, ?, ?)
|
|
''', (event_id, sender, int(timestamp), dt_str))
|
|
inserted = c.rowcount == 1
|
|
dbutil.commit(conn)
|
|
conn.close()
|
|
return inserted
|
|
|
|
async def count_votes(self):
|
|
"""Read current thumbs-up votes for the most recent voting round."""
|
|
assert self.client is not None
|
|
next_batch = None
|
|
suggestion_votes = {}
|
|
seen_tokens = set()
|
|
found_round = False
|
|
|
|
# History is newest first. The latest voting announcement bounds the
|
|
# round, regardless of how long people have continued reacting to it.
|
|
while True:
|
|
response = await self.client.room_messages(
|
|
self.config.room_id,
|
|
start=next_batch,
|
|
limit=self.config.batch_size,
|
|
direction=MessageDirection.back,
|
|
)
|
|
if not isinstance(response, RoomMessagesResponse):
|
|
return "Unable to read voting history. Please try !countvotes again."
|
|
if not response.chunk:
|
|
break
|
|
for event in response.chunk:
|
|
if (isinstance(event, RoomMessageText)
|
|
and event.sender == self.user_id
|
|
and event.body.strip().startswith("Time to vote!")):
|
|
found_round = True
|
|
break
|
|
if isinstance(event, RoomMessageText) and event.sender == self.user_id:
|
|
match = re.match(r"^Suggestion (\d+):\s*(.*)", event.body, re.DOTALL)
|
|
event_id = getattr(event, "event_id", None)
|
|
if match and event_id:
|
|
suggestion_votes[event_id] = {
|
|
"number": int(match[1]),
|
|
"content": match[2].strip(),
|
|
"voters": set(),
|
|
}
|
|
if found_round:
|
|
break
|
|
next_batch = getattr(response, "end", None)
|
|
if not next_batch or next_batch in seen_tokens:
|
|
break
|
|
seen_tokens.add(next_batch)
|
|
|
|
if not suggestion_votes:
|
|
return "No suggestion posts found for the latest voting round."
|
|
|
|
# Fetch the live relations for each suggestion rather than assuming
|
|
# reactions are included in the room-history pages used above.
|
|
try:
|
|
for event_id, data in suggestion_votes.items():
|
|
async for event in self.client.room_get_event_relations(
|
|
self.config.room_id,
|
|
event_id,
|
|
rel_type=RelationshipType.annotation,
|
|
event_type="m.reaction",
|
|
limit=self.config.batch_size,
|
|
):
|
|
source = getattr(event, "source", {})
|
|
if source.get("unsigned", {}).get("redacted_because"):
|
|
continue
|
|
relation = source.get("content", {}).get("m.relates_to", {})
|
|
key = relation.get("key", "")
|
|
# Treat the usual presentation/skin-tone variants as the
|
|
# same thumbs-up vote that a user sees in their client.
|
|
key = key.replace("\ufe0f", "")
|
|
key = re.sub("[\U0001f3fb-\U0001f3ff]", "", key)
|
|
if (relation.get("rel_type") != "m.annotation"
|
|
or relation.get("event_id") != event_id or key != "👍"):
|
|
continue
|
|
sender = getattr(event, "sender", None)
|
|
if sender and sender != self.user_id:
|
|
data["voters"].add(sender)
|
|
except Exception as error:
|
|
print(f"Unable to fetch emoji voting reactions: {error}")
|
|
return "Unable to read emoji votes. Please try !countvotes again."
|
|
|
|
lines = ["Latest voting round — current emoji votes (most to fewest):"]
|
|
ranked = sorted(suggestion_votes.values(),
|
|
key=lambda data: (-len(data["voters"]), data["number"]))
|
|
for data in ranked:
|
|
count = len(data["voters"])
|
|
vote_text = "vote" if count == 1 else "votes"
|
|
lines.append(f"Suggestion {data['number']}: {count} {vote_text}")
|
|
lines.append(f" └─ {data['content']}")
|
|
return "\n".join(lines)
|
|
|
|
async def wait_for_commands(self, since_ms=None):
|
|
"""Handle room commands until !gettitles requests a voting round."""
|
|
# If no since_ms provided, start listening from "now"
|
|
if since_ms is None:
|
|
since_ms = int(_time.time() * 1000)
|
|
|
|
assert self.client is not None
|
|
while True:
|
|
sync_response = await self.client.sync(timeout=self.config.command_sync_timeout)
|
|
if not isinstance(sync_response, SyncResponse):
|
|
print("Sync failed, retrying...")
|
|
continue
|
|
room_info = sync_response.rooms.join.get(self.config.room_id)
|
|
if not room_info or not hasattr(room_info, "timeline") or not hasattr(room_info.timeline, "events"):
|
|
continue
|
|
for event in room_info.timeline.events:
|
|
if isinstance(event, RoomMessageText):
|
|
# Ignore old events (pre-listen timestamp)
|
|
if getattr(event, "server_timestamp", 0) < since_ms:
|
|
continue
|
|
# Ignore our own messages
|
|
if event.sender == self.user_id:
|
|
continue
|
|
body = event.body.strip()
|
|
|
|
if body == "!clear":
|
|
count = self.clear_suggestions(
|
|
event.sender,
|
|
int(getattr(event, "server_timestamp", 0) // 1000),
|
|
getattr(event, "event_id", None),
|
|
)
|
|
if count is not None:
|
|
await self.alert_post(
|
|
f"Cleared {count} pending title suggestions. They will not be posted for voting."
|
|
)
|
|
|
|
# Handle !gettitles
|
|
elif body == "!gettitles":
|
|
event_id = getattr(event, "event_id", None)
|
|
ts_seconds = int(getattr(event, "server_timestamp", 0) // 1000)
|
|
if not self.record_gettitles(event.sender, ts_seconds, event_id):
|
|
continue
|
|
print("!gettitles detected")
|
|
return "gettitles"
|
|
|
|
# Handle !mayhem
|
|
elif body.startswith("!mayhem"):
|
|
event_id = getattr(event, "event_id", None)
|
|
ts_seconds = int(getattr(event, "server_timestamp", 0) // 1000)
|
|
if self.record_mayhem(event.sender, ts_seconds, event_id):
|
|
await self.alert_post("Standby. Ollama is being instructed to generate mayhem. This may take a moment...")
|
|
await self.alert_post(self.generate_mayhem())
|
|
|
|
# Both result commands read current emoji votes.
|
|
elif body in ("!countvotes", "!getvotes"):
|
|
event_id = getattr(event, "event_id", None)
|
|
ts_seconds = int(getattr(event, "server_timestamp", 0) // 1000)
|
|
if not self.record_countvotes(event.sender, ts_seconds, event_id):
|
|
continue
|
|
print(f"{body} detected")
|
|
await self.alert_post("Counting votes... This may take a moment...")
|
|
vote_results = await self.count_votes()
|
|
await self.alert_post(vote_results)
|
|
|
|
# Keep the old method name for backward compatibility but mark as deprecated
|
|
async def wait_for_gettitles(self, since_ms=None, exact=True):
|
|
"""Handle room commands until !gettitles requests suggestion posting."""
|
|
result = await self.wait_for_commands(since_ms)
|
|
return result == "gettitles"
|
|
|
|
async def alert_post(self, post):
|
|
assert self.client is not None
|
|
content = {
|
|
"msgtype": "m.text",
|
|
"body": post
|
|
}
|
|
|
|
resp = await self.client.room_send(
|
|
self.config.room_id,
|
|
message_type="m.room.message",
|
|
content=content
|
|
)
|
|
|
|
if isinstance(resp, RoomSendResponse):
|
|
print("Alert posted")
|
|
else:
|
|
print(f"Failed to post: {resp}")
|
|
|
|
def format_suggestions_block(self):
|
|
lines = ["Suggestions:"]
|
|
for post in self.posts:
|
|
clean_msg = "".join(post.content.strip().splitlines())
|
|
user = post.display_name
|
|
if len(user) > 20:
|
|
user = user[:20] + "..."
|
|
lines.append(f"- {user}: {clean_msg}")
|
|
self.formatted_sugs = "\n".join(lines)
|
|
|
|
def read_db(self, since_timestamp=None, min_timestamp=None, only_unposted=False):
|
|
conn = dbutil.connect(self.db_path)
|
|
c = conn.cursor()
|
|
query = "SELECT user, display_name, message, timestamp, age, is_posted FROM suggestions"
|
|
params = []
|
|
conditions = []
|
|
if since_timestamp:
|
|
conditions.append("timestamp > ?")
|
|
params.append(since_timestamp)
|
|
if min_timestamp:
|
|
conditions.append("timestamp >= ?")
|
|
params.append(min_timestamp)
|
|
if only_unposted:
|
|
conditions.append("is_posted = 0")
|
|
if conditions:
|
|
query += " WHERE " + " AND ".join(conditions)
|
|
query += " ORDER BY timestamp ASC"
|
|
_ = c.execute(query, tuple(params))
|
|
rows = c.fetchall()
|
|
conn.close()
|
|
self.posts = [Post(poster=row[0], display_name=row[1], content=row[2], time=row[3], age=row[4], is_posted=bool(row[5])) for row in rows]
|
|
|
|
def get_last_post(self):
|
|
conn = dbutil.connect("last_post.db")
|
|
c = conn.cursor()
|
|
_ = c.execute('''
|
|
CREATE TABLE IF NOT EXISTS last_post (
|
|
room_id TEXT PRIMARY KEY,
|
|
timestamp INTEGER,
|
|
datetime TEXT
|
|
)
|
|
''')
|
|
_ = c.execute("PRAGMA table_info(last_post)")
|
|
cols = [row[1] for row in c.fetchall()]
|
|
if 'datetime' not in cols:
|
|
_ = c.execute('ALTER TABLE last_post ADD COLUMN datetime TEXT')
|
|
_ = c.execute('SELECT timestamp FROM last_post WHERE room_id = ?', (self.config.room_id,))
|
|
row = c.fetchone()
|
|
conn.close()
|
|
if row:
|
|
return row[0]
|
|
return None
|
|
|
|
def update_last_post(self):
|
|
conn = dbutil.connect("last_post.db")
|
|
c = conn.cursor()
|
|
_ = c.execute('''
|
|
CREATE TABLE IF NOT EXISTS last_post (
|
|
room_id TEXT PRIMARY KEY,
|
|
timestamp INTEGER,
|
|
datetime TEXT
|
|
)
|
|
''')
|
|
_ = c.execute("PRAGMA table_info(last_post)")
|
|
cols = [row[1] for row in c.fetchall()]
|
|
if 'datetime' not in cols:
|
|
_ = c.execute('ALTER TABLE last_post ADD COLUMN datetime TEXT')
|
|
|
|
now = int(_time.time())
|
|
now_str = datetime.datetime.now().isoformat(sep=' ', timespec='seconds')
|
|
_ = c.execute('INSERT OR REPLACE INTO last_post (room_id, timestamp, datetime) VALUES (?, ?, ?)',
|
|
(self.config.room_id, now, now_str))
|
|
dbutil.commit(conn)
|
|
conn.close()
|
|
|
|
async def post_suggestions(self):
|
|
assert self.client is not None
|
|
# Filter out already posted suggestions
|
|
unposted_posts = [post for post in self.posts if not post.is_posted]
|
|
if not unposted_posts:
|
|
await self.alert_post("No new title suggestions posted.")
|
|
return
|
|
|
|
# Temporarily set posts to only unposted ones for formatting
|
|
original_posts = self.posts
|
|
self.posts = unposted_posts
|
|
|
|
self.format_suggestions_block()
|
|
content = {
|
|
"msgtype": "m.text",
|
|
"body": self.formatted_sugs
|
|
}
|
|
|
|
resp = await self.client.room_send(
|
|
self.config.room_id,
|
|
message_type="m.room.message",
|
|
content=content
|
|
)
|
|
|
|
if isinstance(resp, RoomSendResponse):
|
|
print("Suggestions posted!")
|
|
# Mark posts as posted in both objects and database
|
|
for post in unposted_posts:
|
|
post.is_posted = True
|
|
self.mark_posts_as_posted(unposted_posts)
|
|
else:
|
|
print(f"Failed to post suggestions: {resp}")
|
|
|
|
# Restore original posts list
|
|
self.posts = original_posts
|
|
|
|
async def post_suggestions_individually(self):
|
|
"""Post each unposted suggestion as its own message with a 👍 reaction for voting."""
|
|
assert self.client is not None
|
|
unposted_posts = [post for post in self.posts if not post.is_posted]
|
|
if not unposted_posts:
|
|
await self.alert_post("No new title suggestions posted.")
|
|
return
|
|
|
|
await self.alert_post("""
|
|
Time to vote! 🚢🚢🚢
|
|
|
|
Each title is a separate post below. Tap the 👍 reaction on the one you like to cast your vote!
|
|
|
|
May the odds be ever in your favor! 🏹🏹🏹
|
|
|
|
COMMANDS:
|
|
!suggest <title> — Submit a title suggestion.
|
|
!gettitles — Post pending suggestions for voting.
|
|
!countvotes — Show the latest round's current 👍 vote rankings.
|
|
!getvotes — Show the same current emoji results as !countvotes.
|
|
!clear — Clear pending suggestions from future voting rounds.
|
|
!mayhem — Generate chaotic text with Ollama AI.
|
|
|
|
MAKE MAGBOT BETTER:
|
|
GIT REPO: https://magbot.online ⬅️⬅️⬅️ PRs welcome!
|
|
|
|
|
|
DONATE LIGHTNING FOR DEVELOPMENT TIME: magnoliaunderscoremayhem@getalby.com
|
|
- 50K sats pays for me to stay home from the farm for one day
|
|
- The VPS is 10K sats per month
|
|
|
|
|
|
|
|
|
|
""")
|
|
|
|
for idx, post in enumerate(unposted_posts, start=1):
|
|
clean_msg = " ".join(post.content.strip().splitlines())
|
|
user = post.display_name
|
|
if len(user) > 20:
|
|
user = user[:20] + "..."
|
|
label = f"Suggestion {idx}: {user}: {clean_msg}"
|
|
|
|
content = {
|
|
"msgtype": "m.text",
|
|
"body": label,
|
|
}
|
|
|
|
resp = await self.client.room_send(
|
|
self.config.room_id,
|
|
message_type="m.room.message",
|
|
content=content,
|
|
)
|
|
|
|
if not isinstance(resp, RoomSendResponse):
|
|
print(f"Failed to post suggestion {idx}: {resp}")
|
|
continue
|
|
|
|
self.mark_posts_as_posted([post])
|
|
post.is_posted = True
|
|
|
|
reaction = {
|
|
"m.relates_to": {
|
|
"rel_type": "m.annotation",
|
|
"event_id": resp.event_id,
|
|
"key": "👍",
|
|
}
|
|
}
|
|
await self.client.room_send(
|
|
self.config.room_id,
|
|
message_type="m.reaction",
|
|
content=reaction,
|
|
)
|
|
|
|
await asyncio.sleep(self.config.post_interval)
|
|
|
|
print("Suggestions posted!")
|
|
|
|
def mark_posts_as_posted(self, posts=None):
|
|
"""Mark posts as posted in the database"""
|
|
if posts is None:
|
|
posts = self.posts
|
|
|
|
conn = dbutil.connect(self.db_path)
|
|
c = conn.cursor()
|
|
for post in posts:
|
|
_ = c.execute(
|
|
"UPDATE suggestions SET is_posted = 1 WHERE user = ? AND message = ? AND timestamp = ?",
|
|
(post.poster, post.content, post.time)
|
|
)
|
|
dbutil.commit(conn)
|
|
conn.close()
|
|
|
|
async def close(self):
|
|
if self.client:
|
|
await self.client.close()
|
|
|
|
async def acknowledge_rejected_suggestions(self):
|
|
"""Reply to banned users that they cannot submit titles."""
|
|
assert self.client is not None
|
|
for post in self.rejected_banned_posts:
|
|
await self.reject_banned_suggestion(post)
|
|
self.rejected_banned_posts = []
|
|
|
|
async def acknowledge_new_suggestions(self):
|
|
"""React ✅ to newly recorded suggestions to confirm they were saved."""
|
|
assert self.client is not None
|
|
if not self.newly_inserted_posts:
|
|
return
|
|
for post in self.newly_inserted_posts:
|
|
if not getattr(post, "event_id", None):
|
|
continue
|
|
try:
|
|
reaction = {
|
|
"m.relates_to": {
|
|
"rel_type": "m.annotation",
|
|
"event_id": post.event_id,
|
|
"key": "✅",
|
|
}
|
|
}
|
|
resp = await self.client.room_send(
|
|
self.config.room_id,
|
|
message_type="m.reaction",
|
|
content=reaction,
|
|
)
|
|
if isinstance(resp, RoomSendResponse):
|
|
print(f"Acked suggestion from {post.display_name} with ✅")
|
|
else:
|
|
print(f"Failed to ack suggestion: {resp}")
|
|
except Exception as e:
|
|
print(f"Error sending ✅ reaction: {e}")
|