autobotrobot/src/telephone.py

431 lines
20 KiB
Python
Raw Normal View History

from discord.ext import commands
import discord
import logging
import asyncio
2021-04-05 18:08:37 +00:00
import re
import hashlib
from datetime import datetime, timedelta
2021-04-05 18:08:37 +00:00
import os
import pydot
import tempfile
import collections
import aiohttp
import util
import eventbus
# Generate a "phone" address
# Not actually for phones
def generate_address(ctx):
h = hashlib.blake2b(str(ctx.guild.id).encode("utf-8")).digest()
words = open("wordlist-8192.txt").readlines()
out = ""
for i in range(3):
out += words[int.from_bytes(h[i * 2:i * 2 + 3], "little") % 8192].strip().title()
return out
2021-04-05 18:08:37 +00:00
def parse_formatting(bot, text):
def parse_match(m):
try:
target = int(m.group(2))
except ValueError: return m.string
if m.group(1) == "@": # user ping
user = bot.get_user(target)
if user: return { "type": "user_mention", "name": user.name, "id": target }
return f"@{target}"
else: # channel "ping"
channel = bot.get_channel(target)
if channel: return { "type": "channel_mention", "name": channel.name, "id": target }
return f"#{target}"
remaining = text
out = []
while match := re.search(r"<([@#])!?([0-9]+)>", remaining):
start, end = match.span()
out.append(remaining[:start])
out.append(parse_match(match))
remaining = remaining[end:]
out.append(remaining)
return list(filter(lambda x: x != "", out))
def render_formatting(dest_channel, message):
out = ""
for seg in message:
if isinstance(seg, str):
out += seg
else:
kind = seg["type"]
# TODO: use python 3.10 pattern matching
if kind == "user_mention":
member = dest_channel.guild.get_member(seg["id"])
if member != None: out += f"<@{member.id}>"
else: out += f"@{seg['name']}"
elif kind == "channel_mention": # these appear to be clickable across servers/guilds
out += f"<#{seg['id']}>"
else: logging.warn("Unrecognized message seg %s", kind)
return out
class Telephone(commands.Cog):
# Discord event bus link
def __init__(self, bot):
self.webhooks = {}
self.bot = bot
self.unlisten = eventbus.add_listener("discord", self.on_bridge_message)
self.webhook_queue = asyncio.Queue(50)
self.webhook_queue_handler_task = asyncio.create_task(self.send_webhooks())
async def send_webhooks(self):
while True:
webhook, content, username, avatar_url = await self.webhook_queue.get()
wh_obj = discord.Webhook.from_url(webhook, session=self.bot.http._HTTPClient__session)
2021-07-28 15:52:46 +00:00
try:
await wh_obj.send(content=content, username=username, avatar_url=avatar_url, allowed_mentions=discord.AllowedMentions(everyone=False, roles=False, users=False))
except:
logging.exception("Webhook send on %s failed")
2021-04-05 18:08:37 +00:00
async def initial_load_webhooks(self):
rows = await self.bot.database.execute_fetchall("SELECT * FROM discord_webhooks")
for row in rows:
self.webhooks[row["channel_id"]] = row["webhook"]
logging.info("Loaded %d webhooks", len(rows))
2021-07-28 19:30:37 +00:00
async def on_bridge_message(self, channel_id, msg: eventbus.Message):
2021-04-05 18:08:37 +00:00
channel = self.bot.get_channel(channel_id)
if channel:
webhook = self.webhooks.get(channel_id)
2021-07-28 19:30:37 +00:00
attachments_text = "\n".join(f"{at.filename}: {at.proxy_url}" for at in msg.attachments)
async def send_raw(text):
if webhook:
try:
self.webhook_queue.put_nowait((webhook, text, msg.author.name, msg.author.avatar_url))
except asyncio.QueueFull:
text = f"<{msg.author.name}> {text}"
await channel.send(text[:2000], allowed_mentions=discord.AllowedMentions(everyone=False, roles=False, users=False))
else:
text = f"<{msg.author.name}> {text}"
await channel.send(text[:2000], allowed_mentions=discord.AllowedMentions(everyone=False, roles=False, users=False))
2021-07-28 19:30:37 +00:00
await send_raw(render_formatting(channel, msg.message)[:2000])
if attachments_text: await send_raw(attachments_text)
2021-04-05 18:08:37 +00:00
else:
logging.warning("Channel %d not found", channel_id)
2021-04-05 18:08:37 +00:00
@commands.Cog.listener("on_message")
async def send_to_bridge(self, msg):
# discard webhooks and bridge messages (hackily, admittedly, not sure how else to do this)
2021-07-28 19:30:37 +00:00
if msg.content == "" and len(msg.attachments) == 0: return
if (msg.author == self.bot.user and (len(msg.content) > 0 and msg.content[0] == "<")) or msg.author.discriminator == "0000": return
2021-04-05 18:08:37 +00:00
channel_id = msg.channel.id
2021-10-28 11:55:40 +00:00
reply = None
2021-11-09 08:11:15 +00:00
if msg.reference:
if isinstance(msg.reference.resolved, discord.DeletedReferencedMessage):
replying_to = None
elif msg.reference.resolved:
replying_to = msg.reference.resolved
elif msg.reference.cached_message:
replying_to = msg.reference.cached_message
else:
try:
replying_to = await self.bot.get_guild(msg.reference.guild_id).get_channel(msg.reference.channel_id).fetch_message(msg.reference.message_id)
except discord.HTTPException:
replying_to = None
if replying_to:
reply = (eventbus.AuthorInfo(replying_to.author.name, replying_to.author.id, str(replying_to.author.display_avatar.url), replying_to.author.bot), parse_formatting(self.bot, replying_to.content))
2021-11-09 08:11:15 +00:00
else:
reply = (None, None)
msg = eventbus.Message(eventbus.AuthorInfo(msg.author.name, msg.author.id, str(msg.author.display_avatar.url), msg.author.bot),
2021-10-28 11:55:40 +00:00
parse_formatting(self.bot, msg.content), ("discord", channel_id), msg.id, [ at for at in msg.attachments if not at.is_spoiler() ], reply=reply)
2021-04-05 18:08:37 +00:00
await eventbus.push(msg)
2021-04-05 18:08:37 +00:00
def cog_unload(self):
self.unlisten()
self.webhook_queue_handler_task.cancel()
2021-04-05 18:08:37 +00:00
# ++tel commands
@commands.group(name="apiotelephone", aliases=["tel", "tele", "telephone", "apiotel"], brief="ApioTelephone lets you 'call' other servers.")
async def telephone(self, ctx):
2021-04-13 15:34:36 +00:00
"""Call other (participating) servers with ApioTelephone! To configure a channel for telephony, use the setup command (requires Manage Channels).
2021-04-05 18:08:37 +00:00
It's recommended that you give the bot Manage Webhooks permissions in this channel so that it can use webhook calls mode.
To place a call, use dial [number] - the other end has to accept the call.
When you want to end a call, use hangup.
"""
pass
async def get_channel_config(self, channel):
return await self.bot.database.execute_fetchone("SELECT * FROM telephone_config WHERE channel_id = ?", (channel,))
async def get_addr_config(self, addr):
return await self.bot.database.execute_fetchone("SELECT * FROM telephone_config WHERE id = ? COLLATE NOCASE", (addr,))
@telephone.command(brief="Link to other channels", help="""Connect to another channel on Discord or any supported bridges.
Virtual channels also exist.
""")
2021-07-28 19:30:37 +00:00
@commands.check(util.extpriv_check)
2021-04-05 18:08:37 +00:00
async def link(self, ctx, target_type, target_id, bidirectional: bool = True):
target_id = util.extract_codeblock(target_id)
try:
target_id = int(target_id)
except ValueError: pass
2021-04-05 18:08:37 +00:00
await eventbus.add_bridge_link(self.bot.database, ("discord", ctx.channel.id), (target_type, target_id), "manual", bidirectional)
await ctx.send(f"Link established.")
pass
@telephone.command(brief="Undo link commands.")
2022-01-18 21:34:34 +00:00
@commands.check(util.server_mod_check)
2021-04-05 18:08:37 +00:00
async def unlink(self, ctx, target_type, target_id, bidirectional: bool = True):
target_id = util.extract_codeblock(target_id)
try:
target_id = int(target_id)
except ValueError: pass
2021-04-05 18:08:37 +00:00
await eventbus.remove_bridge_link(self.bot.database, ("discord", ctx.channel.id), (target_type, target_id), bidirectional)
await ctx.send(f"Successfully deleted.")
pass
async def find_recent(self, chs, query):
one_week = timedelta(seconds=60*60*24*7)
one_week_ago = datetime.now() - one_week
for ch in chs:
yield True, ch
async for msg in ch.history(limit=None,after=one_week_ago):
if query in msg.content.lower():
yield False, ch, msg
@telephone.command(brief="Find recent messages in channels linked to this")
@commands.check(util.extpriv_check)
async def searchrecent(self, ctx, ch: discord.TextChannel, *, query):
author = ctx.author
chs = []
for dest in eventbus.find_all_destinations(("discord",ch.id)):
if dest[0] == "discord":
chs.append(self.bot.get_channel(dest[1]))
found = await self.find_recent(chs, query)
out = ""
async for ch,ms in found.items():
out += f"{ch.mention} (`#{ch.name}` in `{ch.guild.name}`)\n"
for m in ms:
u = m.author.name if m.author else None
w = "[WH]" if m.webhook_id else ""
out += f"- {m.content[:20]} @{m.created_at} by {u} {w}\n"
for c in util.chunks(out,2000):
await author.send(c)
return found
@telephone.command(brief="Delete recent messages in channels linked to this")
@commands.check(util.extpriv_check)
async def delrecent(self, ctx, ch: discord.TextChannel, *, query):
author = ctx.author
found = await self.searchrecent(ctx,ch,query=query)
await author.send("please say 'GO' to confirm or wait 10 seconds to not confirm")
try:
await self.bot.wait_for('message',check=lambda m:m.author == ctx.author and m.content == "GO" and m.channel == ctx.author.dm_channel,timeout=10)
except asyncio.TimeoutError:
await author.send("timed out")
return
async def try_delete(msg,session):
if msg.webhook_id is not None:
# note: assumes there is only one webhook we control per channel
# i think that's the case
wh_url = await self.bot.database.execute_fetchone("SELECT webhook FROM discord_webhooks WHERE channel_id = ?",(msg.channel.id,))
if wh_url is None:
await author.send(f"no access to webhook: {msg.id} {msg.channel.mention} {msg.jump_url}")
return
wh_url = wh_url['webhook']
wh = discord.Webhook.from_url(wh_url,session=session)
await wh.delete_message(msg.id)
else:
try:
await msg.delete()
except discord.errors.Forbidden:
await author.send(f"!!! couldn't delete msg {msg.id} in {msg.channel.mention}")
msgs = []
for q in found.values():
msgs.extend(q)
async with aiohttp.ClientSession() as session:
await asyncio.gather(*(try_delete(msg,session) for msg in msgs))
await author.send("done")
@telephone.command(brief="Generate a webhook")
2021-07-28 21:08:57 +00:00
@commands.check(util.server_mod_check)
2021-04-05 18:08:37 +00:00
async def init_webhook(self, ctx):
webhook = (await ctx.channel.create_webhook(name="ABR webhook", reason=f"requested by {ctx.author.name}")).url
2021-04-05 18:08:37 +00:00
await self.bot.database.execute("INSERT OR REPLACE INTO discord_webhooks VALUES (?, ?)", (ctx.channel.id, webhook))
await self.bot.database.commit()
self.webhooks[ctx.channel.id] = webhook
await ctx.send("Done.")
@telephone.command()
2021-01-14 09:38:32 +00:00
@commands.check(util.server_mod_check)
2021-04-05 18:08:37 +00:00
async def setup(self, ctx):
num = generate_address(ctx)
await ctx.send(f"Your address is {num}.")
2021-04-05 18:08:37 +00:00
info = await self.get_addr_config(num)
webhook = None
if info: webhook = info["webhook"]
if not info or not webhook:
try:
webhook = (await ctx.channel.create_webhook(name="incoming message display", reason="configure for apiotelephone")).url
2021-04-05 18:08:37 +00:00
await self.bot.database.execute("INSERT OR REPLACE INTO discord_webhooks VALUES (?, ?)", (ctx.channel.id, webhook))
await ctx.send("Created webhook.")
except discord.Forbidden as f:
logging.warn("Could not create webhook in #%s %s", ctx.channel.name, ctx.guild.name, exc_info=f)
await ctx.send("Webhook creation failed - please ensure permissions are available. This is not necessary but is recommended.")
2021-04-05 18:08:37 +00:00
await self.bot.database.execute("INSERT OR REPLACE INTO telephone_config VALUES (?, ?, ?, ?)", (num, ctx.guild.id, ctx.channel.id, webhook))
await self.bot.database.commit()
await ctx.send("Configured.")
2021-04-14 21:44:16 +00:00
@telephone.command(aliases=["rcall"], brief="Dial another telephone channel.")
async def rdial(self, ctx):
# TODO: this is not very performant
random = (await self.bot.database.execute_fetchone("SELECT id FROM telephone_config ORDER BY RANDOM()"))["id"]
await self.dial(ctx, random)
@telephone.command(aliases=["call"], brief="Dial another telephone channel.")
2021-04-05 18:08:37 +00:00
async def dial(self, ctx, address):
# basic checks - ensure this is a phone channel and has no other open calls
2021-04-05 18:08:37 +00:00
channel_info = await self.get_channel_config(ctx.channel.id)
if not channel_info: return await ctx.send(embed=util.error_embed("Not in a phone channel."))
originating_address = channel_info["id"]
if address == originating_address: return await ctx.send(embed=util.error_embed("A channel cannot dial itself. That means *you*, Gibson."))
2021-04-05 18:08:37 +00:00
recv_info = await self.get_addr_config(address)
if not recv_info: return await ctx.send(embed=util.error_embed("Destination address not found. Please check for typos and/or antimemes."))
2021-04-05 18:08:37 +00:00
current_call = await self.bot.database.execute_fetchone("SELECT * FROM calls WHERE from_id = ?", (originating_address,))
if current_call: return await ctx.send(embed=util.error_embed(f"A call is already open (to {current_call['to_id']}) from this channel. Currently, only one outgoing call is permitted at a time."))
# post embed in the receiving channel prompting people to accept/decline call
2021-04-05 18:08:37 +00:00
recv_channel = self.bot.get_channel(recv_info["channel_id"])
_, call_message = await asyncio.gather(
ctx.send(embed=util.info_embed("Outgoing call", f"Dialing {address}...")),
recv_channel.send(embed=util.info_embed("Incoming call",
f"Call from {originating_address}. Click :white_check_mark: to accept or :negative_squared_cross_mark: to decline."))
)
# add clickable reactions to it
await asyncio.gather(
call_message.add_reaction(""),
call_message.add_reaction("")
)
2021-04-05 18:08:37 +00:00
def check(re, u): return (str(re.emoji) == "" or str(re.emoji) == "") and u != self.bot.user
reaction = None
# wait until someone clicks the reactions, or time out and say so
try:
2021-04-05 18:08:37 +00:00
reaction, user = await self.bot.wait_for("reaction_add", timeout=util.config["call_timeout"], check=check)
except asyncio.TimeoutError:
await asyncio.gather(
ctx.send(embed=util.error_embed("Timed out", "Outgoing call timed out - the other end did not pick up.")),
recv_channel.send(embed=util.error_embed("Timed out", "Call timed out - no response in time"))
)
2021-04-05 18:08:37 +00:00
await asyncio.gather(call_message.remove_reaction("", self.bot.user), call_message.remove_reaction("", self.bot.user))
em = str(reaction.emoji) if reaction else ""
if em == "": # accept call
2021-04-05 18:08:37 +00:00
await self.bot.database.execute("INSERT INTO calls VALUES (?, ?, ?)", (originating_address, address, util.timestamp()))
await self.bot.database.commit()
await eventbus.add_bridge_link(self.bot.database, ("discord", ctx.channel.id), ("discord", recv_channel.id), "telephone")
await asyncio.gather(
ctx.send(embed=util.info_embed("Outgoing call", "Call accepted and connected.")),
recv_channel.send(embed=util.info_embed("Incoming call", "Call accepted and connected."))
)
elif em == "": # drop call
await ctx.send(embed=util.error_embed("Your call was declined.", "Call declined"))
@telephone.command(aliases=["disconnect", "quit"], brief="Disconnect latest call.")
2021-04-05 18:08:37 +00:00
async def hangup(self, ctx):
channel_info = await self.get_channel_config(ctx.channel.id)
addr = channel_info["id"]
if not channel_info: return await ctx.send(embed=util.error_embed("Not in a phone channel."))
2021-04-05 18:08:37 +00:00
from_here = await self.bot.database.execute_fetchone("SELECT * FROM calls WHERE from_id = ?", (addr,))
to_here = await self.bot.database.execute_fetchone("SELECT * FROM calls WHERE to_id = ?", (addr,))
if (not to_here) and (not from_here): return await ctx.send(embed=util.error_embed("No calls are active."))
other = None
if from_here:
other = from_here["to_id"]
2021-04-05 18:08:37 +00:00
await self.bot.database.execute("DELETE FROM calls WHERE from_id = ? AND to_id = ?", (addr, other))
elif to_here:
other = to_here["from_id"]
2021-04-05 18:08:37 +00:00
await self.bot.database.execute("DELETE FROM calls WHERE to_id = ? AND from_id = ?", (addr, other))
await self.bot.database.commit()
other_channel = (await self.get_addr_config(other))["channel_id"]
await eventbus.remove_bridge_link(self.bot.database, ("discord", other_channel), ("discord", ctx.channel.id))
await asyncio.gather(
ctx.send(embed=util.info_embed("Hung up", f"Call to {other} disconnected.")),
2021-04-05 18:08:37 +00:00
self.bot.get_channel(other_channel).send(embed=util.info_embed("Hung up", f"Call to {addr} disconnected."))
)
@telephone.command(aliases=["status"], brief="List inbound/outbound calls.")
2021-04-05 18:08:37 +00:00
async def info(self, ctx):
channel_info = await self.get_channel_config(ctx.channel.id)
if not channel_info: return await ctx.send(embed=util.info_embed("Phone status", "Not a phone channel"))
addr = channel_info['id']
title = f"{addr} status"
fields = []
now = datetime.utcnow()
def delta(ts):
return util.format_timedelta(datetime.utcfromtimestamp(ts), now)
2021-04-05 18:08:37 +00:00
incoming = await self.bot.database.execute_fetchall("SELECT * FROM calls WHERE to_id = ?", (addr,))
fields.extend(map(lambda x: ["Incoming call", f"From {x['from_id']} - for {delta(x['start_time'])}"], incoming))
2021-04-05 18:08:37 +00:00
outgoing = await self.bot.database.execute_fetchall("SELECT * FROM calls WHERE from_id = ?", (addr,))
2020-10-11 15:26:29 +00:00
fields.extend(map(lambda x: ["Outgoing call", f"To {x['to_id']} - for {delta(x['start_time'])}"], outgoing))
await ctx.send(embed=util.info_embed(title, f"Connected: {len(incoming) + len(outgoing)}", fields))
2021-04-05 18:08:37 +00:00
@telephone.command(brief="Dump links out of current channel.")
async def graph(self, ctx):
2021-04-13 15:34:36 +00:00
graph = pydot.Dot("linkgraph", ratio="fill")
2021-04-05 18:08:37 +00:00
seen = set()
seen_edges = set()
def node_name(x):
if x[0] == "discord":
chan = self.bot.get_channel(x[1])
2021-04-13 15:34:36 +00:00
if chan and getattr(chan, "name", False):
2021-04-05 18:08:37 +00:00
out = "#" + chan.name
if chan.guild:
out = chan.guild.name + "/" + out
return "discord/" + out
else:
return f"{x[0]}/{x[1]}"
return f"{x[0]}/{x[1]}"
todo = [("discord", ctx.channel.id)]
while todo:
current = todo.pop(0)
graph.add_node(pydot.Node(node_name(current), fontname="monospace"))
for adjacent in eventbus.links[current]:
if adjacent not in seen:
todo.append(adjacent)
edge = (current, adjacent)
if edge not in seen_edges:
graph.add_edge(pydot.Edge(node_name(current), node_name(adjacent)))
seen_edges.add(edge)
seen.add(current)
(handle, tmppath) = tempfile.mkstemp(".png", "graphviz")
graph.write_png(tmppath)
try:
await ctx.send(file=discord.File(handle, filename="out.png"))
finally:
os.unlink(tmppath)
def setup(bot):
cog = Telephone(bot)
bot.add_cog(cog)
asyncio.create_task(cog.initial_load_webhooks())