mirror of
https://gitlab.com/octospacc/WinDog.git
synced 2025-06-05 22:09:20 +02:00
Cross-platform messaging working, preparations for bridges
This commit is contained in:
@ -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 | {
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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))
|
||||
|
||||
|
@ -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))
|
||||
|
||||
|
@ -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={
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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:
|
||||
|
55
WinDog.py
55
WinDog.py
@ -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:
|
||||
|
Reference in New Issue
Block a user