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 DumpToConsole = False
DumpToFile = 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 Debug = False
CmdPrefixes = ".!/" CmdPrefixes = ".!/"
# False: ASCII output; True: ANSI Output (must be escaped)
ExecAllowed = {"date": False, "fortune": False, "neofetch": True, "uptime": False}
WebUserAgent = "WinDog v.Staging" WebUserAgent = "WinDog v.Staging"
#ModuleGroups = (ModuleGroups | { #ModuleGroups = (ModuleGroups | {

View File

@ -10,7 +10,7 @@
# end windog config # """ # end windog config # """
MastodonUrl, MastodonToken = None, None MastodonUrl = MastodonToken = None
import mastodon import mastodon
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
@ -32,7 +32,6 @@ def MastodonMakeInputMessageData(status:dict) -> InputMessageData:
text_html = status["content"], text_html = status["content"],
) )
data.text_plain = BeautifulSoup(data.text_html, "html.parser").get_text() 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(" ") command_tokens = data.text_plain.strip().replace("\t", " ").split(" ")
while command_tokens[0].strip().startswith('@') or not command_tokens[0]: while command_tokens[0].strip().startswith('@') or not command_tokens[0]:
command_tokens.pop(0) command_tokens.pop(0)
@ -51,7 +50,7 @@ def MastodonHandler(event, Mastodon):
if (command := ObjGet(data, "command.name")): if (command := ObjGet(data, "command.name")):
CallEndpoint(command, EventContext(platform="mastodon", event=event, manager=Mastodon), data) 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 media_results = None
if data.media: if data.media:
media_results = [] media_results = []
@ -69,5 +68,5 @@ def MastodonSender(context:EventContext, data:OutputMessageData, destination) ->
visibility=('direct' if context.event['status']['visibility'] == 'direct' else 'unlisted'), 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 # """ # end windog config # """
MatrixUrl, MatrixUsername, MatrixPassword, MatrixToken = None, None, None, None MatrixUrl = MatrixUsername = MatrixPassword = MatrixToken = None
MatrixClient = None
from asyncio import run as asyncio_run, create_task as asyncio_create_task import asyncio
import nio import nio
import queue
MatrixClient = None
MatrixQueue = []#queue.Queue()
def MatrixMain() -> bool: def MatrixMain() -> bool:
if not (MatrixUrl and MatrixUsername and (MatrixPassword or MatrixToken)): if not (MatrixUrl and MatrixUsername and (MatrixPassword or MatrixToken)):
@ -28,6 +31,13 @@ def MatrixMain() -> bool:
def upgrade_username(new:str): def upgrade_username(new:str):
global MatrixUsername global MatrixUsername
MatrixUsername = new 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: async def client_main() -> None:
global MatrixClient global MatrixClient
MatrixClient = nio.AsyncClient(MatrixUrl, MatrixUsername) MatrixClient = nio.AsyncClient(MatrixUrl, MatrixUsername)
@ -37,9 +47,10 @@ def MatrixMain() -> bool:
if (bot_id := ObjGet(login, "user_id")): if (bot_id := ObjGet(login, "user_id")):
upgrade_username(bot_id) # ensure username is fully qualified for the API upgrade_username(bot_id) # ensure username is fully qualified for the API
await MatrixClient.sync(30000) # resync old messages first to "skip read ones" await MatrixClient.sync(30000) # resync old messages first to "skip read ones"
asyncio.ensure_future(queue_handler())
MatrixClient.add_event_callback(MatrixMessageHandler, nio.RoomMessage) MatrixClient.add_event_callback(MatrixMessageHandler, nio.RoomMessage)
await MatrixClient.sync_forever(timeout=30000) await MatrixClient.sync_forever(timeout=30000)
Thread(target=lambda:asyncio_run(client_main())).start() Thread(target=lambda:asyncio.run(client_main())).start()
return True return True
def MatrixMakeInputMessageData(room:nio.MatrixRoom, event:nio.RoomMessage) -> InputMessageData: 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}", message_id = f"matrix:{event.event_id}",
datetime = event.server_timestamp, datetime = event.server_timestamp,
text_plain = event.body, 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( room = SafeNamespace(
id = f"matrix:{room.room_id}", id = f"matrix:{room.room_id}",
name = room.display_name, name = room.display_name,
@ -69,8 +81,16 @@ async def MatrixMessageHandler(room:nio.MatrixRoom, event:nio.RoomMessage) -> No
if (command := ObjGet(data, "command.name")): if (command := ObjGet(data, "command.name")):
CallEndpoint(command, EventContext(platform="matrix", event=SafeNamespace(room=room, event=event), manager=MatrixClient), data) CallEndpoint(command, EventContext(platform="matrix", event=SafeNamespace(room=room, event=event), manager=MatrixClient), data)
def MatrixSender(context:EventContext, data:OutputMessageData, destination) -> None: def MatrixSender(context:EventContext, data:OutputMessageData):
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})) 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 TelegramToken = None
import telegram, telegram.ext import telegram, telegram.ext
from telegram import ForceReply, Bot #, Update from telegram import Bot #, Update
#from telegram.helpers import escape_markdown #from telegram.helpers import escape_markdown
#from telegram.ext import Application, filters, CommandHandler, MessageHandler, CallbackContext #from telegram.ext import Application, filters, CommandHandler, MessageHandler, CallbackContext
from telegram.utils.helpers import escape_markdown from telegram.utils.helpers import escape_markdown
from telegram.ext import CommandHandler, MessageHandler, Filters, CallbackContext from telegram.ext import CommandHandler, MessageHandler, Filters, CallbackContext
TelegramClient = None
def TelegramMain() -> bool: def TelegramMain() -> bool:
if not TelegramToken: if not TelegramToken:
return False return False
updater = telegram.ext.Updater(TelegramToken) global TelegramClient
dispatcher = updater.dispatcher TelegramClient = telegram.ext.Updater(TelegramToken)
dispatcher.add_handler(MessageHandler(Filters.text | Filters.command, TelegramHandler)) TelegramClient.dispatcher.add_handler(MessageHandler(Filters.text | Filters.command, TelegramHandler))
updater.start_polling() TelegramClient.start_polling()
#app = Application.builder().token(TelegramToken).build() #app = Application.builder().token(TelegramToken).build()
#app.add_handler(MessageHandler(filters.TEXT | filters.COMMAND, TelegramHandler)) #app.add_handler(MessageHandler(filters.TEXT | filters.COMMAND, TelegramHandler))
#app.run_polling(allowed_updates=Update.ALL_TYPES) #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), 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.command = ParseCommand(data.text_plain)
data.user.settings = (GetUserSettings(data.user.id) or SafeNamespace()) data.user.settings = (GetUserSettings(data.user.id) or SafeNamespace())
linked = TelegramLinker(data) 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) CallEndpoint(command, EventContext(platform="telegram", event=update, manager=context), data)
Thread(target=handler).start() Thread(target=handler).start()
def TelegramSender(context:EventContext, data:OutputMessageData, destination): def TelegramSender(context:EventContext, data:OutputMessageData):
result = None result = None
if destination: if data.room_id:
result = context.manager.bot.send_message(destination, text=data.text_plain) result = context.manager.bot.send_message(data.room_id, text=data.text_plain)
else: else:
replyToId = (data.ReplyTo or context.event.message.message_id) replyToId = (data.ReplyTo or context.event.message.message_id)
if data.media: if data.media:
@ -103,5 +104,5 @@ def TelegramLinker(data:InputMessageData) -> SafeNamespace:
linked.message = f"https://t.me/c/{room_id}/{message_id}" linked.message = f"https://t.me/c/{room_id}/{message_id}"
return linked 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): if (data.user.id not in AdminIds) and (data.user.tag not in AdminIds):
return SendMessage(context, {"Text": choice(Locale.__('eval'))}) return SendMessage(context, {"Text": choice(Locale.__('eval'))})
destination = data.command.arguments["destination"] destination = data.command.arguments["destination"]
if not (destination and data.command.body): text = data.command.body
return SendMessage(context, {"Text": "Bad usage."}) if not (destination and text):
SendMessage(context, {"TextPlain": data.command.body}, destination) return SendMessage(context, OutputMessageData(text_plain="Bad usage."))
SendMessage(context, {"TextPlain": "Executed."}) SendMessage(context, OutputMessageData(text_plain=text, room_id=destination))
SendMessage(context, OutputMessageData(text_plain="Executed."))
RegisterModule(name="Broadcast", endpoints=[ RegisterModule(name="Broadcast", endpoints=[
SafeNamespace(names=["broadcast"], handler=cBroadcast, arguments={ 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: def cEmbedded(context:EventContext, data:InputMessageData) -> None:
if len(data.command.tokens) >= 2: if len(data.command.tokens) >= 2:
# Find links in command body # Find links in command body
Text = (data.text_markdown + ' ' + data.text_plain) text = (data.text_markdown + ' ' + data.text_plain)
elif data.quoted and data.quoted.text_auto: elif (quoted := data.quoted) and (quoted.text_auto or quoted.text_markdown or quoted.text_html):
# Find links in quoted message # 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: else:
# TODO Error message # TODO Error message
return return
pass pass
urls = URLExtract().find_urls(Text) urls = URLExtract().find_urls(text)
if len(urls) > 0: if len(urls) > 0:
proto = 'https://' proto = 'https://'
url = urls[0] url = urls[0]
@ -47,7 +47,7 @@ def cEmbedded(context:EventContext, data:InputMessageData) -> None:
elif urlDomain == "vm.tiktok.com": elif urlDomain == "vm.tiktok.com":
urlDomain = "vm.vxtiktok.com" urlDomain = "vm.vxtiktok.com"
url = (urlDomain + '/' + '/'.join(url.split('/')[1:])) url = (urlDomain + '/' + '/'.join(url.split('/')[1:]))
SendMessage(context, {"TextPlain": f"{{{proto}{url}}}"}) SendMessage(context, {"text_plain": f"{{{proto}{url}}}"})
# else TODO error message? # else TODO error message?
def cWeb(context:EventContext, data:InputMessageData) -> None: def cWeb(context:EventContext, data:InputMessageData) -> None:
@ -79,13 +79,13 @@ def cNews(context:EventContext, data:InputMessageData) -> None:
pass pass
def cTranslate(context:EventContext, data:InputMessageData) -> None: def cTranslate(context:EventContext, data:InputMessageData) -> None:
instances = ["lingva.ml", "lingva.lunar.icu"]
language_to = data.command.arguments["language_to"] language_to = data.command.arguments["language_to"]
text_input = (data.command.body or (data.quoted and data.quoted.text_plain)) text_input = (data.command.body or (data.quoted and data.quoted.text_plain))
if not (text_input and language_to): if not (text_input and language_to):
return SendMessage(context, {"TextPlain": f"Usage: /translate <to language> <text>"}) return SendMessage(context, {"TextPlain": f"Usage: /translate <to language> <text>"})
try: try:
# TODO: Use many different public Lingva instances in rotation to avoid overloading a specific one result = json.loads(HttpReq(f'https://{randchoice(instances)}/api/v1/auto/{language_to}/{urlparse.quote(text_input)}').read())
result = json.loads(HttpReq(f'https://lingva.ml/api/v1/auto/{language_to}/{urlparse.quote(text_input)}').read())
SendMessage(context, {"TextPlain": f"[{result['info']['detectedSource']} (auto) -> {language_to}]\n\n{result['translation']}"}) SendMessage(context, {"TextPlain": f"[{result['info']['detectedSource']} (auto) -> {language_to}]\n\n{result['translation']}"})
except Exception: except Exception:
raise raise

View File

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