Cross-platform messaging working, preparations for bridges

This commit is contained in:
2024-06-30 01:42:37 +02:00
parent 6d2f51f02c
commit 754e199526
9 changed files with 103 additions and 55 deletions

View File

@ -15,13 +15,13 @@ LogToFile = True
DumpToConsole = False
DumpToFile = False
AdminIds = [ "telegram:123456789", "telegram:634314973", "activitypub:admin@mastodon.example.com", ]
AdminIds = [ "telegram:123456789", "telegram:634314973", "matrix:@admin:matrix.example.com", "matrix:@octt:matrix.org", "activitypub:admin@mastodon.example.com", ]
DefaultLang = "en"
BridgesConfig = []
DefaultLanguage = "en"
Debug = False
CmdPrefixes = ".!/"
# False: ASCII output; True: ANSI Output (must be escaped)
ExecAllowed = {"date": False, "fortune": False, "neofetch": True, "uptime": False}
WebUserAgent = "WinDog v.Staging"
#ModuleGroups = (ModuleGroups | {

View File

@ -10,7 +10,7 @@
# end windog config # """
MastodonUrl, MastodonToken = None, None
MastodonUrl = MastodonToken = None
import mastodon
from bs4 import BeautifulSoup
@ -32,7 +32,6 @@ def MastodonMakeInputMessageData(status:dict) -> InputMessageData:
text_html = status["content"],
)
data.text_plain = BeautifulSoup(data.text_html, "html.parser").get_text()
data.text_auto = GetWeightedText(data.text_html, data.text_plain)
command_tokens = data.text_plain.strip().replace("\t", " ").split(" ")
while command_tokens[0].strip().startswith('@') or not command_tokens[0]:
command_tokens.pop(0)
@ -51,7 +50,7 @@ def MastodonHandler(event, Mastodon):
if (command := ObjGet(data, "command.name")):
CallEndpoint(command, EventContext(platform="mastodon", event=event, manager=Mastodon), data)
def MastodonSender(context:EventContext, data:OutputMessageData, destination) -> None:
def MastodonSender(context:EventContext, data:OutputMessageData) -> None:
media_results = None
if data.media:
media_results = []
@ -69,5 +68,5 @@ def MastodonSender(context:EventContext, data:OutputMessageData, destination) ->
visibility=('direct' if context.event['status']['visibility'] == 'direct' else 'unlisted'),
)
RegisterPlatform(name="Mastodon", main=MastodonMain, sender=MastodonSender, managerClass=mastodon.Mastodon)
RegisterPlatform(name="Mastodon", main=MastodonMain, sender=MastodonSender, manager_class=mastodon.Mastodon)

View File

