forked from Spferical/matrix-shellbot
-
Notifications
You must be signed in to change notification settings - Fork 0
/
main.py
207 lines (177 loc) · 7.45 KB
/
main.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
#!/usr/bin/env python3
import click
import sys
import threading
import select
import pty
import os
import re
import requests
import time
import logging
import codecs
from matrix_client.client import MatrixClient
SHELL_CMD_REGEX = r'!shell (.*)'
CTRLC_CMD_REGEX = r'!ctrl\+c|!ctrlc|!shell ctrlc|!shell ctrl\+c'
MAX_STDOUT_PER_MSG = 1024 * 16
logger = logging.getLogger('shellbot')
logging.basicConfig(stream=sys.stdout, level=logging.INFO,
format="%(asctime)s:%(name)s:%(levelname)s:%(message)s")
escape_parser = re.compile(r'\x1b\[?([\d;]*)(\w)')
cleared_line_parser = re.compile(r'[^\n]*\x1b\[K')
def handle_escape_codes(shell_out):
"""
Parses (as best we can) the raw escape codes in a bunch of shell output
and converts it into chatclient-friendly text.
shell_out: string of shell output
"""
shell_out_after_clears = re.sub(cleared_line_parser, "", shell_out)
shell_out_noescapes = re.sub(escape_parser, "", shell_out_after_clears)
return shell_out_noescapes
def on_message(event, pin, allowed_users):
"""
Writes contents of a message event to the shell.
event: matrix event dict
pin: file object for pty master
allowed_users: users authorized to send input to the shell
A newline is appended to the text contents when written, so a one-line
message may be interpreted as a command.
Special cases: !ctrlc sends a sequence as if the user typed ctrl+c.
"""
if event['sender'] in allowed_users and (
'msgtype' in event['content'] and
event['content']['msgtype'] == 'm.text'):
message = str(event['content']['body'])
if re.match(CTRLC_CMD_REGEX, message, flags=re.I):
logger.info('sending ctrl+c')
pin.write('\x03')
pin.flush()
else:
cmd_match = re.match(SHELL_CMD_REGEX, message, flags=re.I)
if cmd_match:
message = cmd_match.group(1)
logger.info('shell stdin: {}'.format(message))
pin.write(message)
pin.write('\n')
pin.flush()
def get_inviter(invite_state, user_id):
for event in invite_state['events']:
logger.info(event)
if event['type'] == 'm.room.member' and (
event['content']['membership'] == 'invite' and
event['state_key'] == user_id):
return event['sender']
def on_invite(client, room_id, state, allowed_users):
inviter = get_inviter(state, client.user_id)
if inviter in allowed_users:
logger.info("joining room {} from {}'s invitation"
.format(room_id, inviter))
client.join_room(room_id)
def stdout_to_messages(buf, incremental_decoder, flush=True):
"""
Returns a list of strings to be sent in separate matrix messages.
Mutates buf to include only unsent shell output.
If flush is True, we output everything we have.
Otherwise, we only output if we reach our maximum message size, in which
case we split output into multiple messages, doing our best to split on
newlines.
Note that some intermediate output will be kept by the incremental decoder.
This is needed so we don't accidentally cut off output in the middle of a
utf8 multibyte character.
buf: list of bytestrings read from shell process, each <=1024 bytes long
incremental_decoder: incremental utf8 decoder
flush: whether to output all text, even if we haven't reached the message
size limit yet
"""
if flush:
total_stdout = b''.join(buf)
buf.clear()
return [incremental_decoder.decode(total_stdout)]
elif sum(len(s) for s in buf) > MAX_STDOUT_PER_MSG:
# grab <=1k chunks of stdout until we run out or have nearly too much
stdout_to_send = []
bytes_to_send = 0
while buf and len(buf[0]) + bytes_to_send <= MAX_STDOUT_PER_MSG:
stdout_to_send.append(buf.pop(0))
bytes_to_send += len(stdout_to_send[-1])
total_stdout = b''.join(stdout_to_send)
# cut off everything until the last newline, if any
last_newline = total_stdout.rfind(b'\n')
if last_newline != -1:
buf.append(total_stdout[last_newline + 1:])
return [incremental_decoder.decode(total_stdout[:last_newline])]
else:
# it's all one huge line. send it anyways.
return [incremental_decoder.decode(total_stdout)]
return []
def shell_stdout_handler(master, client, stop):
"""
Reads output from the shell process until there's a 0.1s+ period of no
output. Then, sends it as a message to all allowed matrix rooms.
master: master pipe for the pty. gives us read/write with the shell.
client: matrix client
stop: threading.Event that activates when the bot shuts down
This function exits when stop is set.
"""
buf = []
decoder = codecs.getincrementaldecoder('utf8')(errors='replace')
while not stop.is_set():
shell_has_more = select.select([master], [], [], 0.1)[0]
if shell_has_more:
shell_stdout = os.read(master, 1024)
if shell_stdout == '':
return
buf.append(shell_stdout)
if buf and client.rooms:
for shell_out in stdout_to_messages(
buf, decoder, flush=not shell_has_more):
logger.info('shell stdout: {}'.format(shell_out))
text = handle_escape_codes(shell_out)
text = text.replace('\r', '')
html = '<pre><code>' + text + '</code></pre>'
for room in client.rooms.values():
room.send_html(html, body=text)
@click.command()
@click.option('--homeserver', default='https://matrix.org',
help='matrix homeserver url')
@click.option('--authorize', default=['@matthew:vgd.me'], multiple=True,
help='authorize user to issue commands '
'& invite the bot to rooms')
@click.argument('username')
@click.argument('password')
def run_bot(homeserver, authorize, username, password):
allowed_users = authorize
shell_env = os.environ.copy()
shell_env['TERM'] = 'vt100'
child_pid, master = pty.fork()
if child_pid == 0: # we are the child
os.execlpe('sh', 'sh', shell_env)
pin = os.fdopen(master, 'w')
stop = threading.Event()
client = MatrixClient(homeserver)
client.login_with_password_no_sync(username, password)
# listen for invites during initial event sync so we don't miss any
client.add_invite_listener(
lambda room_id, state: on_invite(client, room_id, state,
allowed_users))
client.listen_for_events() # get rid of initial event sync
client.add_listener(lambda event: on_message(event, pin, allowed_users),
event_type='m.room.message')
shell_stdout_handler_thread = threading.Thread(
target=shell_stdout_handler, args=(master, client, stop))
shell_stdout_handler_thread.start()
while True:
try:
client.listen_forever()
except KeyboardInterrupt:
stop.set()
sys.exit(0)
except requests.exceptions.Timeout:
logger.warn("timeout. Trying again in 5s...")
time.sleep(5)
except requests.exceptions.ConnectionError as e:
logger.warn(repr(e))
logger.warn("disconnected. Trying again in 5s...")
time.sleep(5)
if __name__ == "__main__":
run_bot(auto_envvar_prefix='SHELLBOT')