Rewrite bot

This commit is contained in:
Patrick Neff 2020-08-05 23:13:23 +02:00
parent c60809b94a
commit cfe2cf16db
13 changed files with 482 additions and 317 deletions

View File

@ -6,3 +6,6 @@ indent_size = 4
charset = utf-8 charset = utf-8
trim_trailing_whitespace = false trim_trailing_whitespace = false
insert_final_newline = false insert_final_newline = false
[*.py]
insert_final_newline = true

2
.gitignore vendored
View File

@ -154,3 +154,5 @@ bot
# Local History for Visual Studio Code # Local History for Visual Studio Code
.history/ .history/
bot_old.py

View File

@ -8,9 +8,11 @@ flake8 = "*"
flake8-quotes = "*" flake8-quotes = "*"
flake8-annotations = "*" flake8-annotations = "*"
yapf = "*" yapf = "*"
isort = "*"
[packages] [packages]
matrix-bot = {editable = true, path = "."} matrix-bot = {editable = true, path = "."}
appdirs = "*"
[requires] [requires]
python_version = "3.8" python_version = "3.8"

18
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "a17f7aa5b096658663f9e578d693b9f69f01711f3776f8ede29ac1ff34175380" "sha256": "4e2921a6197811de728000532c94147d2fdcf4a20d76d1d7e6da6d39eebd4d91"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@ -41,6 +41,14 @@
"markers": "python_full_version >= '3.5.3'", "markers": "python_full_version >= '3.5.3'",
"version": "==3.6.2" "version": "==3.6.2"
}, },
"appdirs": {
"hashes": [
"sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41",
"sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"
],
"index": "pypi",
"version": "==1.4.4"
},
"async-timeout": { "async-timeout": {
"hashes": [ "hashes": [
"sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f",
@ -354,6 +362,14 @@
"index": "pypi", "index": "pypi",
"version": "==3.2.0" "version": "==3.2.0"
}, },
"isort": {
"hashes": [
"sha256:723de131d9ae9d2561844f0ee525ce33a396a11bcda57174f036ed5ab3d6a122",
"sha256:cdca22530d093ed16983ba52c41560fa0219d1b958e44fd2ae2995dcc7b785be"
],
"index": "pypi",
"version": "==5.3.0"
},
"mccabe": { "mccabe": {
"hashes": [ "hashes": [
"sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",

View File

@ -30,15 +30,10 @@ After that you can begin sending messages
cd MATRIX_BOT_DIR cd MATRIX_BOT_DIR
source venv/bin/activate source venv/bin/activate
matrix-bot message "Message Content" # To send to the default room matrix-bot send '!yourRoomId' "Message Content"
matrix-bot message -r '!yourRoomId' "Message Content" # To send to a specific room
Or start the bot in daemon mode. In this mode the configured room will be monitored for incoming messages and you can send messages from external scripts with `matrix-botctl` Or start the bot in daemon mode. In this mode the bot will listen for defined events (not implemented yet)
cd MATRIX_BOT_DIR cd MATRIX_BOT_DIR
source venv/bin/activate source venv/bin/activate
matrix-bot matrix-bot run
# Then you can send messages with
matrix-botctl message "Message Content"

View File

@ -1,124 +1,46 @@
from nio import (AsyncClient, AsyncClientConfig, LoginResponse,
KeyVerificationEvent, KeyVerificationStart,
KeyVerificationCancel, KeyVerificationKey, KeyVerificationMac,
ToDeviceError, LocalProtocolError, SyncResponse,
RoomMessageText)
import click
import traceback
import sys
import os
import json
import asyncio import asyncio
import markdown import json
import logging
import os
import signal
import sys
import traceback
from .config import (CONFIG_FILE, STORE_PATH, NEXT_BATCH_PATH, HOMESERVER_URL, import click
FIFO_PATH) from nio import (AsyncClient, AsyncClientConfig, InviteEvent,
KeyVerificationCancel, KeyVerificationEvent,
KeyVerificationKey, KeyVerificationMac, KeyVerificationStart,
LocalProtocolError, LoginResponse, MatrixRoom, RoomMessage,
RoomMessageText, SyncResponse, ToDeviceError)
from .config import Config
from .utils import setup_logger
# from .message import TextMessage
def write_details_to_disk(resp: LoginResponse, credentials: dict) -> None: class Bot(object):
"""Write the required login details to disk.
It will allow following logins to be made without password.
Arguments:
---------
resp : LoginResponse - successful client login response
credentials : dict - The credentials used to sign in
"""
# open the config file in write-mode
with open(CONFIG_FILE, 'w') as f:
# write the login details to disk
json.dump(
{
'homeserver': credentials[
'homeserver'], # e.g. "https://matrix.example.org"
'device_name':
credentials['device_name'], # e.g. 'matrix-bot'
'room_id':
credentials['room_id'], # e.g. '!yourRoomId:example.org'
'user_id': resp.user_id, # e.g. '@user:example.org'
'device_id':
resp.device_id, # device ID, 10 uppercase letters
'access_token': resp.access_token, # cryptogr. access token
},
f,
)
class Bot():
def __init__(self) -> None: def __init__(self) -> None:
self._loop = asyncio.get_event_loop() self.__logger = setup_logger(__name__)
self.logger.debug('Initializing Bot')
self.__loop = asyncio.get_running_loop()
self.__first_sync = True
for s in (signal.SIGINT, signal.SIGTERM):
self.__loop.add_signal_handler(s, self.__signal_handler)
@classmethod async def login(self) -> AsyncClient:
async def login(cls: any) -> any:
bot = cls()
await bot._login()
return bot
@property
def client(self) -> AsyncClient:
return self._client
@property
def loop(self) -> asyncio.BaseEventLoop:
return self._loop
@property
def client_config(self) -> AsyncClientConfig:
return self._client_config
@property
def config(self) -> dict:
return self._config
async def send_message_to_room(
self,
room_id: str,
content: dict,
message_type: str = 'm.room.message') -> None:
"""Login and wait for and perform emoji verify."""
# Set up event callbacks
await self._login()
if room_id is None:
room_id = self.config['room_id']
await self.client.sync(timeout=30000, full_state=True)
await self.client.room_send(
room_id=room_id,
message_type=message_type,
content=content,
ignore_unverified_devices=True,
)
await self.client.set_presence('offline')
await self.client.close()
async def send_text_to_room(self, room_id: str, message: str) -> None:
content = {
'msgtype': 'm.text',
'format': 'org.matrix.custom.html',
'formatted_body': markdown.markdown(message),
}
await self.send_message_to_room(room_id, content)
async def send_notice_to_room(self, room_id: str, message: str) -> None:
content = {
'msgtype': 'm.notice',
'format': 'org.matrix.custom.html',
'formatted_body': markdown.markdown(message),
}
await self.send_message_to_room(room_id, content)
async def _login(self) -> AsyncClient:
"""Login to the matrix homeserver defined in the config file. """Login to the matrix homeserver defined in the config file.
""" """
self._client_config = AsyncClientConfig( self.logger.debug('Starting login process')
self.__client_config = AsyncClientConfig(
max_limit_exceeded=0, max_limit_exceeded=0,
max_timeouts=0, max_timeouts=0,
store_sync_tokens=True, store_sync_tokens=True,
encryption_enabled=True, encryption_enabled=True,
) )
# If there are no previously-saved credentials, we'll use the password # If there are no previously-saved credentials, we'll use the password
if not os.path.exists(CONFIG_FILE): if not os.path.exists(Config.CONFIG_FILE):
self.logger.debug('Starting password verification process')
click.secho( click.secho(
'First time use. Did not find credential file. Asking ' 'First time use. Did not find credential file. Asking '
'for homeserver, user, and password to create ' 'for homeserver, user, and password to create '
@ -126,8 +48,8 @@ class Bot():
bold=True, bold=True,
) )
if not os.path.exists(STORE_PATH): if not os.path.exists(Config.STORE_PATH):
os.makedirs(STORE_PATH) os.makedirs(Config.STORE_PATH)
credentials = self._ask_credentials() credentials = self._ask_credentials()
@ -135,7 +57,7 @@ class Bot():
client = AsyncClient( client = AsyncClient(
credentials['homeserver'], credentials['homeserver'],
credentials['user_id'], credentials['user_id'],
store_path=STORE_PATH, store_path=Config.STORE_PATH,
config=self.client_config, config=self.client_config,
) )
@ -149,20 +71,16 @@ class Bot():
# check that we logged in succesfully # check that we logged in succesfully
if isinstance(resp, LoginResponse): if isinstance(resp, LoginResponse):
write_details_to_disk(resp, credentials) self.__write_details_to_disk(resp, credentials)
else: else:
click.secho( self.logger.debug(f'homeserver = {credentials["homeserver"]}; '
f'homeserver = {credentials["homeserver"]}; ' f' user = {credentials["user_id"]}')
f' user = {credentials["user_id"]}', self.logger.warn(f'Failed to log in: {resp}')
fg='red',
)
click.secho(f'Failed to log in: {resp}', fg='red')
sys.exit(1) sys.exit(1)
self._config = { self.__config = {
'user_id': credentials['user_id'], 'user_id': credentials['user_id'],
'homeserver': credentials['homeserver'], 'homeserver': credentials['homeserver'],
'room_id': credentials['room_id'],
'device_name': credentials['device_name'], 'device_name': credentials['device_name'],
'device_id': resp.device_id, 'device_id': resp.device_id,
'access_token': resp.access_token, 'access_token': resp.access_token,
@ -177,15 +95,16 @@ class Bot():
# Otherwise the config file exists, so we'll use the stored credentials # Otherwise the config file exists, so we'll use the stored credentials
else: else:
self.logger.debug('Reading credentials.json')
# open the file in read-only mode # open the file in read-only mode
with open(CONFIG_FILE, 'r') as f: with open(Config.CONFIG_FILE, 'r') as f:
self._config = json.load(f) self.__config = json.load(f)
# Initialize the matrix client based on credentials from file # Initialize the matrix client based on credentials from file
client = AsyncClient( client = AsyncClient(
self.config['homeserver'], self.config['homeserver'],
self.config['user_id'], self.config['user_id'],
device_id=self.config['device_id'], device_id=self.config['device_id'],
store_path=STORE_PATH, store_path=Config.STORE_PATH,
config=self.client_config, config=self.client_config,
) )
@ -194,36 +113,49 @@ class Bot():
device_id=self.config['device_id'], device_id=self.config['device_id'],
access_token=self.config['access_token'], access_token=self.config['access_token'],
) )
# click.secho('Logged in using stored credentials.', fg='green') self.logger.debug('Logged in using stored credentials.')
self._client = client self.__client = client
await self.client.set_presence('online')
return client return client
async def shutdown(self, sig: any) -> None: async def sync(self) -> None:
await self.client.close() self.logger.debug('Starting sync')
tasks = [ next_batch = self.__read_next_batch()
t for t in asyncio.all_tasks() if t is not asyncio.current_task()
]
for task in tasks:
task.cancel()
await task
await asyncio.gather(*tasks, return_exceptions=False) if self.client.should_upload_keys:
self.loop.stop() await self.client.keys_upload()
async def _verify(self) -> None: if self.client.should_query_keys:
await self.client.keys_query()
if self.client.should_claim_keys:
await self.client.keys_claim(self.get_users_for_key_claiming())
await self.client.sync(timeout=30000,
full_state=True,
since=next_batch)
async def sync_forever(self) -> None:
# next_batch = self.__read_next_batch()
await self.client.sync_forever(timeout=30000, full_state=True)
async def verify(self) -> None:
"""Login and wait for and perform emoji verify.""" """Login and wait for and perform emoji verify."""
# Set up event callbacks # Set up event callbacks
client = await self._login() client = await self.login()
client.add_to_device_callback(self._to_device_callback,
self.logger.debug('Adding callbacks')
client.add_to_device_callback(self.__to_device_callback,
(KeyVerificationEvent, )) (KeyVerificationEvent, ))
# Sync encryption keys with the server # Sync encryption keys with the server
# Required for participating in encrypted rooms # Required for participating in encrypted rooms
if self.client.should_upload_keys: if self.client.should_upload_keys:
await self.client.keys_upload() await self.client.keys_upload()
click.secho('\nStarting verification process...', bold=True) click.secho('\nStarting verification process...',
bold=True,
fg='green')
click.secho( click.secho(
'\nThis program is ready and waiting for the other ' '\nThis program is ready and waiting for the other '
'party to initiate an emoji verification with us by ' 'party to initiate an emoji verification with us by '
@ -231,56 +163,30 @@ class Bot():
'client.', 'client.',
fg='green', fg='green',
) )
await self.client.sync_forever(timeout=30000, full_state=True) await self.sync_forever()
def verify(self) -> None: async def run(self) -> None:
self._run_async(self._verify()) await self.login()
def run(self) -> None: self.client.add_response_callback(self.__sync_callback,
self._run_async(self._run()) (SyncResponse, ))
self.client.add_event_callback(self.__message_callback,
(RoomMessage, ))
self.client.add_event_callback(self.__invite_callback, (InviteEvent, ))
async def _run(self) -> None: await self.sync_forever()
fifo_name = FIFO_PATH
if os.path.exists(fifo_name): async def find_room_by_id(self, room_id: str) -> (MatrixRoom, None):
os.remove(fifo_name) rooms = self.client.rooms.keys()
if room_id in rooms:
os.mkfifo(fifo_name) return self.client.rooms[room_id]
pipe_fd = os.open(fifo_name, (os.O_RDONLY | os.O_NONBLOCK)) return None
client = await self._login()
# we read the previously-written token...
next_batch_name = NEXT_BATCH_PATH
if os.path.exists(next_batch_name):
with open(next_batch_name, 'r') as next_batch_token:
# ... and well async_client to use it
self.client.next_batch = next_batch_token.read()
client.add_response_callback(self._sync_callback, SyncResponse)
client.add_event_callback(self._room_text_message_callback,
RoomMessageText)
await asyncio.gather(
self.client.sync_forever(timeout=30000, full_state=True),
self._fifo_reader(pipe_fd),
)
def _run_async(self, future: asyncio.Future) -> None:
try:
self.loop.run_until_complete(future)
except Exception:
print(traceback.format_exc())
sys.exit(1)
except asyncio.exceptions.CancelledError:
sys.exit(0)
except KeyboardInterrupt:
sys.exit(0)
def _ask_credentials(self) -> dict: def _ask_credentials(self) -> dict:
"""Ask the user for credentials """Ask the user for credentials
""" """
try: try:
homeserver = HOMESERVER_URL homeserver = Config.HOMESERVER_URL
homeserver = click.prompt( homeserver = click.prompt(
click.style('Enter your homeserver URL', bold=True), click.style('Enter your homeserver URL', bold=True),
default=homeserver, default=homeserver,
@ -300,11 +206,6 @@ class Bot():
default=device_name, default=device_name,
) )
room_id = '!yourRoomId:gaja-group.com'
room_id = click.prompt(
click.style('Enter a default room ID to send to', bold=True),
default=room_id,
)
except click.exceptions.Abort: except click.exceptions.Abort:
sys.exit(0) sys.exit(0)
@ -312,10 +213,11 @@ class Bot():
'homeserver': homeserver, 'homeserver': homeserver,
'user_id': user_id, 'user_id': user_id,
'device_name': device_name, 'device_name': device_name,
'room_id': room_id,
} }
async def _to_device_callback(self, event): # noqa # Callbacks
async def __to_device_callback(self, event): # noqa
"""Handle events sent to device.""" """Handle events sent to device."""
try: try:
client = self.client client = self.client
@ -351,16 +253,15 @@ class Bot():
""" """
if 'emoji' not in event.short_authentication_string: if 'emoji' not in event.short_authentication_string:
click.secho( click.echo(
'Other device does not support emoji verification ' 'Other device does not support emoji verification '
f'{event.short_authentication_string}.', f'{event.short_authentication_string}.')
fg='red',
)
return return
resp = await client.accept_key_verification( resp = await client.accept_key_verification(
event.transaction_id) event.transaction_id)
if isinstance(resp, ToDeviceError): if isinstance(resp, ToDeviceError):
click.secho(f'accept_key_verification failed with {resp}', self.logger.warning(
f'accept_key_verification failed with {resp}',
fg='red') fg='red')
sas = client.key_verifications[event.transaction_id] sas = client.key_verifications[event.transaction_id]
@ -368,7 +269,8 @@ class Bot():
todevice_msg = sas.share_key() todevice_msg = sas.share_key()
resp = await client.to_device(todevice_msg) resp = await client.to_device(todevice_msg)
if isinstance(resp, ToDeviceError): if isinstance(resp, ToDeviceError):
click.secho(f'to_device failed with {resp}', fg='red') self.logger.warning(f'to_device failed with {resp}',
fg='red')
elif isinstance(event, KeyVerificationCancel): # anytime elif isinstance(event, KeyVerificationCancel): # anytime
""" at any time: receive KeyVerificationCancel """ at any time: receive KeyVerificationCancel
@ -388,11 +290,8 @@ class Bot():
# client.cancel_key_verification(tx_id, reject=False) # client.cancel_key_verification(tx_id, reject=False)
# here. The SAS flow is already cancelled. # here. The SAS flow is already cancelled.
# We only need to inform the user. # We only need to inform the user.
click.secho( click.echo('\nVerification has been cancelled by '
'\nVerification has been cancelled by ' f'{event.sender} for reason "{event.reason}".')
f'{event.sender} for reason "{event.reason}".',
fg='yellow',
)
elif isinstance(event, KeyVerificationKey): # second step elif isinstance(event, KeyVerificationKey): # second step
""" Second step is to receive KeyVerificationKey """ Second step is to receive KeyVerificationKey
@ -458,7 +357,8 @@ class Bot():
resp = await client.cancel_key_verification( resp = await client.cancel_key_verification(
event.transaction_id, reject=False) event.transaction_id, reject=False)
if isinstance(resp, ToDeviceError): if isinstance(resp, ToDeviceError):
print(f'cancel_key_verification failed with {resp}') self.logger.warn(
f'cancel_key_verification failed with {resp}')
elif isinstance(event, KeyVerificationMac): # third step elif isinstance(event, KeyVerificationMac): # third step
""" Third step is to receive KeyVerificationMac """ Third step is to receive KeyVerificationMac
@ -490,7 +390,7 @@ class Bot():
else: else:
resp = await client.to_device(todevice_msg) resp = await client.to_device(todevice_msg)
if isinstance(resp, ToDeviceError): if isinstance(resp, ToDeviceError):
print(f'to_device failed with {resp}') self.logger.warn(f'to_device failed with {resp}')
# print(f'sas.we_started_it = {sas.we_started_it}\n' # print(f'sas.we_started_it = {sas.we_started_it}\n'
# f'sas.sas_accepted = {sas.sas_accepted}\n' # f'sas.sas_accepted = {sas.sas_accepted}\n'
# f'sas.canceled = {sas.canceled}\n' # f'sas.canceled = {sas.canceled}\n'
@ -502,76 +402,102 @@ class Bot():
'to exit.', 'to exit.',
fg='green') fg='green')
else: else:
print(f'Received unexpected event type {type(event)}. ' self.logger.warn(
f'Received unexpected event type {type(event)}. '
f'Event is {event}. Event will be ignored.') f'Event is {event}. Event will be ignored.')
except BaseException: except BaseException:
print(traceback.format_exc()) self.logger.critical(traceback.format_exc())
def _sync_callback(self, event: any) -> None: async def shutdown(self) -> None:
with open(NEXT_BATCH_PATH, 'w') as next_batch_token: self.logger.info('Shutdown Bot')
for task in asyncio.Task.all_tasks():
task.cancel()
await self.client.close()
async def __sync_callback(self, event: any) -> None:
self.logger.debug('Client syncing and saving next batch token')
if self.__first_sync and len(self.client.invited_rooms) > 0:
for room in self.client.invited_rooms:
await self.client.join(room)
self.__first_sync = False
with open(Config.NEXT_BATCH_PATH, 'w') as next_batch_token:
next_batch_token.write(event.next_batch) next_batch_token.write(event.next_batch)
async def _room_text_message_callback(self, room: any, async def __invite_callback(self, source: MatrixRoom, sender: any) -> None:
message: any) -> None: await self.client.join(source.room_id)
if room.room_id == self.config['room_id']:
if message.body.startswith('!help'):
await self.send_notice_to_room(
room.room_id, """##### Usage
**!help** - displays this help message async def __text_message_callback(self, source: MatrixRoom,
""") message: RoomMessageText) -> None:
self.logger.debug('Text Message Recieved: %s %s: %s', source.room_id,
message.sender, message.body)
async def _fifo_reader(self, pipe_fd: str) -> None: async def __message_callback(self, source: MatrixRoom,
with os.fdopen(pipe_fd) as file: message: RoomMessage) -> None:
while True: print(message)
data = file.read() self.logger.debug('Message Recieved')
if len(data) > 0: if (isinstance(message, RoomMessageText)):
try: await self.__text_message_callback(source, message)
data = data.split('\0')
for d in data:
if (len(d) > 0):
cmd = json.loads(d)
room_id = self.config['room_id']
if cmd['type'] == 'message':
if 'room_id' in cmd.keys():
room_id = cmd['room_id']
await self.send_notice_to_room(
room_id, cmd['content'])
except json.JSONDecodeError as e:
print('JSON decode error:', e)
pass
await asyncio.sleep(1)
def __signal_handler(self) -> None:
self.loop.create_task(self.shutdown())
@click.group(invoke_without_command=True) # Files
@click.option('-v', '--verbose', count=True)
@click.pass_context
def cli(ctx: click.Context, verbose: int) -> None:
# click.secho('Matrix Bot\n', bold='true')
if ctx.invoked_subcommand is None:
bot = Bot()
bot.run()
def __write_details_to_disk(self, resp: LoginResponse,
credentials: dict) -> None:
"""Write the required login details to disk.
@cli.command('verify') It will allow following logins to be made without password.
def verify_command() -> None:
bot = Bot()
bot.verify()
Arguments:
---------
resp : LoginResponse - successful client login response
credentials : dict - The credentials used to sign in
@cli.command('message') """
@click.argument('message') # open the config file in write-mode
@click.option('-r', '--room', help='the room to send to') with open(Config.CONFIG_FILE, 'w') as f:
def send_command(message: str, room: str) -> None: # write the login details to disk
bot = Bot() json.dump(
bot._run_async(bot.send_text_to_room(room, message)) {
'credentials': {
'homeserver': credentials['homeserver'],
'device_name': credentials['device_name'],
'user_id': resp.user_id,
'device_id': resp.device_id,
'access_token': resp.access_token,
}
},
f,
)
def __read_next_batch(self) -> (str, None):
# we read the previously-written token...
next_batch_name = Config.NEXT_BATCH_PATH
if os.path.exists(next_batch_name):
with open(next_batch_name, 'r') as next_batch_token:
# ... and well async_client to use it
self.client.next_batch = next_batch_token.read()
return self.client.next_batch
@cli.command('run') # Properties
def run() -> None:
bot = Bot()
bot.run()
@property
def loop(self) -> asyncio.AbstractEventLoop:
return self.__loop
if __name__ == '__main__': @property
cli() def logger(self) -> logging.Logger:
return self.__logger
@property
def config(self) -> dict:
return self.__config
@property
def client_config(self) -> AsyncClientConfig:
return self.__client_config
@property
def client(self) -> AsyncClient:
return self.__client

View File

@ -1,35 +0,0 @@
import os
import sys
import click
import json
from .config import FIFO_PATH
def send_message(message: str) -> None:
fifo_name = FIFO_PATH
if not os.path.exists(fifo_name):
sys.exit(1)
# Starting two readers and one writer, but only one reader
# will be reading at the same time.
content = {'type': 'message', 'content': message}
with open(fifo_name, 'w') as file:
file.write(json.dumps(content))
file.write('\0')
@click.group()
def cli() -> None:
pass
@cli.command()
@click.argument('message', nargs=-1)
def message(message: list) -> None:
send_message(' '.join(message))
if __name__ == '__main__':
cli()

117
matrix_bot/cli.py Normal file
View File

@ -0,0 +1,117 @@
import logging
import sys
import click
from .bot import Bot
from .utils import run_async, setup_logger
from .config import Config
from .message import TextMessage, MarkdownMessage
from .exceptions import NoRoomException
logger = setup_logger(__name__)
def get_loglevel(verbosity: int) -> int:
level = logging.ERROR
if verbosity == 1:
level = logging.WARNING
elif verbosity == 2:
level = logging.INFO
elif verbosity >= 3:
level = logging.DEBUG
return level
async def send_message(room_id: str, message: str, markdown: bool) -> None:
logger.debug('Sending a message to %s', room_id)
bot = Bot()
client = await bot.login()
await bot.sync()
room = await bot.find_room_by_id(room_id)
if markdown:
message = MarkdownMessage(message)
else:
message = TextMessage(message)
try:
await message.send(client, room)
except NoRoomException:
click.echo(f'No Room with id {room_id} found')
finally:
await bot.shutdown()
async def main_run() -> None:
logger.debug('Starting')
bot = Bot()
await bot.run()
async def main_verify() -> None:
logger.debug('Starting')
bot = Bot()
await bot.verify()
@click.group(invoke_without_command=True)
@click.option('-v',
'--verbose',
count=True,
help='Set the loglevel (Can be used multiple times e.g. -vvv)')
@click.option('-b',
'--botname',
default='default',
help='The name of the bot to start')
@click.pass_context
def cli(ctx: click.Context,
verbose: bool,
botname: str,
help: str = 'matrix-bot - A Bot That listens in matrix chatrooms'
) -> None:
ctx.ensure_object(dict)
Config.init(botname, get_loglevel(verbose))
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
sys.exit(1)
@cli.command(help='Start the Bot')
@click.pass_context
@click.option('-v',
'--verbose',
count=True,
help='Set the loglevel (Can be used multiple times e.g. -vvv)')
def run(ctx: click.Context, verbose: int) -> None:
run_async(main_run())
@cli.command(help='Verify the bot with a matrix homeserver')
@click.pass_context
@click.option('-v',
'--verbose',
count=True,
help='Set the loglevel (Can be used multiple times e.g. -vvv)')
def verify(ctx: click.Context, verbose: int) -> None:
run_async(main_verify())
@cli.command(help='Send a message with the bot')
@click.pass_context
@click.argument('room_id')
@click.argument('message', nargs=-1, required=True)
@click.option('-m', '--markdown/--no-markdown', default=False)
@click.option('-v',
'--verbose',
count=True,
help='Set the loglevel (Can be used multiple times e.g. -vvv)')
def send(ctx: click.Context, room_id: str, message: list, markdown: bool,
verbose: int) -> None:
run_async(send_message(room_id, ' '.join(message), markdown))
if __name__ == '__main__':
cli(obj={})

View File

@ -1,15 +1,32 @@
import os import os
import appdirs
# The directory containing the store and credentials file
CONFIG_DIRECTORY = '.'
# file to store credentials in case you want to run program multiple times
# login credentials JSON file
CONFIG_FILE = os.path.join(CONFIG_DIRECTORY, 'credentials.json')
# directory to store persistent data for end-to-end encryption
STORE_PATH = os.path.join(CONFIG_DIRECTORY, 'store') # local directory
FIFO_PATH = os.path.join(CONFIG_DIRECTORY, 'fifo') # local directory
NEXT_BATCH_PATH = os.path.join(CONFIG_DIRECTORY,
'next_batch') # local directory
class Config:
APP_NAME = 'matrix-bot'
# Default Homeserver URL # Default Homeserver URL
HOMESERVER_URL = 'https://matrix.gaja-group.com' HOMESERVER_URL = 'https://matrix.gaja-group.com'
@classmethod
def init(cls, botname: str = 'default', loglevel: int = 40) -> None:
cls.LOGLEVEL = loglevel
cls.CONFIG_DIRECTORY = os.path.join(appdirs.user_config_dir(),
cls.APP_NAME, botname)
cls.DATA_DIRECTORY = os.path.join(appdirs.user_data_dir(),
cls.APP_NAME, botname)
if not os.path.exists(cls.CONFIG_DIRECTORY):
os.makedirs(cls.CONFIG_DIRECTORY)
if not os.path.exists(cls.DATA_DIRECTORY):
os.makedirs(cls.DATA_DIRECTORY)
cls.CONFIG_FILE = os.path.join(cls.CONFIG_DIRECTORY, 'config.json')
# directory to store persistent data for end-to-end encryption
cls.STORE_PATH = os.path.join(cls.DATA_DIRECTORY,
'store') # local directory
cls.NEXT_BATCH_PATH = os.path.join(cls.DATA_DIRECTORY,
'next_batch') # local directory
print(cls.CONFIG_DIRECTORY)
Config.init()

2
matrix_bot/exceptions.py Normal file
View File

@ -0,0 +1,2 @@
class NoRoomException(BaseException):
pass

89
matrix_bot/message.py Normal file
View File

@ -0,0 +1,89 @@
import re
import logging
from nio import AsyncClient, MatrixRoom
from markdown import markdown
from .utils import setup_logger
from .exceptions import NoRoomException
class Message(object):
def __init__(self,
_type: str = 'm.room.message',
loglevel: int = 10) -> None:
self.__type = _type
self.__ignore_unverified_devices = True
self.__logger = setup_logger(__name__)
async def send(self, client: AsyncClient,
room: (MatrixRoom, None)) -> None:
if room:
self.logger.debug('Sending message: %s', self.content)
await client.room_send(
room.room_id,
self.type,
self.content,
ignore_unverified_devices=self.ignore_unverified_devices)
else:
raise NoRoomException()
@property
def type(self) -> str:
return self.__type
@property
def ignore_unverified_devices(self) -> bool:
return self.__ignore_unverified_devices
@property
def logger(self) -> logging.Logger:
return self.__logger
@property
def content(self) -> str:
raise NotImplementedError('content must be implemented')
class TextMessage(Message):
def __init__(self,
body: str,
msgtype: str = 'm.notice',
_type: str = 'm.room.message') -> None:
super().__init__(_type)
self.body = body
self.msgtype = msgtype
@property
def content(self) -> dict:
content = {'msgtype': self.msgtype, 'body': self.body}
return content
class MarkdownMessage(Message):
clean_regexp = re.compile('<.*?>')
def __init__(self,
body: str,
msgtype: str = 'm.notice',
_type: str = 'm.room.message') -> None:
super().__init__(_type)
self.formatted_body = markdown(body)
self.format = 'org.matrix.custom.html'
self.body = self.__clean_html(self.formatted_body)
self.msgtype = msgtype
def __clean_html(self, raw_html: str) -> str:
cleantext = re.sub(self.clean_regexp, '', raw_html)
return cleantext
@property
def content(self) -> dict:
content = {
'msgtype': self.msgtype,
'body': self.body,
'formatted_body': self.formatted_body,
'format': self.format
}
return content

30
matrix_bot/utils.py Normal file
View File

@ -0,0 +1,30 @@
import asyncio
import logging
from .config import Config
def run_async(future: asyncio.Future) -> None:
global logger
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(future)
except asyncio.exceptions.CancelledError:
loop.run_until_complete(asyncio.sleep(0.25))
finally:
loop.stop()
def setup_logger(name: str = __name__) -> logging.Logger:
logger = logging.getLogger(name)
logger.setLevel(Config.LOGLEVEL)
ch = logging.StreamHandler()
ch.setLevel(Config.LOGLEVEL)
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s')
ch.setFormatter(formatter)
logger.addHandler(ch)
return logger

View File

@ -7,7 +7,8 @@ setup(
install_requires=['Click', 'matrix-nio[e2e]', 'markdown'], install_requires=['Click', 'matrix-nio[e2e]', 'markdown'],
entry_points=""" entry_points="""
[console_scripts] [console_scripts]
matrix-bot=matrix_bot.bot:cli matrix-bot=matrix_bot.cli:cli
matrix-botctl=matrix_bot.botctl:cli matrix-send=matrix_bot.cli:send
matrix-verify=matrix_bot.cli:verify
""", """,
) )