telephony semioverhaul

This commit is contained in:
osmarks 2021-04-05 19:08:37 +01:00
parent e525f8da10
commit 4d5ed52f7f
6 changed files with 192 additions and 151 deletions

View File

@ -97,6 +97,9 @@ UPDATE user_data SET guild_id = '_global' WHERE rowid IN
(SELECT rowid FROM user_data d WHERE d.key = user_data.key AND d.user_id = user_data.user_id ORDER BY rowid DESC LIMIT 1)
FROM user_data WHERE guild_id IS NULL GROUP BY user_id, key);
DELETE FROM user_data WHERE guild_id IS NULL;
""",
"""
ALTER TABLE links ADD COLUMN cause TEXT;
"""
]

View File

@ -1,90 +0,0 @@
import eventbus
import discord
import asyncio
import logging
import re
import discord.ext.commands as commands
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: 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 DiscordLink(commands.Cog):
def __init__(self, bot):
self.webhooks = {}
self.bot = bot
self.unlisten = eventbus.add_listener("discord", self.on_bridge_message)
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))
async def on_bridge_message(self, channel_id, msg):
channel = self.bot.get_channel(channel_id)
if channel:
webhook = self.webhooks.get(channel_id)
if webhook:
wh_obj = discord.Webhook.from_url(webhook, adapter=discord.AsyncWebhookAdapter(self.bot.http._HTTPClient__session))
await wh_obj.send(
content=render_formatting(channel, msg.message)[:2000], username=msg.author.name, avatar_url=msg.author.avatar_url,
allowed_mentions=discord.AllowedMentions(everyone=False, roles=False, users=False))
else:
text = f"<{msg.author.name}> {render_formatting(channel, msg.message)}"
await channel.send(text[:2000], allowed_mentions=discord.AllowedMentions(everyone=False, roles=False, users=False))
else:
logging.warning("Channel %d not found", channel_id)
@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)
if msg.content == "": return
if (msg.author == self.bot.user and msg.content[0] == "<") or msg.author.discriminator == "0000": return
channel_id = msg.channel.id
msg = eventbus.Message(eventbus.AuthorInfo(msg.author.name, msg.author.id, str(msg.author.avatar_url), msg.author.bot), parse_formatting(self.bot, msg.content), ("discord", channel_id), msg.id)
await eventbus.push(msg)
def cog_unload(self):
self.unlisten()
def setup(bot):
cog = DiscordLink(bot)
bot.add_cog(cog)
asyncio.create_task(cog.initial_load_webhooks())

View File

@ -79,20 +79,20 @@ def add_listener(s, l):
listeners[s].add(l)
return lambda: listeners[s].remove(l)
async def add_bridge_link(db, c1, c2):
logging.info("Bridging %s and %s", repr(c1), repr(c2))
async def add_bridge_link(db, c1, c2, cause=None, bidirectional=True):
logging.info("Bridging %s and %s (bidirectional: %s)", repr(c1), repr(c2), bidirectional)
links[c1].add(c2)
links[c2].add(c1)
await db.execute("INSERT INTO links VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING", (c1[0], c1[1], c2[0], c2[1], util.timestamp()))
await db.execute("INSERT INTO links VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING", (c2[0], c2[1], c1[0], c1[1], util.timestamp()))
if bidirectional: links[c2].add(c1)
await db.execute("INSERT INTO links VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING", (c1[0], c1[1], c2[0], c2[1], util.timestamp(), cause))
if bidirectional: await db.execute("INSERT INTO links VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING", (c2[0], c2[1], c1[0], c1[1], util.timestamp(), cause))
await db.commit()
async def remove_bridge_link(db, c1, c2):
logging.info("Unbridging %s and %s", repr(c1), repr(c2))
async def remove_bridge_link(db, c1, c2, bidirectional=True):
logging.info("Unbridging %s and %s (bidirectional: %s)", repr(c1), repr(c2), bidirectional)
links[c1].remove(c2)
links[c2].remove(c1)
if bidirectional: links[c2].remove(c1)
await db.execute("DELETE FROM links WHERE (to_type = ? AND to_id = ?) AND (from_type = ? AND from_id = ?)", (c1[0], c1[1], c2[0], c2[1]))
await db.execute("DELETE FROM links WHERE (to_type = ? AND to_id = ?) AND (from_type = ? AND from_id = ?)", (c2[0], c2[1], c1[0], c1[1]))
if bidirectional: await db.execute("DELETE FROM links WHERE (to_type = ? AND to_id = ?) AND (from_type = ? AND from_id = ?)", (c2[0], c2[1], c1[0], c1[1]))
await db.commit()
async def initial_load(db):

View File

@ -28,7 +28,7 @@ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(asctime)s %(mess
intents = discord.Intents.default()
intents.members = True
bot = commands.Bot(command_prefix=config["prefix"], description="AutoBotRobot, the most useless bot in the known universe." + util.config.get("description_suffix", ""),
bot = commands.Bot(command_prefix=commands.when_mentioned_or(config["prefix"]), description="AutoBotRobot, the most useless bot in the known universe." + util.config.get("description_suffix", ""),
case_insensitive=True, allowed_mentions=discord.AllowedMentions(everyone=False, users=True, roles=True), intents=intents)
bot._skip_check = lambda x, y: False
@ -72,7 +72,7 @@ async def andrew_bad(ctx):
async def on_ready():
logging.info("Connected as " + bot.user.name)
await bot.change_presence(status=discord.Status.online,
activity=discord.Activity(name=f"{bot.command_prefix}help", type=discord.ActivityType.listening))
activity=discord.Activity(name=f"{config['prefix']}help", type=discord.ActivityType.listening))
visible_users = prometheus_client.Gauge("abr_visible_users", "Users the bot can see")
def get_visible_users():

View File

@ -2,8 +2,12 @@ from discord.ext import commands
import discord
import logging
import asyncio
import re
import hashlib
from datetime import datetime
import os
import pydot
import tempfile
import util
import eventbus
@ -18,88 +22,172 @@ def generate_address(ctx):
out += words[int.from_bytes(h[i * 2:i * 2 + 3], "little") % 8192].strip().title()
return out
def setup(bot):
@bot.group(name="apiotelephone", aliases=["tel", "tele", "telephone", "apiotel"], brief="ApioTelephone lets you 'call' other servers.", help=f"""
Call other (participating) servers with ApioTelephone! To configure a channel for telephony, do `{bot.command_prefix}tel setup` (requires Manage Channels).
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, do `{bot.command_prefix}tel dial [number]` - the other end has to accept the call.
When you want to end a call, do {bot.command_prefix}tel disconnect.
""")
async def telephone(ctx): pass
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))
async def get_channel_config(channel):
return await bot.database.execute_fetchone("SELECT * FROM telephone_config WHERE channel_id = ?", (channel,))
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
async def get_addr_config(addr):
return await bot.database.execute_fetchone("SELECT * FROM telephone_config WHERE id = ?", (addr,))
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)
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))
async def on_bridge_message(self, channel_id, msg):
channel = self.bot.get_channel(channel_id)
if channel:
webhook = self.webhooks.get(channel_id)
if webhook:
wh_obj = discord.Webhook.from_url(webhook, adapter=discord.AsyncWebhookAdapter(self.bot.http._HTTPClient__session))
await wh_obj.send(
content=render_formatting(channel, msg.message)[:2000], username=msg.author.name, avatar_url=msg.author.avatar_url,
allowed_mentions=discord.AllowedMentions(everyone=False, roles=False, users=False))
else:
text = f"<{msg.author.name}> {render_formatting(channel, msg.message)}"
await channel.send(text[:2000], allowed_mentions=discord.AllowedMentions(everyone=False, roles=False, users=False))
else:
logging.warning("Channel %d not found", channel_id)
@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)
if msg.content == "": return
if (msg.author == self.bot.user and msg.content[0] == "<") or msg.author.discriminator == "0000": return
channel_id = msg.channel.id
msg = eventbus.Message(eventbus.AuthorInfo(msg.author.name, msg.author.id, str(msg.author.avatar_url), msg.author.bot), parse_formatting(self.bot, msg.content), ("discord", channel_id), msg.id)
await eventbus.push(msg)
def cog_unload(self):
self.unlisten()
# ++tel commands
@commands.group(name="apiotelephone", aliases=["tel", "tele", "telephone", "apiotel"], brief="ApioTelephone lets you 'call' other servers.")
async def telephone(self, ctx):
f"""Call other (participating) servers with ApioTelephone! To configure a channel for telephony, use the setup command (requires Manage Channels).
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.
""")
@commands.check(util.admin_check)
async def link(ctx, target_type, target_id):
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
await eventbus.add_bridge_link(bot.database, ("discord", ctx.channel.id), (target_type, target_id))
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.")
@commands.check(util.admin_check)
async def unlink(ctx, target_type, target_id):
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
await eventbus.remove_bridge_link(bot.database, ("discord", ctx.channel.id), (target_type, target_id))
await eventbus.remove_bridge_link(self.bot.database, ("discord", ctx.channel.id), (target_type, target_id), bidirectional)
await ctx.send(f"Successfully deleted.")
pass
@telephone.command(brief="Generate a webhook")
@commands.check(util.admin_check)
async def init_webhook(ctx):
async def init_webhook(self, ctx):
webhook = (await ctx.channel.create_webhook(name="ABR webhook", reason=f"requested by {ctx.author.name}")).url
await bot.database.execute("INSERT OR REPLACE INTO discord_webhooks VALUES (?, ?)", (ctx.channel.id, webhook))
await bot.database.commit()
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()
@commands.check(util.server_mod_check)
async def setup(ctx):
async def setup(self, ctx):
num = generate_address(ctx)
await ctx.send(f"Your address is {num}.")
info = await get_addr_config(num)
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
await bot.database.execute("INSERT OR REPLACE INTO discord_webhooks VALUES (?, ?)", (ctx.channel.id, webhook))
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.")
await bot.database.execute("INSERT OR REPLACE INTO telephone_config VALUES (?, ?, ?, ?)", (num, ctx.guild.id, ctx.channel.id, webhook))
await bot.database.commit()
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.")
@telephone.command(aliases=["call"], brief="Dial another telephone channel.")
async def dial(ctx, address):
async def dial(self, ctx, address):
# basic checks - ensure this is a phone channel and has no other open calls
channel_info = await get_channel_config(ctx.channel.id)
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."))
recv_info = await get_addr_config(address)
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."))
current_call = await bot.database.execute_fetchone("SELECT * FROM calls WHERE from_id = ?", (originating_address,))
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
recv_channel = bot.get_channel(recv_info["channel_id"])
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",
@ -111,24 +199,24 @@ def setup(bot):
call_message.add_reaction("")
)
def check(re, u): return (str(re.emoji) == "" or str(re.emoji) == "") and u != bot.user
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:
reaction, user = await bot.wait_for("reaction_add", timeout=util.config["call_timeout"], check=check)
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"))
)
await asyncio.gather(call_message.remove_reaction("", bot.user), call_message.remove_reaction("", bot.user))
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
await bot.database.execute("INSERT INTO calls VALUES (?, ?, ?)", (originating_address, address, util.timestamp()))
await bot.database.commit()
await eventbus.add_bridge_link(bot.database, ("discord", ctx.channel.id), ("discord", recv_channel.id))
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."))
@ -137,33 +225,33 @@ def setup(bot):
await ctx.send(embed=util.error_embed("Your call was declined.", "Call declined"))
@telephone.command(aliases=["disconnect", "quit"], brief="Disconnect latest call.")
async def hangup(ctx):
channel_info = await get_channel_config(ctx.channel.id)
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."))
from_here = await bot.database.execute_fetchone("SELECT * FROM calls WHERE from_id = ?", (addr,))
to_here = await bot.database.execute_fetchone("SELECT * FROM calls WHERE to_id = ?", (addr,))
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"]
await bot.database.execute("DELETE FROM calls WHERE from_id = ? AND to_id = ?", (addr, other))
await self.bot.database.execute("DELETE FROM calls WHERE from_id = ? AND to_id = ?", (addr, other))
elif to_here:
other = to_here["from_id"]
await bot.database.execute("DELETE FROM calls WHERE to_id = ? AND from_id = ?", (addr, other))
await bot.database.commit()
other_channel = (await get_addr_config(other))["channel_id"]
await eventbus.remove_bridge_link(bot.database, ("discord", other_channel), ("discord", ctx.channel.id))
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.")),
bot.get_channel(other_channel).send(embed=util.info_embed("Hung up", f"Call to {addr} disconnected."))
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.")
async def info(ctx):
channel_info = await get_channel_config(ctx.channel.id)
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"
@ -174,8 +262,49 @@ def setup(bot):
def delta(ts):
return util.format_timedelta(datetime.utcfromtimestamp(ts), now)
incoming = await bot.database.execute_fetchall("SELECT * FROM calls WHERE to_id = ?", (addr,))
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))
outgoing = await bot.database.execute_fetchall("SELECT * FROM calls WHERE from_id = ?", (addr,))
outgoing = await self.bot.database.execute_fetchall("SELECT * FROM calls WHERE from_id = ?", (addr,))
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))
@telephone.command(brief="Dump links out of current channel.")
async def graph(self, ctx):
graph = pydot.Dot("linkgraph")
seen = set()
seen_edges = set()
def node_name(x):
if x[0] == "discord":
chan = self.bot.get_channel(x[1])
if chan:
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())

View File

@ -265,7 +265,6 @@ extensions = (
"commands",
"userdata",
"irc_link",
"discord_link",
"duckduckgo"
)