Compare commits

...

2 Commits

Author SHA1 Message Date
Patrick Neff c60809b94a Update README 2020-08-04 12:08:16 +02:00
Patrick Neff 01e4e9f18b Add control script 2020-08-04 12:03:56 +02:00
8 changed files with 253 additions and 204 deletions

3
.gitignore vendored
View File

@ -140,6 +140,9 @@ cython_debug/
# Project specific
credentials.json
store/*
next_batch
fifo
bot
# VSCode
.vscode/*

View File

@ -7,7 +7,6 @@ verify_ssl = true
flake8 = "*"
flake8-quotes = "*"
flake8-annotations = "*"
black = "*"
yapf = "*"
[packages]

99
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "b572da4f2fb6963d3f61a7a2234f625fd8c09274e429df366367fb4b359510a0"
"sha256": "a17f7aa5b096658663f9e578d693b9f69f01711f3776f8ede29ac1ff34175380"
},
"pipfile-spec": 6,
"requires": {
@ -331,37 +331,6 @@
}
},
"develop": {
"appdirs": {
"hashes": [
"sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41",
"sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"
],
"version": "==1.4.4"
},
"attrs": {
"hashes": [
"sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
"sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==19.3.0"
},
"black": {
"hashes": [
"sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b",
"sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"
],
"index": "pypi",
"version": "==19.10b0"
},
"click": {
"hashes": [
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
"sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==7.1.2"
},
"flake8": {
"hashes": [
"sha256:15e351d19611c887e482fb960eae4d44845013cc142d42896e9862f775d8cf5c",
@ -392,13 +361,6 @@
],
"version": "==0.6.1"
},
"pathspec": {
"hashes": [
"sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0",
"sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"
],
"version": "==0.8.0"
},
"pycodestyle": {
"hashes": [
"sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367",
@ -415,65 +377,6 @@
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.2.0"
},
"regex": {
"hashes": [
"sha256:0dc64ee3f33cd7899f79a8d788abfbec168410be356ed9bd30bbd3f0a23a7204",
"sha256:1269fef3167bb52631ad4fa7dd27bf635d5a0790b8e6222065d42e91bede4162",
"sha256:14a53646369157baa0499513f96091eb70382eb50b2c82393d17d7ec81b7b85f",
"sha256:3a3af27a8d23143c49a3420efe5b3f8cf1a48c6fc8bc6856b03f638abc1833bb",
"sha256:46bac5ca10fb748d6c55843a931855e2727a7a22584f302dd9bb1506e69f83f6",
"sha256:4c037fd14c5f4e308b8370b447b469ca10e69427966527edcab07f52d88388f7",
"sha256:51178c738d559a2d1071ce0b0f56e57eb315bcf8f7d4cf127674b533e3101f88",
"sha256:5ea81ea3dbd6767873c611687141ec7b06ed8bab43f68fad5b7be184a920dc99",
"sha256:6961548bba529cac7c07af2fd4d527c5b91bb8fe18995fed6044ac22b3d14644",
"sha256:75aaa27aa521a182824d89e5ab0a1d16ca207318a6b65042b046053cfc8ed07a",
"sha256:7a2dd66d2d4df34fa82c9dc85657c5e019b87932019947faece7983f2089a840",
"sha256:8a51f2c6d1f884e98846a0a9021ff6861bdb98457879f412fdc2b42d14494067",
"sha256:9c568495e35599625f7b999774e29e8d6b01a6fb684d77dee1f56d41b11b40cd",
"sha256:9eddaafb3c48e0900690c1727fba226c4804b8e6127ea409689c3bb492d06de4",
"sha256:bbb332d45b32df41200380fff14712cb6093b61bd142272a10b16778c418e98e",
"sha256:bc3d98f621898b4a9bc7fecc00513eec8f40b5b83913d74ccb445f037d58cd89",
"sha256:c11d6033115dc4887c456565303f540c44197f4fc1a2bfb192224a301534888e",
"sha256:c50a724d136ec10d920661f1442e4a8b010a4fe5aebd65e0c2241ea41dbe93dc",
"sha256:d0a5095d52b90ff38592bbdc2644f17c6d495762edf47d876049cfd2968fbccf",
"sha256:d6cff2276e502b86a25fd10c2a96973fdb45c7a977dca2138d661417f3728341",
"sha256:e46d13f38cfcbb79bfdb2964b0fe12561fe633caf964a77a5f8d4e45fe5d2ef7"
],
"version": "==2020.7.14"
},
"toml": {
"hashes": [
"sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f",
"sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"
],
"version": "==0.10.1"
},
"typed-ast": {
"hashes": [
"sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",
"sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919",
"sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa",
"sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652",
"sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75",
"sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01",
"sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d",
"sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1",
"sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907",
"sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c",
"sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3",
"sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b",
"sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614",
"sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb",
"sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b",
"sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41",
"sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6",
"sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34",
"sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe",
"sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4",
"sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"
],
"version": "==1.4.1"
},
"yapf": {
"hashes": [
"sha256:3000abee4c28daebad55da6c85f3cd07b8062ce48e2e9943c8da1b9667d48427",

View File

@ -6,8 +6,8 @@ Requires libolm to be installed. in Debian this is done with `apt install libolm
## Requiements
- Python 3.8
- libolm
- Python 3.8
- libolm
## Intallation
@ -32,3 +32,13 @@ After that you can begin sending messages
source venv/bin/activate
matrix-bot message "Message Content" # To send to the default room
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`
cd MATRIX_BOT_DIR
source venv/bin/activate
matrix-bot
# Then you can send messages with
matrix-botctl message "Message Content"

View File

@ -1,33 +1,18 @@
from nio import (
AsyncClient,
AsyncClientConfig,
LoginResponse,
KeyVerificationEvent,
KeyVerificationStart,
KeyVerificationCancel,
KeyVerificationKey,
KeyVerificationMac,
ToDeviceError,
LocalProtocolError,
)
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 signal
import markdown
# 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
# Default Homeserver URL
HOMESERVER_URL = 'https://matrix.gaja-group.com'
from .config import (CONFIG_FILE, STORE_PATH, NEXT_BATCH_PATH, HOMESERVER_URL,
FIFO_PATH)
def write_details_to_disk(resp: LoginResponse, credentials: dict) -> None:
@ -61,16 +46,12 @@ def write_details_to_disk(resp: LoginResponse, credentials: dict) -> None:
)
class Bot:
class Bot():
def __init__(self) -> None:
self._loop = asyncio.get_event_loop()
signals = (signal.SIGHUP, signal.SIGTERM, signal.SIGINT)
for sig in signals:
self._loop.add_signal_handler(
sig, lambda sig=sig: asyncio.create_task(self.shutdown(sig)))
@classmethod
async def login(cls: any) -> object:
async def login(cls: any) -> any:
bot = cls()
await bot._login()
return bot
@ -91,24 +72,41 @@ class Bot:
def config(self) -> dict:
return self._config
def send_text_message_to_room(self, room_id: str, message: str) -> None:
async def send_message(room_id: str, message: str) -> None:
"""Login and wait for and perform emoji verify."""
# Set up event callbacks
content = {'msgtype': 'm.text', 'body': message}
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='m.room.message',
content=content,
ignore_unverified_devices=True,
)
await self.client.close()
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()
self.loop.run_until_complete(send_message(room_id, message))
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.
@ -131,7 +129,7 @@ class Bot:
if not os.path.exists(STORE_PATH):
os.makedirs(STORE_PATH)
credentials = self.ask_credentials()
credentials = self._ask_credentials()
# Initialize the matrix client
client = AsyncClient(
@ -165,7 +163,7 @@ class Bot:
'user_id': credentials['user_id'],
'homeserver': credentials['homeserver'],
'room_id': credentials['room_id'],
'device_name': resp['device_name'],
'device_name': credentials['device_name'],
'device_id': resp.device_id,
'access_token': resp.access_token,
}
@ -199,6 +197,7 @@ class Bot:
# click.secho('Logged in using stored credentials.', fg='green')
self._client = client
await self.client.set_presence('online')
return client
@ -214,57 +213,100 @@ class Bot:
await asyncio.gather(*tasks, return_exceptions=False)
self.loop.stop()
def verify(self) -> None:
async def verify() -> None:
"""Login and wait for and perform emoji verify."""
# Set up event callbacks
client = await self._login()
client.add_to_device_callback(self.to_device_callback,
(KeyVerificationEvent, ))
# Sync encryption keys with the server
# Required for participating in encrypted rooms
if self.client.should_upload_keys:
await self.client.keys_upload()
click.secho('\nStarting verification process...', bold=True)
click.secho(
'\nThis program is ready and waiting for the other '
'party to initiate an emoji verification with us by '
'selecting "Verify by Emoji" in their Matrix '
'client.',
fg='green',
)
await self.client.sync_forever(timeout=30000, full_state=True)
async def _verify(self) -> None:
"""Login and wait for and perform emoji verify."""
# Set up event callbacks
client = await self._login()
client.add_to_device_callback(self._to_device_callback,
(KeyVerificationEvent, ))
# Sync encryption keys with the server
# Required for participating in encrypted rooms
if self.client.should_upload_keys:
await self.client.keys_upload()
click.secho('\nStarting verification process...', bold=True)
click.secho(
'\nThis program is ready and waiting for the other '
'party to initiate an emoji verification with us by '
'selecting "Verify by Emoji" in their Matrix '
'client.',
fg='green',
)
await self.client.sync_forever(timeout=30000, full_state=True)
self.loop.run_until_complete(verify())
def verify(self) -> None:
self._run_async(self._verify())
def run(self) -> None:
self._run_async(self._run())
async def _run(self) -> None:
fifo_name = FIFO_PATH
if os.path.exists(fifo_name):
os.remove(fifo_name)
os.mkfifo(fifo_name)
pipe_fd = os.open(fifo_name, (os.O_RDONLY | os.O_NONBLOCK))
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:
"""Ask the user for credentials
"""
homeserver = HOMESERVER_URL
homeserver = click.prompt(
click.style('Enter your homeserver URL', bold=True),
default=homeserver,
)
try:
homeserver = HOMESERVER_URL
homeserver = click.prompt(
click.style('Enter your homeserver URL', bold=True),
default=homeserver,
)
if not homeserver.startswith('https://'):
homeserver = 'https://' + homeserver
if not homeserver.startswith('https://'):
homeserver = 'https://' + homeserver
user_id = '@user:gaja-group.com'
user_id = click.prompt(click.style('Enter your full user ID',
bold=True),
default=user_id)
user_id = '@user:gaja-group.com'
user_id = click.prompt(click.style('Enter your full user ID',
bold=True),
default=user_id)
device_name = 'matrix-bot'
device_name = click.prompt(
click.style('Choose a name for this device', bold=True),
default=device_name,
)
device_name = 'matrix-bot'
device_name = click.prompt(
click.style('Choose a name for this device', bold=True),
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,
)
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:
sys.exit(0)
return {
'homeserver': homeserver,
@ -273,7 +315,7 @@ class Bot:
'room_id': room_id,
}
async def to_device_callback(self, event): # noqa
async def _to_device_callback(self, event): # noqa
"""Handle events sent to device."""
try:
client = self.client
@ -455,21 +497,60 @@ class Bot:
# f'sas.timed_out = {sas.timed_out}\n'
# f'sas.verified = {sas.verified}\n'
# f'sas.verified_devices = {sas.verified_devices}\n')
click.secho('Emoji verification was successful!',
fg='green')
await self.shutdown([], self._loop)
click.secho(
'Emoji verification was successful! Please use Ctrl+C '
'to exit.',
fg='green')
else:
print(f'Received unexpected event type {type(event)}. '
f'Event is {event}. Event will be ignored.')
except BaseException:
print(traceback.format_exc())
def _sync_callback(self, event: any) -> None:
with open(NEXT_BATCH_PATH, 'w') as next_batch_token:
next_batch_token.write(event.next_batch)
@click.group()
async def _room_text_message_callback(self, room: any,
message: any) -> None:
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 _fifo_reader(self, pipe_fd: str) -> None:
with os.fdopen(pipe_fd) as file:
while True:
data = file.read()
if len(data) > 0:
try:
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)
@click.group(invoke_without_command=True)
@click.option('-v', '--verbose', count=True)
def cli(verbose: int) -> None:
@click.pass_context
def cli(ctx: click.Context, verbose: int) -> None:
# click.secho('Matrix Bot\n', bold='true')
pass
if ctx.invoked_subcommand is None:
bot = Bot()
bot.run()
@cli.command('verify')
@ -483,7 +564,13 @@ def verify_command() -> None:
@click.option('-r', '--room', help='the room to send to')
def send_command(message: str, room: str) -> None:
bot = Bot()
bot.send_text_message_to_room(room, message)
bot._run_async(bot.send_text_to_room(room, message))
@cli.command('run')
def run() -> None:
bot = Bot()
bot.run()
if __name__ == '__main__':

35
matrix_bot/botctl.py Normal file
View File

@ -0,0 +1,35 @@
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()

15
matrix_bot/config.py Normal file
View File

@ -0,0 +1,15 @@
import os
# 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
# Default Homeserver URL
HOMESERVER_URL = 'https://matrix.gaja-group.com'

View File

@ -4,13 +4,10 @@ setup(
name='matrix-bot',
version='0.1',
py_modules=['matrix_bot'],
install_requires=[
'Click',
'matrix-nio[e2e]',
'markdown'
],
install_requires=['Click', 'matrix-nio[e2e]', 'markdown'],
entry_points="""
[console_scripts]
matrix-bot=matrix_bot.bot:cli
matrix-botctl=matrix_bot.botctl:cli
""",
)