fixed reply handling and added more support for post interactions
All checks were successful
git.dreamfall.space/pnut-matrix/pipeline/head This commit looks good

- Replies from clients with quotes are handled properly, issue #75
- Repost and Bookmark via reactions now supported
- Bot commands for post interactions added, issue #71
This commit is contained in:
Morgan McMillian 2025-01-23 10:10:41 -08:00
parent c23889b6a8
commit 3d5abbba8b
3 changed files with 277 additions and 132 deletions

View file

@ -23,6 +23,7 @@ dependencies = [
"asyncclick",
"peewee",
"tomlkit",
"beautifulsoup4",
]
[project.urls]

View file

@ -11,6 +11,7 @@ import time
import os
import argparse
from bs4 import BeautifulSoup
from mautrix.client import ClientAPI
from mautrix.types import *
from pnut_matrix.models import *
@ -110,12 +111,6 @@ async def on_receive_events(transaction):
logging.debug(event)
logging.debug('~~~~~~~~~~~~~~~')
# if (app.config['MATRIX_ADMIN_ROOM'] and
# app.config['MATRIX_ADMIN_ROOM'] == event['room_id']):
# logging.debug('>----on_admin_event----<')
# await on_admin_event(event)
# return jsonify({})
user = PnutUsers.select().where(PnutUsers.matrix_id ==
event['sender']).first()
@ -127,6 +122,10 @@ async def on_receive_events(transaction):
logging.debug('>----delete_message----<')
delete_message(event, user)
elif event['type'] == 'm.reaction':
logging.debug('>----react_to_message----<')
await react_message(event, user)
elif event['type'] == 'm.room.member':
if ('is_direct' in event['content'] and
'membership' in event['content']):
@ -166,6 +165,12 @@ async def new_message(event, user):
if event['room_id'] == app.config['pnut']['global_room']:
room = PnutChannels(pnut_chan=0,
room_id=app.config['pnut']['global_room'])
elif event['room_id'] == app.config['pnut']['news_room']:
room = PnutChannels(pnut_chan=0,
room_id=app.config['pnut']['news_room'])
elif event['room_id'] == app.config['pnut']['bot_room']:
room = PnutChannels(pnut_chan=0,
room_id=app.config['pnut']['bot_room'])
else:
logging.debug('-room not mapped-')
@ -191,69 +196,32 @@ async def new_message(event, user):
pnutpy.api.add_authorization_token(token)
raw = {}
raw['io.pnut.core.crosspost'] = [crosspost_raw(event)]
text, oembed = await msg_from_event(event, user)
# raw['io.pnut.core.crosspost'] = [crosspost_raw(event)]
text, oembed, reply_to = await msg_from_event(event, user, room.pnut_chan)
if text is None:
return
text = prefix + text
if oembed:
raw['io.pnut.core.oembed'] = [oembed]
logging.debug(oembed)
reply_to_id = None
ev_content = event['content']
if 'm.relates_to' in ev_content:
m_relates_to = ev_content['m.relates_to']
if 'm.in_reply_to' in m_relates_to:
reply_event_id = m_relates_to['m.in_reply_to']['event_id']
e = Events.select().where(Events.event_id ==
reply_event_id).first()
if e is not None:
reply_to_id = e.pnut_id
try:
payload = {'raw': raw}
if room.pnut_chan == 0:
if reply_to_id is not None:
orig, meta = pnutpy.api.get_post(reply_to_id)
if orig.user.id != user.pnut_user_id:
author = orig.user.username
text = f"@{author} {text}"
if 'content' in orig:
cc_list = []
for m in orig.content.entities.mentions:
if m.text == author:
continue
cc_list.append(f"@{m.text}")
if len(cc_list) > 0:
copy = " ".join(cc_list)
text = f"{text} /{copy}"
payload['reply_to'] = reply_to_id
if room.pnut_chan == 0:
payload['text'] = text
if reply_to:
payload['reply_to'] = reply_to
data, meta = pnutpy.api.create_post(data=payload)
else:
if reply_to_id is not None:
orig, meta = pnutpy.api.get_message(room.pnut_chan, reply_to_id)
if orig.user.id != user.pnut_user_id:
author = orig.user.username
text = f"@{author} {text}"
if 'content' in orig:
cc_list = []
for m in orig.content.entities.mentions:
if m.text == author:
continue
cc_list.append(f"@{m.text}")
if len(cc_list) > 0:
copy = " ".join(cc_list)
text = f"{text} /{copy}"
payload['reply_to'] = reply_to_id
payload['text'] = text
data, meta = pnutpy.api.create_message(room.pnut_chan,
data=payload)
if reply_to:
payload['reply_to'] = reply_to
data, meta = pnutpy.api.create_message(room.pnut_chan, data=payload)
bridge_event = Events(
event_id=event['event_id'],
room_id=event['room_id'],
@ -276,11 +244,11 @@ async def new_message(event, user):
logging.exception('-something bad happened here-')
return
async def msg_from_event(event, user):
async def msg_from_event(event, user, pnut_chan):
text = None
oembed = None
if (event['content']['msgtype'] == 'm.text' or
event['content']['msgtype'] == 'm.notice'):
reply_to = None
if event['content']['msgtype'] == 'm.text':
text = event['content']['body']
fregex = re.compile(r'!file\s(\d+)')
@ -298,6 +266,36 @@ async def msg_from_event(event, user):
'file_token': pnut_file.pnut_file_token,
'format': 'oembed'}}
if text.startswith('!') and pnut_chan == 0:
logging.debug(">>>> stream command <<<<")
text, reply_to = await on_stream_command(event, user, pnut_chan)
elif 'm.relates_to' in event['content']:
m_relates_to = event['content']['m.relates_to']
if 'm.in_reply_to' in m_relates_to:
reply_event_id = m_relates_to['m.in_reply_to']['event_id']
e = Events.select().where(Events.event_id ==
reply_event_id).first()
if e is not None:
reply_to = e.pnut_id
if 'formatted_body' in event['content']:
formatted_body = event['content']['formatted_body']
soup = BeautifulSoup(formatted_body, 'html.parser')
mx_reply = soup.findAll('mx-reply')
for item in mx_reply:
item.decompose()
body = soup.decode()
m = fregex.search(body)
if m is not None:
file_id = m.group(1)
body = fregex.sub('', body)
else:
body = text
text = cmd_stream_reply(user, reply_to, body, pnut_chan)
elif event['content']['msgtype'] == 'm.emote':
text = "* " + event['content']['body']
@ -306,7 +304,7 @@ async def msg_from_event(event, user):
event['content']['msgtype'] == 'm.audio') and
user is not None):
await media_from_event(event, user)
return None, None
return None, None, None
elif (event['content']['msgtype'] == 'm.image' or
event['content']['msgtype'] == 'm.video' or
@ -329,7 +327,7 @@ async def msg_from_event(event, user):
logging.debug('-unknown msg type- ' + event['content']['msgtype'])
return
return text, oembed
return text, oembed, reply_to
def crosspost_raw(event):
cross_profile = {'username': event['sender']}
@ -463,6 +461,50 @@ def delete_message(event, user):
except pnutpy.errors.PnutPermissionDenied:
pass
async def react_message(event, user):
as_id = (f"@{app.config['matrix']['sender_local']}:"
f"{app.config['matrix']['domain']}")
matrix_api = ClientAPI(as_id,
base_url=app.config['matrix']['homeserver'],
token=app.config['matrix']['as_token'])
if event['sender'] == as_id:
return
if app.config['matrix']['namespace'] in event['sender']:
return
if user is None:
return
pnutpy.api.add_authorization_token(user.pnut_user_token)
m_relates_to = event['content']['m.relates_to']
react_event_id = m_relates_to['event_id']
e = Events.select().where(Events.event_id == react_event_id).first()
if e is None:
logging.debug("- can't find the event to react to -")
return
if e.pnut_channel == 0:
# repost
if m_relates_to['key'] == '👀':
reply = cmd_stream_repost(e.pnut_id)
# bookmark
elif m_relates_to['key'] in ['❤️', '', '👍', '🔖']:
reply = cmd_stream_bookmark(e.pnut_id)
else:
return
reply = f'{event['sender']} {reply}'
reply_msg = TextMessageEventContent(msgtype=MessageType.NOTICE,
body=reply)
await matrix_api.send_message(event['room_id'], reply_msg)
def get_profile(userid):
url = app.config['matrix']['homeserver'] + "/_matrix/client/r0/profile/" + userid
r = requests.get(url)
@ -559,78 +601,6 @@ def new_matrix_user(username):
logging.debug(r.text)
return
# async def on_admin_event(event):
# matrix_api = ClientAPI(app.config['MATRIX_AS_ID'],
# base_url=app.config['MATRIX_HOST'],
# token=app.config['MATRIX_AS_TOKEN'])
#
# logging.debug("- admin room event recieved -")
#
# if event['type'] != 'm.room.message':
# return jsonify({})
#
# msg = event['content']['body'].split(' ')
#
# try:
# if msg[0] == 'help':
# if len(msg) > 1:
# await matrix_api.send_message(event['room_id'],
# cmd_admin_help(msg[1]))
# else:
# await matrix_api.send_message(event['room_id'],
# cmd_admin_help())
#
# elif msg[0] == 'list':
# await matrix_api.send_message(event['room_id'], cmd_admin_list())
#
# except Exception:
# errmsg = "- on_admin_event -"
# logging.exception(errmsg)
# def cmd_admin_help(cmd=None):
# help_usage = "help [command]"
# help_desc = "Show information about available commands."
# list_usage = "list"
# list_desc = "List the rooms currently linked with pnut.io."
#
# if cmd == 'help':
# text = "usage: " + help_usage + "\n\n"
# text += help_desc
# if cmd == 'list':
# text = "usage: " + list_usage + "\n\n"
# text += list_desc
# elif cmd == 'unlink':
# text = "usage: " + unlink_usage + "\n\n"
# text += unlink_desc
# elif cmd == 'link':
# text = "usage: " + link_usage + "\n\n"
# text += link_desc
# else:
# text = "The following commands are available:\n\n"
# text += help_usage + "\n"
# text += list_usage + "\n"
# text += unlink_usage + "\n"
# text += link_usage + "\n"
#
# return TextMessageEventContent(msgtype='m.text', body=text)
# def cmd_admin_list():
# text = ""
# rooms = PnutChannels.select()
#
# if len(rooms) > 0:
# text = "ID\tMATRIX ID\tPNUT CHANNEL\n"
# else:
# text = " - no rooms are currently linked - \n"
#
# for room in rooms:
# text += str(room.id) + '\t'
# text += room.room_id + '\t\t\t\t\t'
# text += str(room.pnut_chan) + '\t'
# text += '\n'
#
# return TextMessageEventContent(msgtype='m.text', body=text)
async def on_direct_invite(event):
as_id = (f"@{app.config['matrix']['sender_local']}:"
f"{app.config['matrix']['domain']}")
@ -730,7 +700,7 @@ async def on_direct_message(event, user, room):
raw = {}
raw['io.pnut.core.crosspost'] = [crosspost_raw(event)]
evtext, evraw = await msg_from_event(event, user)
evtext, evraw, noop = await msg_from_event(event, user, room.pnut_chan)
text = prefix + evtext
try:
@ -752,6 +722,180 @@ async def on_direct_message(event, user, room):
return jsonify({})
async def on_stream_command(event, user, pnut_chan):
as_id = (f"@{app.config['matrix']['sender_local']}:"
f"{app.config['matrix']['domain']}")
matrix_api = ClientAPI(as_id,
base_url=app.config['matrix']['homeserver'],
token=app.config['matrix']['as_token'])
if event['type'] != 'm.room.message':
return None, None
args = event['content']['body'].split(' ')
stream_commands = {
'!help': '\t\t\t\tdisplay help text',
'!reply': ' (post_id) (text)\treply to post',
'!replyg': ' (post_id) (text)\treply globally to post',
'!repost': ' (post_id)\t\trepost a post to your followers',
'!bookmark': ' (post_id)\t\tbookmark a post',
}
if args[0] == '!help':
reply = f'{event['sender']}\n'
reply += '<pre>Commands:\n'
for key in stream_commands:
reply += f"{key} {stream_commands[key]}\n"
reply += '</pre>'
reply_msg = TextMessageEventContent(msgtype=MessageType.TEXT,
format=Format.HTML,
formatted_body=reply)
await matrix_api.send_message(event['room_id'], reply_msg)
return None, None
elif args[0] == '!reply':
if len(args) < 3:
reply = f'{event['sender']} invalid command!\n'
reply += f"<pre>Usage: !reply {stream_commands['!reply']}</pre>"
reply_msg = TextMessageEventContent(msgtype=MessageType.TEXT,
format=Format.HTML,
formatted_body=reply)
await matrix_api.send_message(event['room_id'], reply_msg)
return None, None
text = cmd_stream_reply(user, args[1], ' '.join(args[2:]), pnut_chan)
reply_to = args[1]
return text, reply_to
elif args[0] == '!replyg':
if len(args) < 3:
reply = f'{event['sender']} invalid command!\n'
reply += f"<pre>Usage: !replyg {stream_commands['!replyg']}</pre>"
reply_msg = TextMessageEventContent(msgtype=MessageType.TEXT,
format=Format.HTML,
formatted_body=reply)
await matrix_api.send_message(event['room_id'], reply_msg)
return None, None
text = ' '.join(args[2:])
reply_to = args[1]
return text, reply_to
elif args[0] == '!repost':
if len(args) < 2:
reply = f'{event['sender']} invalid command!\n'
reply += f"<pre>Usage: !repost {stream_commands['!repost']}</pre>"
reply_msg = TextMessageEventContent(msgtype=MessageType.TEXT,
format=Format.HTML,
formatted_body=reply)
reply = await matrix_api.send_message(event['room_id'], reply_msg)
return None, None
reply = cmd_stream_repost(args[1])
reply = f'{event['sender']} {reply}'
reply_msg = TextMessageEventContent(msgtype=MessageType.NOTICE,
body=reply)
await matrix_api.send_message(event['room_id'], reply_msg)
return None, None
elif args[0] == '!bookmark':
if len(args) < 2:
reply = f'{event['sender']} invalid command!\n'
reply += f"<pre>Usage: !bookmark {stream_commands['!bookmark']}</pre>"
reply_msg = TextMessageEventContent(msgtype=MessageType.TEXT,
format=Format.HTML,
formatted_body=reply)
reply = await matrix_api.send_message(event['room_id'], reply_msg)
return None, None
reply = cmd_stream_bookmark(args[1])
reply = f'{event['sender']} {reply}'
reply_msg = TextMessageEventContent(msgtype=MessageType.NOTICE,
body=reply)
await matrix_api.send_message(event['room_id'], reply_msg)
return None, None
else:
logging.debug('>>> unknown command')
logging.debug(text)
logging.debug(user)
return None, None
def cmd_stream_reply(user, post_id, text, pnut_chan):
logging.debug(f'>>> reply to {post_id}')
logging.debug(text)
try:
pnut_user, meta = pnutpy.api.get_user(user.pnut_user_id)
if pnut_chan == 0:
orig, meta = pnutpy.api.get_post(post_id)
else:
orig, meta = pnutpy.api.get_message(pnut_chan, post_id)
if orig.user.id != user.pnut_user_id:
author = orig.user.username
text = f"@{author} {text}"
if 'content' in orig:
cc_list = []
for m in orig.content.entities.mentions:
if m.text == author:
continue
if m.text == pnut_user.username:
continue
cc_list.append(f"@{m.text}")
if len(cc_list) > 0:
copy = " ".join(cc_list)
text = f"{text} /{copy}"
return text
except pnutpy.errors.PnutAuthAPIException:
logging.error('>>> PNUT AUTH ERROR!!!')
except Exception:
logging.exception('!!!!!!!!!!!!!!!!!!!!!')
def cmd_stream_bookmark(post_id):
logging.debug(f'>>> bookmark {post_id}')
try:
orig, meta = pnutpy.api.get_post(post_id)
logging.debug(f'>>> {orig.content.text}')
if orig.you_bookmarked:
post, meta = pnutpy.api.unbookmark_post(post_id)
return f'unbookmarked post {post.id}'
else:
post, meta = pnutpy.api.bookmark_post(post_id)
return f'bookmarked post {post.id}'
except pnutpy.errors.PnutAuthAPIException:
logging.error('>>> PNUT AUTH ERROR!!!')
except Exception:
logging.exception('!!!!!!!!!!!!!!!!!!!!!')
def cmd_stream_repost(post_id):
logging.debug(f'>>> repost {post_id}')
try:
orig, meta = pnutpy.api.get_post(post_id)
logging.debug(f'>>> {orig.content.text}')
if orig.you_reposted:
post, meta = pnutpy.api.unrepost_post(post_id)
return f'unreposted post {post_id}'
else:
post, meta = pnutpy.api.repost_post(post_id)
return f'reposted post {post_id}'
except pnutpy.errors.PnutAuthAPIException:
logging.error('>>> PNUT AUTH ERROR!!!')
except Exception:
logging.exception('!!!!!!!!!!!!!!!!!!!!!')
async def on_control_message(event, user):
as_id = (f"@{app.config['matrix']['sender_local']}:"
f"{app.config['matrix']['domain']}")

View file

@ -196,7 +196,7 @@ async def new_pnut_post(post, meta):
postlink = f"https://posts.pnut.io/{post.id}"
plaintext = f"{post.content.text}\n{postlink}"
htmltext = (f"{post.content.html}"
f" &nbsp;<a href='{postlink}'>[🔗]</a>")
f" &nbsp;<a href='{postlink}'>[🔗]</a> - {post.id}")
eventtext = TextMessageEventContent(msgtype=MessageType.TEXT,
format=Format.HTML,
body=plaintext,