diff --git a/pygomx/pyproject.toml b/pygomx/pyproject.toml index b09b354..662608a 100644 --- a/pygomx/pyproject.toml +++ b/pygomx/pyproject.toml @@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta" [project] name = "pygomx" -version = "0.0.1" +version = "0.0.2" requires-python = ">=3.10" description = "python pindings for a golang matrix library" license = "AGPL-3.0-only" readme = "README.txt" -dependencies = ["cffi>=2.0.0"] +dependencies = ["asyncio", "cffi>=2.0.0"] [project.urls] homepage = "https://codeberg.org/saces/pygomx" diff --git a/smal/src/smal/pygomx.py b/pygomx/src/pygomx/client.py similarity index 73% rename from smal/src/smal/pygomx.py rename to pygomx/src/pygomx/client.py index 2c0ebc7..aefc67f 100644 --- a/smal/src/smal/pygomx.py +++ b/pygomx/src/pygomx/client.py @@ -1,29 +1,19 @@ # -*- coding: utf-8 -*- -import logging -from _pygomx import lib, ffi + + import json -from .errors import APIError +import logging + +from _pygomx import ffi, lib + +from .errors import APIError, CheckApiError, CheckApiResult +import asyncio +import threading logger = logging.getLogger(__name__) -def checkApiError(cstr): - result = ffi.string(cstr).decode("utf-8") - lib.FreeCString(cstr) - - if result.startswith("ERR:"): - raise APIError(result) - - if result == "SUCCESS.": - return - - logger.debug(result) - - result_dict = json.loads(result) - return result_dict - - -class _MXClient: +class _AsyncClient: """ core binding """ @@ -71,48 +61,59 @@ class _MXClient: self.UserID = result_dict["userid"] self.DeviceID = result_dict["deviceid"] - def _sync(self): + async def _sync(self): r = lib.apiv0_startclient(self.client_id) - checkApiError(r) + CheckApiError(r) def _stopsync(self): r = lib.apiv0_stopclient(self.client_id) - checkApiError(r) + CheckApiError(r) - def _sendmessage(self, data_dict): + async def _sendmessage(self, data_dict): data = json.dumps(data_dict).encode(encoding="utf-8") r = lib.apiv0_sendmessage(self.client_id, data) - result = checkApiError(r) - return result + return CheckApiResult(r) def leaveroom(self, roomid): r = lib.apiv0_leaveroom(self.client_id, roomid.encode(encoding="utf-8")) - checkApiError(r) + CheckApiError(r) - def joinedrooms(self): + async def joinedrooms(self): r = lib.apiv0_joinedrooms(self.client_id) - return checkApiError(r) + return CheckApiResult(r) def _createroom(self, data_dict): data = json.dumps(data_dict).encode(encoding="utf-8") r = lib.apiv0_createroom(self.client_id, data) - return checkApiError(r) + return CheckApiError(r) def process_event(self, evt): if hasattr(self, "on_event") and callable(self.on_event): - self.on_event(evt) + loop = asyncio.new_event_loop() + threading.Thread( + target=loop.run_forever, name="Async Runner", daemon=True + ).start() + asyncio.run_coroutine_threadsafe(self.on_event(evt), loop).result() else: logger.warn(f"got event but on_event not declared: {evt}") def process_message(self, msg): if hasattr(self, "on_message") and callable(self.on_message): - self.on_message(msg) + loop = asyncio.new_event_loop() + threading.Thread( + target=loop.run_forever, name="Async Runner", daemon=True + ).start() + asyncio.run_coroutine_threadsafe(self.on_message(msg), loop).result() else: logger.warn(f"got message but on_message not declared: {msg}") def process_sys(self, ntf): if hasattr(self, "on_sys") and callable(self.on_sys): - self.on_sys(ntf) + loop = asyncio.new_event_loop() + threading.Thread( + target=loop.run_forever, name="Async Runner", daemon=True + ).start() + asyncio.run_coroutine_threadsafe(self.on_sys(ntf), loop).result() else: logger.warn(f"got systen notification but on_sys not declared: {ntf}") diff --git a/pygomx/src/pygomx/errors.py b/pygomx/src/pygomx/errors.py index 38ca664..f8f1a99 100644 --- a/pygomx/src/pygomx/errors.py +++ b/pygomx/src/pygomx/errors.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +from _pygomx import ffi, lib +import json class APIError(Exception): @@ -11,3 +13,29 @@ class APIError(Exception): def __init__(self, message): self.message = message[4:] super().__init__(self.message) + + +def apiResult(cstr): + result = ffi.string(cstr).decode("utf-8") + lib.FreeCString(cstr) + return result + + +def CheckApiError(cstr): + result = apiResult(cstr) + + if result.startswith("ERR:"): + raise APIError(result) + + +def CheckApiResult(cstr): + result = apiResult(cstr) + + if result.startswith("ERR:"): + raise APIError(result) + + if result == "SUCCESS.": + return None + + result_dict = json.loads(result) + return result_dict diff --git a/pygomx/src/pygomx/py.typed b/pygomx/src/pygomx/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pygomx/src/pygomx/pygomx.py b/pygomx/src/pygomx/simple/client.py similarity index 86% rename from pygomx/src/pygomx/pygomx.py rename to pygomx/src/pygomx/simple/client.py index aa34b5f..11977f2 100644 --- a/pygomx/src/pygomx/pygomx.py +++ b/pygomx/src/pygomx/simple/client.py @@ -4,30 +4,14 @@ import logging from _pygomx import ffi, lib -from .errors import APIError +from .errors import APIError, CheckApiError logger = logging.getLogger(__name__) -def checkApiError(cstr): - result = ffi.string(cstr).decode("utf-8") - lib.FreeCString(cstr) - - if result.startswith("ERR:"): - raise APIError(result) - - if result == "SUCCESS.": - return - - logger.debug(result) - - result_dict = json.loads(result) - return result_dict - - -class _MXClient: +class _SimpleClient: """ - core binding + synchronous core binding """ def __init__(self): @@ -75,30 +59,30 @@ class _MXClient: def _sync(self): r = lib.apiv0_startclient(self.client_id) - checkApiError(r) + CheckApiError(r) def _stopsync(self): r = lib.apiv0_stopclient(self.client_id) - checkApiError(r) + CheckApiError(r) def _sendmessage(self, data_dict): data = json.dumps(data_dict).encode(encoding="utf-8") r = lib.apiv0_sendmessage(self.client_id, data) - result = checkApiError(r) + result = CheckApiError(r) return result def leaveroom(self, roomid): r = lib.apiv0_leaveroom(self.client_id, roomid.encode(encoding="utf-8")) - checkApiError(r) + CheckApiError(r) def joinedrooms(self): r = lib.apiv0_joinedrooms(self.client_id) - return checkApiError(r) + return CheckApiError(r) def _createroom(self, data_dict): data = json.dumps(data_dict).encode(encoding="utf-8") r = lib.apiv0_createroom(self.client_id, data) - return checkApiError(r) + return CheckApiError(r) def process_event(self, evt): if hasattr(self, "on_event") and callable(self.on_event): diff --git a/smal/pyproject.toml b/smal/pyproject.toml index ebcb97b..d41747e 100644 --- a/smal/pyproject.toml +++ b/smal/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "smal" version = "0.0.1" -requires-python = ">=3.11" +requires-python = ">=3.10" description = "smal - simple matrix application library" authors = [{ name = "saces" }] license = "AGPL-3.0-only" @@ -16,8 +16,10 @@ classifiers = [ ] dependencies = [ + "asyncio", "cffi>=2.0.0", "click", + "pygomx>=0.0.2" ] [tool.setuptools.package-dir] @@ -38,3 +40,4 @@ mxclearaccount = "pymxutils.mxutils:clearaccount" mxserverinfo = "pymxutils.mxutils:serverinfo" smalsetup = "smal.smalsetup:smalsetup" demobot = "demobot:main" +simplebot = "demobot.simple:main" diff --git a/smal/src/demobot/demobot.py b/smal/src/demobot/demobot.py index a6be4ad..b1afbc1 100644 --- a/smal/src/demobot/demobot.py +++ b/smal/src/demobot/demobot.py @@ -16,13 +16,13 @@ DEFAULT_PREFIX = "!" class DemoBot(SMALBot): - def on_sys(self, ntf): + async def on_sys(self, ntf): print("Got a system notification: ", ntf) - def on_event(self, evt): + async def on_event(self, evt): print("Got an event: ", evt) - def on_message(self, msg): + async def on_message(self, msg): if msg["type"] != "m.room.message": # not a room message @@ -46,7 +46,7 @@ class DemoBot(SMALBot): if msg["content"]["body"] == "!leave": logger.info(f"leaving room {msg['roomid']}") - self.leaveroom(msg["roomid"]) + await self.leaveroom(msg["roomid"]) return if msg["content"]["body"].startswith("!echo"): @@ -57,32 +57,30 @@ class DemoBot(SMALBot): txt = "Empty text? Are you kidding me?" if msg["is_direct"]: - self.sendmessage(msg["roomid"], txt) + await self.sendmessage(msg["roomid"], txt) else: - self.sendmessagereply(msg["roomid"], msg["id"], msg["sender"], txt) + await self.sendmessagereply( + msg["roomid"], msg["id"], msg["sender"], txt + ) return logger.info(f"ignored a message: {msg}") - def listjoinedrooms(self): - roomlist = self.joinedrooms() + async def on_startup_run(self): + roomlist = await self.joinedrooms() for room in roomlist: if room["is_direct"]: txt = "Hey, I'm back for secret talk :)" else: txt = "I'm back online." - self.sendnotice(room["roomid"], txt) + await self.sendnotice(room["roomid"], txt) def main(): # create and initialize the bot bot = DemoBot(DEFAULT_PREFIX) - # the bot's matrix client is ready to use now - # request the list of joined rooms - bot.listjoinedrooms() - - # start syncing forever (listen for incommmig messages/events) + # start the asyncio event loop and sync forever (listen for incommmig messages/events) bot.run() diff --git a/smal/src/demobot/simple/__init__.py b/smal/src/demobot/simple/__init__.py new file mode 100644 index 0000000..8cf9cb3 --- /dev/null +++ b/smal/src/demobot/simple/__init__.py @@ -0,0 +1 @@ +from .demobot import main diff --git a/smal/src/demobot/simple/__main__.py b/smal/src/demobot/simple/__main__.py new file mode 100644 index 0000000..40ba55d --- /dev/null +++ b/smal/src/demobot/simple/__main__.py @@ -0,0 +1,4 @@ +import sys +from .demobot import main + +sys.exit(main()) diff --git a/smal/src/demobot/simple/demobot.py b/smal/src/demobot/simple/demobot.py new file mode 100644 index 0000000..069cf93 --- /dev/null +++ b/smal/src/demobot/simple/demobot.py @@ -0,0 +1,90 @@ +import logging +from smal.simple.bot import SMALBot + +# setup logging, we want timestamps +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s.%(msecs)03d %(levelname)s %(name)s - %(funcName)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) +logger = logging.getLogger(__name__) +logger.setLevel(level=logging.INFO) + + +DEFAULT_PREFIX = "!" + + +class SimpleDemoBot(SMALBot): + + def on_sys(self, ntf): + print("Got a system notification: ", ntf) + + def on_event(self, evt): + print("Got an event: ", evt) + + def on_message(self, msg): + + if msg["type"] != "m.room.message": + # not a room message + logger.error(f"not a room message: {msg}") + return + + if msg["sender"] == self.UserID: + # ignore own messages + logger.info(f"ignore own message: {msg}") + return + + if "msgtype" in msg["content"].keys() and msg["content"]["msgtype"] != "m.text": + # only react to messages, not emotes + logger.debug(f"ignore unknown message type: {msg}") + return + + if msg["content"]["body"] == "!stop": + logger.info("stopping the bot") + self.stop() + return + + if msg["content"]["body"] == "!leave": + logger.info(f"leaving room {msg['roomid']}") + self.leaveroom(msg["roomid"]) + return + + if msg["content"]["body"].startswith("!echo"): + + txt = msg["content"]["body"][5:].strip() + + if txt == "": + txt = "Empty text? Are you kidding me?" + + if msg["is_direct"]: + self.sendmessage(msg["roomid"], txt) + else: + self.sendmessagereply(msg["roomid"], msg["id"], msg["sender"], txt) + return + + logger.info(f"ignored a message: {msg}") + + def listjoinedrooms(self): + roomlist = self.joinedrooms() + for room in roomlist: + if room["is_direct"]: + txt = "Hey, I'm back for secret talk :)" + else: + txt = "I'm back online." + self.sendnotice(room["roomid"], txt) + + +def main(): + # create and initialize the bot + bot = SimpleDemoBot(DEFAULT_PREFIX) + + # the bot's matrix client is ready to use now + # request the list of joined rooms + bot.listjoinedrooms() + + # start syncing forever (listen for incommmig messages/events) + bot.run() + + +if __name__ == "__main__": + main() diff --git a/smal/src/smal/app.py b/smal/src/smal/app.py index 2144c91..7a4813b 100644 --- a/smal/src/smal/app.py +++ b/smal/src/smal/app.py @@ -1,8 +1,9 @@ # -*- coding: utf-8 -*- import logging +import asyncio -from .pygomx import _MXClient +from pygomx.client import _AsyncClient logger = logging.getLogger(__name__) @@ -11,8 +12,35 @@ logger = logging.getLogger(__name__) """ -class SMALApp(_MXClient): - """ """ +class SMALApp(_AsyncClient): + """ + + implement 'async def self.on_startup()' + async_client is logged in & ready. + time to setup extra things & hooks not covered by this class + sync_loop will not start til we return + + implement 'async def self.on_startup_run()' + async_client is logged in & ready. + this will not wait for return + do your even long running startup code here + + """ def __init__(self): super().__init__() + + def run(self): + asyncio.run(self.main_loop()) + + async def main_loop(self): + if hasattr(self, "on_startup") and callable(self.on_startup): + await self.on_startup() + + if hasattr(self, "on_startup_run") and callable(self.on_startup_run): + await asyncio.ensure_future(self.on_startup_run()) + + await self._sync() + + def stop(self): + self._stopsync() diff --git a/smal/src/smal/bot.py b/smal/src/smal/bot.py index c4f085b..1921f4e 100644 --- a/smal/src/smal/bot.py +++ b/smal/src/smal/bot.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- import logging -import sys from .app import SMALApp @@ -18,22 +17,16 @@ class SMALBot(SMALApp): super().__init__() self._sigil = sigil - def run(self): - self._sync() - - def stop(self): - self._stopsync() - - def sendmessage(self, roomid, text): + async def sendmessage(self, roomid, text): data = {} data["roomid"] = roomid data["content"] = {} data["content"]["body"] = text data["content"]["msgtype"] = "m.text" - self._sendmessage(data) + await self._sendmessage(data) - def sendmessagereply(self, roomid, msgid, mxid, text): + async def sendmessagereply(self, roomid, msgid, mxid, text): data = {} data["roomid"] = roomid data["content"] = {} @@ -47,13 +40,13 @@ class SMALBot(SMALApp): data["content"]["m.relates_to"]["m.in_reply_to"] = {} data["content"]["m.relates_to"]["m.in_reply_to"]["event_id"] = msgid - self._sendmessage(data) + await self._sendmessage(data) - def sendnotice(self, roomid, text): + async def sendnotice(self, roomid, text): data = {} data["roomid"] = roomid data["content"] = {} data["content"]["body"] = text data["content"]["msgtype"] = "m.notice" - self._sendmessage(data) + await self._sendmessage(data) diff --git a/smal/src/smal/py.typed b/smal/src/smal/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/smal/src/smal/simple/app.py b/smal/src/smal/simple/app.py new file mode 100644 index 0000000..abfb8a1 --- /dev/null +++ b/smal/src/smal/simple/app.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- + +import logging + +from pygomx.simple import _SimpleClient + +logger = logging.getLogger(__name__) + +""" + +""" + + +class SMALApp(_SimpleClient): + """ """ + + def __init__(self): + super().__init__() diff --git a/smal/src/smal/simple/bot.py b/smal/src/smal/simple/bot.py new file mode 100644 index 0000000..85e2db4 --- /dev/null +++ b/smal/src/smal/simple/bot.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +import logging + +from .app import SMALApp + +logger = logging.getLogger(__name__) + +""" + +""" + + +class SMALBot(SMALApp): + """ """ + + def __init__(self, sigil): + super().__init__() + self._sigil = sigil + + def run(self): + self._sync() + + def stop(self): + self._stopsync() + + def sendmessage(self, roomid, text): + data = {} + data["roomid"] = roomid + data["content"] = {} + data["content"]["body"] = text + data["content"]["msgtype"] = "m.text" + + self._sendmessage(data) + + def sendmessagereply(self, roomid, msgid, mxid, text): + data = {} + data["roomid"] = roomid + data["content"] = {} + data["content"]["body"] = text + data["content"]["msgtype"] = "m.text" + data["content"]["m.mentions"] = {} + data["content"]["m.mentions"]["user_ids"] = [ + mxid, + ] + data["content"]["m.relates_to"] = {} + data["content"]["m.relates_to"]["m.in_reply_to"] = {} + data["content"]["m.relates_to"]["m.in_reply_to"]["event_id"] = msgid + + self._sendmessage(data) + + def sendnotice(self, roomid, text): + data = {} + data["roomid"] = roomid + data["content"] = {} + data["content"]["body"] = text + data["content"]["msgtype"] = "m.notice" + + self._sendmessage(data)