@ -16,11 +16,14 @@
# end windog config # """
MatrixUrl, MatrixUsername, MatrixPassword, MatrixToken = None, None, None, None
MatrixClient = None
MatrixUrl = MatrixUsername = MatrixPassword = MatrixToken = None
from asyncio import run as asyncio_run, create_task as asyncio_create_task
import asyncio
import nio
import queue
MatrixClient = None
MatrixQueue = []#queue.Queue()
def MatrixMain() -> bool:
if not (MatrixUrl and MatrixUsername and (MatrixPassword or MatrixToken)):
@ -28,6 +31,13 @@ def MatrixMain() -> bool:
def upgrade_username(new:str):
global MatrixUsername
MatrixUsername = new
async def queue_handler():
asyncio.ensure_future(queue_handler())
if not len(MatrixQueue):
# avoid 100% CPU usage ☠️
time.sleep(0.01)
while len(MatrixQueue):
MatrixSender(*MatrixQueue.pop(0))
async def client_main() -> None:
global MatrixClient
MatrixClient = nio.AsyncClient(MatrixUrl, MatrixUsername)
@ -37,9 +47,10 @@ def MatrixMain() -> bool:
if (bot_id := ObjGet(login, "user_id")):
upgrade_username(bot_id) # ensure username is fully qualified for the API
await MatrixClient.sync(30000) # resync old messages first to "skip read ones"
asyncio.ensure_future(queue_handler())
MatrixClient.add_event_callback(MatrixMessageHandler, nio.RoomMessage)
await MatrixClient.sync_forever(timeout=30000)
Thread(target=lambda:asyncio_run(client_main())).start()
Thread(target=lambda:asyncio.run(client_main())).start()
return True
def MatrixMakeInputMessageData(room:nio.MatrixRoom, event:nio.RoomMessage) -> InputMessageData:
@ -47,7 +58,8 @@ def MatrixMakeInputMessageData(room:nio.MatrixRoom, event:nio.RoomMessage) -> In
message_id = f"matrix:{event.event_id}",
datetime = event.server_timestamp,
text_plain = event.body,
text_html = event.formatted_body, # note: this could be None
text_html = ObjGet(event, "formatted_body"), # this could be unavailable
media = ({"url": event.url} if ObjGet(event, "url") else None),
room = SafeNamespace(
id = f"matrix:{room.room_id}",
name = room.display_name,
@ -69,8 +81,16 @@ async def MatrixMessageHandler(room:nio.MatrixRoom, event:nio.RoomMessage) -> No
if (command := ObjGet(data, "command.name")):
CallEndpoint(command, EventContext(platform="matrix", event=SafeNamespace(room=room, event=event), manager=MatrixClient), data)
def MatrixSender(context:EventContext, data:OutputMessageData, destination) -> None:
asyncio_create_task(context.manager.room_send(room_id=context.event.room.room_id, message_type="m.room.message", content={"msgtype": "m.text", "body": data.text_plain}))
def MatrixSender(context:EventContext, data:OutputMessageData):
try:
asyncio.get_event_loop()
except RuntimeError:
MatrixQueue.append((context, data))
return None
asyncio.create_task(context.manager.room_send(
room_id=(data.room_id or ObjGet(context, "event.room.room_id")),
message_type="m.room.message",
content={"msgtype": "m.text", "body": data.text_plain}))
RegisterPlatform(name="Matrix", main=MatrixMain, sender=MatrixSender)
RegisterPlatform(name="Matrix", main=MatrixMain, sender=MatrixSender, manager_class=(lambda:MatrixClient))

View File

@ -12,19 +12,21 @@
TelegramToken = None
import telegram, telegram.ext
from telegram import ForceReply, Bot #, Update
from telegram import Bot #, Update
#from telegram.helpers import escape_markdown
#from telegram.ext import Application, filters, CommandHandler, MessageHandler, CallbackContext
from telegram.utils.helpers import escape_markdown
from telegram.ext import CommandHandler, MessageHandler, Filters, CallbackContext
TelegramClient = None
def TelegramMain() -> bool:
if not TelegramToken:
return False
updater = telegram.ext.Updater(TelegramToken)
dispatcher = updater.dispatcher
dispatcher.add_handler(MessageHandler(Filters.text | Filters.command, TelegramHandler))
updater.start_polling()
global TelegramClient
TelegramClient = telegram.ext.Updater(TelegramToken)
TelegramClient.dispatcher.add_handler(MessageHandler(Filters.text | Filters.command, TelegramHandler))
TelegramClient.start_polling()
#app = Application.builder().token(TelegramToken).build()
#app.add_handler(MessageHandler(filters.TEXT | filters.COMMAND, TelegramHandler))
#app.run_polling(allowed_updates=Update.ALL_TYPES)
@ -49,7 +51,6 @@ def TelegramMakeInputMessageData(message:telegram.Message) -> InputMessageData:
name = (message.chat.title or message.chat.first_name),
),
)
data.text_auto = GetWeightedText(data.text_markdown, data.text_plain)
data.command = ParseCommand(data.text_plain)
data.user.settings = (GetUserSettings(data.user.id) or SafeNamespace())
linked = TelegramLinker(data)
@ -69,10 +70,10 @@ def TelegramHandler(update:telegram.Update, context:CallbackContext=None) -> Non
CallEndpoint(command, EventContext(platform="telegram", event=update, manager=context), data)
Thread(target=handler).start()
def TelegramSender(context:EventContext, data:OutputMessageData, destination):
def TelegramSender(context:EventContext, data:OutputMessageData):
result = None
if destination:
result = context.manager.bot.send_message(destination, text=data.text_plain)
if data.room_id:
result = context.manager.bot.send_message(data.room_id, text=data.text_plain)
else:
replyToId = (data.ReplyTo or context.event.message.message_id)
if data.media:
@ -103,5 +104,5 @@ def TelegramLinker(data:InputMessageData) -> SafeNamespace:
linked.message = f"https://t.me/c/{room_id}/{message_id}"
return linked
RegisterPlatform(name="Telegram", main=TelegramMain, sender=TelegramSender, linker=TelegramLinker, eventClass=telegram.Update)
RegisterPlatform(name="Telegram", main=TelegramMain, sender=TelegramSender, linker=TelegramLinker, event_class=telegram.Update, manager_class=(lambda:TelegramClient))

View File

