First commit.
This commit is contained in:
commit
7f6bdf248c
6 changed files with 365 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
**/__pycache__/
|
||||
.env
|
0
README.md
Normal file
0
README.md
Normal file
6
app/.env.default
Normal file
6
app/.env.default
Normal file
|
@ -0,0 +1,6 @@
|
|||
DISCORD_TOKEN = token_here
|
||||
DISCORD_CHANNEL_ID = XXXXYYYYYYZZZZZ
|
||||
MUCK_HOST = localhost
|
||||
MUCK_PORT = 7675
|
||||
MUCK_USERNAME = Discord_Bot
|
||||
MUCK_PASSWORD = super_secret
|
331
app/muck_discord_bot.py
Normal file
331
app/muck_discord_bot.py
Normal file
|
@ -0,0 +1,331 @@
|
|||
import asyncio
|
||||
import discord
|
||||
import re
|
||||
import logging
|
||||
import os
|
||||
from collections import deque
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
DISCORD_TOKEN = os.getenv('DISCORD_TOKEN', '')
|
||||
DISCORD_CHANNEL_ID = os.getenv('DISCORD_CHANNEL_ID', '')
|
||||
|
||||
MUCK_HOST = os.getenv('MUCK_HOST', 'localhost')
|
||||
MUCK_PORT = int(os.getenv('MUCK_PORT', '2560'))
|
||||
MUCK_USERNAME = os.getenv('MUCK_USERNAME', 'Discord_Bot')
|
||||
MUCK_PASSWORD = os.getenv('MUCK_PASSWORD', '')
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
class PresenceSyncManager:
|
||||
def __init__(self):
|
||||
self.current_player = None
|
||||
self.player_description_buffer = []
|
||||
self.collecting_player_desc = False
|
||||
self.blank_line_count = 0
|
||||
|
||||
self.reader = None
|
||||
self.writer = None
|
||||
self.channel = None
|
||||
self.logged_in = False
|
||||
self.discord_client = None
|
||||
self.recent_spoofs = deque(maxlen=10)
|
||||
self.whospe_buffer = None
|
||||
self.active_threads = {} # Maps player name -> discord.Thread object
|
||||
self.last_player_descriptions = {}
|
||||
self.previous_players = None
|
||||
|
||||
async def start(self, channel, discord_client):
|
||||
self.channel = channel
|
||||
self.discord_client = discord_client
|
||||
self.reader, self.writer = await asyncio.open_connection(MUCK_HOST, MUCK_PORT)
|
||||
await self._login()
|
||||
asyncio.create_task(self._read_loop())
|
||||
asyncio.create_task(self.periodic_sync())
|
||||
|
||||
async def periodic_sync(self):
|
||||
while True:
|
||||
self.writer.write(b"whospe\n")
|
||||
await self.writer.drain()
|
||||
await asyncio.sleep(30) # Adjust interval as needed
|
||||
|
||||
async def _login(self):
|
||||
await asyncio.sleep(1.5)
|
||||
self.writer.write(f"connect {MUCK_USERNAME} {MUCK_PASSWORD}\n".encode())
|
||||
await self.writer.drain()
|
||||
|
||||
async def _read_loop(self):
|
||||
buffer = []
|
||||
while True:
|
||||
try:
|
||||
line = await self.reader.readline()
|
||||
if not line:
|
||||
continue
|
||||
decoded = line.decode(errors='ignore').strip()
|
||||
logging.info(f"MUCK: {decoded}")
|
||||
|
||||
if not self.logged_in:
|
||||
buffer.append(decoded)
|
||||
if "You have" in decoded and "mail" in decoded:
|
||||
self.logged_in = True
|
||||
print("✅ Login complete.")
|
||||
asyncio.create_task(self._update_room_state())
|
||||
await self.channel.send("Bot is online and synced with MUCK.")
|
||||
else:
|
||||
await self._handle_muck_line(decoded)
|
||||
|
||||
except Exception as e:
|
||||
logging.exception("Error in read loop")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def _update_room_state(self):
|
||||
self.writer.write(b"whospe\n")
|
||||
if not hasattr(self, "room_description_posted"):
|
||||
await asyncio.sleep(1) # Slight delay to avoid overlap with player desc capture
|
||||
self.room_thread = await self.channel.create_thread(
|
||||
name="📍 Room State",
|
||||
type=discord.ChannelType.public_thread
|
||||
)
|
||||
self.writer.write(b"look\n")
|
||||
await self.writer.drain()
|
||||
self.room_description_posted = True
|
||||
self.room_lines = []
|
||||
|
||||
async def _process_whospe_output(self, lines):
|
||||
logging.info("[WHOSPE] Parsed output:")
|
||||
current_players = set()
|
||||
for line in lines:
|
||||
logging.info(f"[WHOSPE] {line}")
|
||||
match = re.match(r"^(?:IC|OOC) \| (\w+)", line)
|
||||
if match:
|
||||
name = match.group(1)
|
||||
if name == MUCK_USERNAME:
|
||||
continue
|
||||
current_players.add(name)
|
||||
thread = self.active_threads.get(name)
|
||||
if not thread:
|
||||
thread = await self.channel.create_thread(
|
||||
name=f"🦁 {name}",
|
||||
type=discord.ChannelType.public_thread
|
||||
)
|
||||
# Store thread before sending message to avoid duplicate creation
|
||||
self.active_threads[name] = thread
|
||||
await thread.send(f"📌 Created thread for **{name}** (spotted in the MUCK).")
|
||||
self.current_player = name
|
||||
self.collecting_player_desc = True
|
||||
self.player_description_buffer = []
|
||||
self.writer.write(f"look {name}\n".encode())
|
||||
await self.writer.drain()
|
||||
# Start a timeout to capture description if it doesn't finish naturally
|
||||
if not hasattr(self, "player_desc_timeout_task") or self.player_desc_timeout_task.done():
|
||||
self.player_desc_timeout_task = asyncio.create_task(self._timeout_player_description())
|
||||
|
||||
self.previous_players = self.previous_players or set()
|
||||
departed_players = self.previous_players - current_players
|
||||
self.previous_players = current_players
|
||||
|
||||
# Delete threads for players no longer present
|
||||
for player in departed_players:
|
||||
thread = self.active_threads.get(player)
|
||||
if thread:
|
||||
try:
|
||||
await thread.delete()
|
||||
del self.active_threads[player]
|
||||
except Exception as e:
|
||||
logging.exception(f"Failed to delete thread for {player}")
|
||||
|
||||
# Issue room look only after all player descriptions have been handled
|
||||
if not self.collecting_player_desc and not hasattr(self, "room_description_posted"):
|
||||
self.room_description_posted = True
|
||||
self.writer.write(b"look\n")
|
||||
await self.writer.drain()
|
||||
|
||||
async def issue_room_look_if_safe(self):
|
||||
if not self.collecting_player_desc and not hasattr(self, "room_description_posted"):
|
||||
self.room_description_posted = True
|
||||
self.writer.write(b"look\n")
|
||||
await self.writer.drain()
|
||||
|
||||
async def _handle_muck_line(self, line):
|
||||
# Start whospe capture
|
||||
if "WhoElse version" in line:
|
||||
self.whospe_buffer = [line]
|
||||
return
|
||||
|
||||
if self.whospe_buffer is not None:
|
||||
self.whospe_buffer.append(line)
|
||||
if line.strip().endswith("=(done)=-"):
|
||||
await self._process_whospe_output(self.whospe_buffer)
|
||||
# Removed room description collection after whospe as per instructions
|
||||
self.whospe_buffer = None
|
||||
return
|
||||
|
||||
if self.collecting_player_desc:
|
||||
if re.match(r"^\w+$", line.strip()):
|
||||
# Finish current and start new
|
||||
if self.last_player_descriptions.get(self.current_player) == "\n".join(self.player_description_buffer).strip():
|
||||
self.collecting_player_desc = False
|
||||
self.player_description_buffer = []
|
||||
self.current_player = None
|
||||
self.blank_line_count = 0
|
||||
return
|
||||
await self._post_player_description(self.current_player, self.player_description_buffer)
|
||||
logging.info(f"📤 Finished description capture for {self.current_player}")
|
||||
self.current_player = line.strip()
|
||||
self.player_description_buffer = []
|
||||
self.blank_line_count = 0
|
||||
logging.info(f"📥 Starting description capture for {self.current_player}")
|
||||
if not hasattr(self, "player_desc_timeout_task") or self.player_desc_timeout_task.done():
|
||||
self.player_desc_timeout_task = asyncio.create_task(self._timeout_player_description())
|
||||
return
|
||||
elif line.strip() == "":
|
||||
self.blank_line_count += 1
|
||||
self.player_description_buffer.append(line)
|
||||
if self.blank_line_count >= 2:
|
||||
if self.last_player_descriptions.get(self.current_player) == "\n".join(self.player_description_buffer).strip():
|
||||
self.collecting_player_desc = False
|
||||
self.player_description_buffer = []
|
||||
self.current_player = None
|
||||
self.blank_line_count = 0
|
||||
return
|
||||
await self._post_player_description(self.current_player, self.player_description_buffer)
|
||||
logging.info(f"📤 Finished description capture for {self.current_player}")
|
||||
self.collecting_player_desc = False
|
||||
self.player_description_buffer = []
|
||||
self.current_player = None
|
||||
self.blank_line_count = 0
|
||||
if not hasattr(self, "player_desc_timeout_task") or self.player_desc_timeout_task.done():
|
||||
self.player_desc_timeout_task = asyncio.create_task(self._timeout_player_description())
|
||||
return
|
||||
else:
|
||||
self.blank_line_count = 0
|
||||
self.player_description_buffer.append(line)
|
||||
if not hasattr(self, "player_desc_timeout_task") or self.player_desc_timeout_task.done():
|
||||
self.player_desc_timeout_task = asyncio.create_task(self._timeout_player_description())
|
||||
return
|
||||
|
||||
# Capture player description
|
||||
match = re.match(r"^(\w+)$", line)
|
||||
if match and not self.collecting_player_desc:
|
||||
player = match.group(1)
|
||||
if player in self.active_threads:
|
||||
self.current_player = player
|
||||
self.player_description_buffer = []
|
||||
self.collecting_player_desc = True
|
||||
self.blank_line_count = 0
|
||||
logging.info(f"📥 Starting description capture for {player}")
|
||||
if not hasattr(self, "player_desc_timeout_task") or self.player_desc_timeout_task.done():
|
||||
self.player_desc_timeout_task = asyncio.create_task(self._timeout_player_description())
|
||||
return
|
||||
|
||||
# Filter out echo
|
||||
if "[DISCORD]" in line or MUCK_USERNAME in line:
|
||||
return
|
||||
|
||||
if self.collecting_player_desc:
|
||||
return
|
||||
|
||||
if not hasattr(self, "room_lines"):
|
||||
self.room_lines = []
|
||||
|
||||
if self.current_player is None and not self.collecting_player_desc:
|
||||
if line.startswith("_(") or line == "Discord Room" or "It's a" in line:
|
||||
self.room_lines.append(line)
|
||||
return
|
||||
|
||||
if self.room_lines and self.current_player is None and self.room_thread:
|
||||
for room_line in self.room_lines:
|
||||
await self.room_thread.send(f"[MUCK] {room_line}")
|
||||
self.room_lines = []
|
||||
|
||||
if not self.collecting_player_desc:
|
||||
# Skip repetitive or non-informative lines
|
||||
if line.startswith("[IC ]") or line.startswith("[OOC]") or "You can see..." in line:
|
||||
return
|
||||
if line.strip() == "" or re.match(r"^\(.*\)$", line):
|
||||
return
|
||||
if hasattr(self, "room_description_posted") and self.room_description_posted:
|
||||
# Removed old room description block per instructions
|
||||
pass
|
||||
if self.channel:
|
||||
await self.channel.send(f"[MUCK] {line}")
|
||||
|
||||
async def _post_player_description(self, player, lines):
|
||||
if not lines or all(l.strip() == "" for l in lines):
|
||||
return
|
||||
content = "\n".join(lines).strip()
|
||||
if self.last_player_descriptions.get(player) == content:
|
||||
return
|
||||
self.last_player_descriptions[player] = content
|
||||
thread = self.active_threads.get(player)
|
||||
if thread:
|
||||
await thread.send(f"📖 **{player}**'s Description:\n```\n{content}\n```")
|
||||
|
||||
async def _timeout_player_description(self):
|
||||
logging.info("⏳ Timeout task started for player description.")
|
||||
await asyncio.sleep(2)
|
||||
if self.collecting_player_desc and self.player_description_buffer:
|
||||
if self.last_player_descriptions.get(self.current_player) == "\n".join(self.player_description_buffer).strip():
|
||||
self.collecting_player_desc = False
|
||||
self.player_description_buffer = []
|
||||
self.current_player = None
|
||||
self.blank_line_count = 0
|
||||
return
|
||||
await self._post_player_description(self.current_player, self.player_description_buffer)
|
||||
logging.info(f"📤 Finished description capture for {self.current_player} (timeout)")
|
||||
self.collecting_player_desc = False
|
||||
self.player_description_buffer = []
|
||||
self.current_player = None
|
||||
self.blank_line_count = 0
|
||||
|
||||
def format_spoof(self, message):
|
||||
content = message.content.strip()
|
||||
name = message.author.display_name
|
||||
|
||||
if content.startswith(":"):
|
||||
pose = content[1:]
|
||||
if pose.startswith(","):
|
||||
return f"spoof [DISCORD] {name}{pose}"
|
||||
else:
|
||||
return f"spoof [DISCORD] {name} {pose}"
|
||||
|
||||
elif content.lower().startswith("!me "):
|
||||
return f"spoof [DISCORD] {name} {content[4:].strip()}"
|
||||
|
||||
elif (content.startswith("_") and content.endswith("_") and
|
||||
content.count("_") >= 2 and not content[1:-1].strip().startswith("_")):
|
||||
return f"spoof [DISCORD] {name} {content[1:-1].strip()}"
|
||||
|
||||
return f"spoof [DISCORD] {name} says, \"{content}\""
|
||||
|
||||
async def send_to_muck(self, message):
|
||||
spoof_text = self.format_spoof(message)
|
||||
self.recent_spoofs.append(spoof_text.strip())
|
||||
self.writer.write((spoof_text + "\n").encode())
|
||||
await self.writer.drain()
|
||||
|
||||
intents = discord.Intents.default()
|
||||
intents.messages = True
|
||||
intents.message_content = True
|
||||
|
||||
client = discord.Client(intents=intents)
|
||||
presence_sync = PresenceSyncManager()
|
||||
|
||||
@client.event
|
||||
async def on_ready():
|
||||
logging.info("discord.client logging in using static token")
|
||||
print(f"Logged in as {client.user}")
|
||||
channel = client.get_channel(DISCORD_CHANNEL_ID)
|
||||
await presence_sync.start(channel, client)
|
||||
|
||||
@client.event
|
||||
async def on_message(message):
|
||||
if message.author == client.user:
|
||||
return
|
||||
if message.channel.id != DISCORD_CHANNEL_ID:
|
||||
return
|
||||
await presence_sync.send_to_muck(message)
|
||||
|
||||
client.run(DISCORD_TOKEN)
|
17
project.toml
Normal file
17
project.toml
Normal file
|
@ -0,0 +1,17 @@
|
|||
[project]
|
||||
name = "muck_discord_bot"
|
||||
version = "0.0.1"
|
||||
description = "A bot for connecting Discord to a MUCK."
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"python-dotenv",
|
||||
"discord.py",
|
||||
"telnetlib3"
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tools.setuptools]
|
||||
packages = ["muck_discord_bot"]
|
9
setup.cfg
Normal file
9
setup.cfg
Normal file
|
@ -0,0 +1,9 @@
|
|||
[metadata]
|
||||
name = muck_discord_bot
|
||||
version = 0.0.1
|
||||
|
||||
[options]
|
||||
packages = find:
|
||||
|
||||
[options.packages.find]
|
||||
where = .
|
Loading…
Add table
Add a link
Reference in a new issue