@ -7,10 +7,11 @@ def cBroadcast(context:EventContext, data:InputMessageData) -> None:
if (data.user.id not in AdminIds) and (data.user.tag not in AdminIds):
return SendMessage(context, {"Text": choice(Locale.__('eval'))})
destination = data.command.arguments["destination"]
if not (destination and data.command.body):
return SendMessage(context, {"Text": "Bad usage."})
SendMessage(context, {"TextPlain": data.command.body}, destination)
SendMessage(context, {"TextPlain": "Executed."})
text = data.command.body
if not (destination and text):
return SendMessage(context, OutputMessageData(text_plain="Bad usage."))
SendMessage(context, OutputMessageData(text_plain=text, room_id=destination))
SendMessage(context, OutputMessageData(text_plain="Executed."))
RegisterModule(name="Broadcast", endpoints=[
SafeNamespace(names=["broadcast"], handler=cBroadcast, arguments={

View File

@ -18,15 +18,15 @@ def HttpReq(url:str, method:str|None=None, *, body:bytes=None, headers:dict[str,
def cEmbedded(context:EventContext, data:InputMessageData) -> None:
if len(data.command.tokens) >= 2:
# Find links in command body
Text = (data.text_markdown + ' ' + data.text_plain)
elif data.quoted and data.quoted.text_auto:
text = (data.text_markdown + ' ' + data.text_plain)
elif (quoted := data.quoted) and (quoted.text_auto or quoted.text_markdown or quoted.text_html):
# Find links in quoted message
Text = (data.quoted.text_markdown + ' ' + data.quoted.text_plain)
text = ((quoted.text_markdown or '') + ' ' + (quoted.text_plain or '') + ' ' + (quoted.text_html or ''))
else:
# TODO Error message
return
pass
urls = URLExtract().find_urls(Text)
urls = URLExtract().find_urls(text)
if len(urls) > 0:
proto = 'https://'
url = urls[0]
@ -47,7 +47,7 @@ def cEmbedded(context:EventContext, data:InputMessageData) -> None:
elif urlDomain == "vm.tiktok.com":
urlDomain = "vm.vxtiktok.com"
url = (urlDomain + '/' + '/'.join(url.split('/')[1:]))
SendMessage(context, {"TextPlain": f"{{{proto}{url}}}"})
SendMessage(context, {"text_plain": f"{{{proto}{url}}}"})
# else TODO error message?
def cWeb(context:EventContext, data:InputMessageData) -> None:
@ -79,13 +79,13 @@ def cNews(context:EventContext, data:InputMessageData) -> None:
pass
def cTranslate(context:EventContext, data:InputMessageData) -> None:
instances = ["lingva.ml", "lingva.lunar.icu"]
language_to = data.command.arguments["language_to"]
text_input = (data.command.body or (data.quoted and data.quoted.text_plain))
if not (text_input and language_to):
return SendMessage(context, {"TextPlain": f"Usage: /translate <to language> <text>"})
try:
# TODO: Use many different public Lingva instances in rotation to avoid overloading a specific one
result = json.loads(HttpReq(f'https://lingva.ml/api/v1/auto/{language_to}/{urlparse.quote(text_input)}').read())
result = json.loads(HttpReq(f'https://{randchoice(instances)}/api/v1/auto/{language_to}/{urlparse.quote(text_input)}').read())
SendMessage(context, {"TextPlain": f"[{result['info']['detectedSource']} (auto) -> {language_to}]\n\n{result['translation']}"})
except Exception:
raise

View File

@ -3,6 +3,13 @@
# Licensed under AGPLv3 by OctoSpacc #
# ==================================== #
""" # windog config start # """
# False: ASCII output; True: ANSI Output (must be escaped)
ExecAllowed = {"date": False, "fortune": False, "neofetch": True, "uptime": False}
""" # end windog config # """
import subprocess
from re import compile as re_compile

View File

@ -9,6 +9,7 @@ WinDog/WinDogBot is a chatbot I've been (lazily) developing for years, with some
The officially-hosted instances of this bot are, respectively:
* [@WinDogBot](https://t.me/WinDogBot) on Telegram
* [@windog:matrix.org](https://matrix.to/#/@windog:matrix.org) on Matrix
* [@WinDog@botsin.space](https://botsin.space/@WinDog) on Mastodon (can also be used from any other Fediverse platform)
In case you want to run your own instance:

View File

@ -13,8 +13,8 @@ from os import listdir
from os.path import isfile, isdir
from random import choice, choice as randchoice, randint
from threading import Thread
from traceback import format_exc
from urllib import parse as urlparse
from traceback import format_exc, format_exc as traceback_format_exc
from urllib import parse as urlparse, urllib_parse
from yaml import load as yaml_load, BaseLoader as yaml_BaseLoader
from bs4 import BeautifulSoup
from markdown import markdown
@ -25,12 +25,16 @@ from LibWinDog.Database import *
# <https://daringfireball.net/projects/markdown/syntax#backslash>
MdEscapes = '\\`*_{}[]()<>#+-.!|='
def NamespaceUnion(namespaces:list|tuple, clazz=SimpleNamespace):
def ObjectUnion(*objects:object, clazz:object=None):
dikt = {}
for namespace in namespaces:
for key, value in tuple(namespace.__dict__.items()):
auto_clazz = None
for obj in objects:
if type(obj) == dict:
obj = (clazz or SafeNamespace)(**obj)
for key, value in tuple(obj.__dict__.items()):
dikt[key] = value
return clazz(**dikt)
auto_clazz = obj.__class__
return (clazz or auto_clazz)(**dikt)
def Log(text:str, level:str="?", *, newline:bool|None=None, inline:bool=False) -> None:
endline = '\n'
@ -53,13 +57,13 @@ def SetupLocales() -> None:
Log(f'Cannot load {lang} locale, exiting.')
raise
exit(1)
for key in Locale[DefaultLang]:
Locale['Fallback'][key] = Locale[DefaultLang][key]
for key in Locale[DefaultLanguage]:
Locale['Fallback'][key] = Locale[DefaultLanguage][key]
for lang in Locale:
for key in Locale[lang]:
if not key in Locale['Fallback']:
Locale['Fallback'][key] = Locale[lang][key]
def querier(query:str, lang:str=DefaultLang):
def querier(query:str, lang:str=DefaultLanguage):
value = None
query = query.split('.')
try:
@ -106,7 +110,7 @@ def isinstanceSafe(clazz:any, instance:any, /) -> bool:
return False
def get_string(bank:dict, query:str|dict, lang:str=None, /):
if not (result := ObjGet(bank, f"{query}.{lang or DefaultLang}")):
if not (result := ObjGet(bank, f"{query}.{lang or DefaultLanguage}")):
if not (result := ObjGet(bank, f"{query}.en")):
result = ObjGet(bank, query)
return result
@ -199,6 +203,13 @@ def ParseCommand(text:str) -> SafeNamespace|None:
def OnMessageParsed(data:InputMessageData) -> None:
DumpMessage(data)
UpdateUserDb(data.user)
for bridge in BridgesConfig:
if data.room.id in bridge:
rooms = list(bridge)
rooms.remove(data.room.id)
for room in rooms:
tokens = room.split(':')
SendMessage(SafeNamespace(platform=tokens[0]), ObjectUnion(data, {"room_id": ':'.join(tokens)}))
def UpdateUserDb(user:SafeNamespace) -> None:
try:
@ -214,14 +225,14 @@ def UpdateUserDb(user:SafeNamespace) -> None:
def DumpMessage(data:InputMessageData) -> None:
if not (Debug and (DumpToFile or DumpToConsole)):
return
text = (data.text_plain.replace('\n', '\\n') if data.text_auto else '')
text = (data.text_plain.replace('\n', '\\n') if data.text_plain else '')
text = f"[{int(time.time())}] [{time.ctime()}] [{data.room and data.room.id}] [{data.message_id}] [{data.user.id}] {text}"
if DumpToConsole:
print(text, data)
if DumpToFile:
open((DumpToFile if (DumpToFile and type(DumpToFile) == str) else "./Dump.txt"), 'a').write(text + '\n')
def SendMessage(context:EventContext, data:OutputMessageData, destination=None) -> None:
def SendMessage(context:EventContext, data:OutputMessageData) -> None:
data = (OutputMessageData(**data) if type(data) == dict else data)
# TODO remove this after all modules are changed
@ -244,10 +255,18 @@ def SendMessage(context:EventContext, data:OutputMessageData, destination=None)
#data.text_html = ???
if data.media:
data.media = SureArray(data.media)
#for platform in Platforms.values():
# if isinstanceSafe(context.event, platform.eventClass) or isinstanceSafe(context.manager, platform.managerClass):
# return platform.sender(context, data, destination)
return Platforms[context.platform].sender(context, data, destination)
if data.room_id:
tokens = data.room_id.split(':')
if tokens[0] != context.platform:
context.platform = tokens[0]
context.manager = context.event = None
data.room_id = ':'.join(tokens[1:])
if context.platform not in Platforms:
return None
platform = Platforms[context.platform]
if (not context.manager) and (manager := platform.manager_class):
context.manager = (manager() if callable(manager) else manager)
return platform.sender(context, data)
def SendNotice(context:EventContext, data) -> None:
pass
@ -255,8 +274,8 @@ def SendNotice(context:EventContext, data) -> None:
def DeleteMessage(context:EventContext, data) -> None:
pass
def RegisterPlatform(name:str, main:callable, sender:callable, linker:callable=None, *, eventClass=None, managerClass=None) -> None:
Platforms[name.lower()] = SafeNamespace(main=main, sender=sender, linker=linker, eventClass=eventClass, managerClass=managerClass)
def RegisterPlatform(name:str, main:callable, sender:callable, linker:callable=None, *, event_class=None, manager_class=None) -> None:
Platforms[name.lower()] = SafeNamespace(main=main, sender=sender, linker=linker, event_class=event_class, manager_class=manager_class)
Log(f"{name}, ", inline=True)
def RegisterModule(name:str, endpoints:dict, *, group:str|None=None) -> None: