~funderscore blog cgit wiki get in touch
aboutsummaryrefslogtreecommitdiff
blob: 2f872d31e9371859b85d7791c03861f22383be85 (plain)
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
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
#!/usr/bin/python
# SPDX-License-Identifier: CC0
#
# Written by: Ferass El Hafidi <vitali64pmemail@protonmail.com>
#
from sys import exit
import json
import string
import socket
import requests
import miniirc
import hashlib
import yaml
import re
from time import sleep
from requests.adapters import HTTPAdapter, Retry

puppets = {}

def connect_new_puppet(nickname, user_id, gaccount):
    if not puppets.get(gaccount):
        puppets[gaccount] = {}

    try:
        print("* Puppet %s already connected, skipping" % puppets[gaccount][user_id])

        if puppets[gaccount][user_id].connected == True:
            return puppets[gaccount][user_id]
    except KeyError:
        pass

    # If there's too many puppets, disconnect the oldest one
    if len(puppets[gaccount]) == client_limit:
        puppets[gaccount][0].disconnect(msg="Connection closed for inactivity")
        puppets[gaccount].pop(0)

    # Sanitize nickname
    allowed_chars = string.digits + string.ascii_letters + "^|\\-_[]{}"
    sanitized_nickname = nickname
    for char in sanitized_nickname:
        if char not in allowed_chars:
            # Try to represent illegal characters with similar, allowed ones
            if char == "(" or char == "<":
                sanitized_nickname = sanitized_nickname.replace(char, "[")
            elif char == ")" or char == ">":
                sanitized_nickname = sanitized_nickname.replace(char, "]")
            elif char == "/":
                sanitized_nickname = sanitized_nickname.replace(char, "|")
            elif char == "." or char == " ":
                sanitized_nickname = sanitized_nickname.replace(char, "_")
            else:
                sanitized_nickname = sanitized_nickname.replace(char, "-")
    if sanitized_nickname[0] in string.digits + '-':
        sanitized_nickname = "_" + sanitized_nickname

    if (sanitized_nickname.endswith("Serv") or "." in nickname) and gaccount.startswith("irc."):
        print("* Not creating puppet of a network service/server (%s)", sanitized_nickname)
        return

    print("* Connecting new puppet %s (user_id %s) as %s" % (nickname,
            user_id, sanitized_nickname))

    puppets[gaccount][user_id] = miniirc.IRC(
        irc_host,
        irc_port,
        sanitized_nickname,
        realname="%s [%s]" % (user_id, gaccount),
        auto_connect=False,
        debug=False,
        ssl=irc_tls,
        persist=False,
        quit_message="quit"
    )
    puppets[gaccount][user_id].Handler('PRIVMSG', colon=False)(fail_on_pm)
    userid_hash = hashlib.sha3_224(bytes(user_id, "utf-8"))
    write_ident_file(str(userid_hash.hexdigest()))

    puppets[gaccount][user_id].connect()

    while puppets[gaccount][user_id].connected != True:
        pass

    return puppets[gaccount][user_id]

def write_ident_file(ident):
    if ident_enabled:
        print("* Writing to ident file for oidentd/ident2")
        with open(ident_file, 'w') as file:
            file.write(ident_fmt.replace("%user%", ident))

def get_puppet_client(gaccount, user_id):
    try:
        ret = puppets[gaccount][user_id]
    except KeyError:
        print("* Error finding puppet client %s (%s)" % (user_id, gaccount))
        return None
    return ret

def on_irc_msg(irc, hostmask, args):
    nick    = hostmask[0]
    channel = args[0]
    message = args[-1]

    for gw in puppets:
        for puppet in puppets[gw]:
            if puppets[gw][puppet].nick == nick:
                return # don't act on own messages

    # Get on which gateway it was sent
    for gw in gateways:
        if gateways[gw] == channel:
            gateway = gw
            break
    else:
        return

    # Craft API message
    api_message = {
        "text":     message,
        "username": nick,
        "userid":   hostmask[1] + "@" + hostmask[2],
        "gateway":  gateway
    }

    print("* Sending messages to gateway")

    req = requests.post(f"{api_host}/api/message", json=api_message)


def api_loop():
    away = False
    sess = requests.Session()
    sess_retries = Retry(total=10000,
                    backoff_factor=0.1,
                    status_forcelist=[ 500, 502, 503, 504 ])

    sess.mount('http://', HTTPAdapter(max_retries=sess_retries))
    sess.mount('https://', HTTPAdapter(max_retries=sess_retries))

    while True:
        req = sess.get(f"{api_host}/api/stream", stream=True)

        try:
            for line in req.iter_lines():
                if line:
                    # Unset away status for all puppets
                    if away:
                        for gw in puppets:
                            for puppet in puppets[gw]:
                                puppets[gw][puppet].send("AWAY")
                        away = False
                    message = json.loads(line.decode('utf-8'))

                    if message.get("message") == "Not Found":
                        print("Error: Channel not found: %s" % channel)
                        return -1

                    if message["event"] == "api_connected":
                        print("Connected!")
                    elif message["event"] == "" or message["event"] == "user_action": # XXX: Probably a message
                        channel = gateways[message["gateway"]]
                        print("Bridging message event")
                        # Connect puppet if it does not exist already
                        puppet = connect_new_puppet(message["username"], message["userid"], message["account"])

                        if puppet:
                            # Have the puppet join the channel if it isn't joined already
                            puppet.send("JOIN", channel)

                            # Parse message for long code blocks
                            if paste_enabled:
                                code_blocks = re.findall("^\\`\\`\\`(.*?)^\\`\\`\\`", message["text"], flags=re.S + re.M)
                                for code_block in code_blocks:
                                    if code_block.count("\n") > paste_maxlines:
                                        cb_checksum = hashlib.sha3_224(bytes(code_block, "utf-8"))
                                        file = cb_checksum.hexdigest() + ".txt"
                                        message["text"] = message["text"].replace("```" + code_block + "```", "%s%s" % (paste_domain, file))
                                        with open(paste_dir + "/" + file, mode='w') as file_paste:
                                            print(code_block, file=file_paste)
                                    else:
                                        # Remove trailing ```'s
                                        message["text"] = message["text"].replace("```" + code_block + "```", code_block)

                            # Send the message
                            for line in message["text"].split("\n"):
                                if len(line) >= irc_msglen:
                                    # Hacky...
                                    buf = ""
                                    i = 0
                                    for char in line:
                                        i += 1
                                        if len(buf) < irc_msglen and i != (len(line) - 1):
                                            buf += char
                                        elif len(buf) == irc_msglen or i == (len(line) - 1):
                                            puppet.msg(channel, buf)
                                            buf = ""
                                else:
                                    if line != "":
                                        if message["event"] == "user_action":
                                            puppet.me(channel, line)
                                        else:
                                            puppet.msg(channel, line)
                                sleep(0.5)
                    elif message["event"] == "join":
                        channel = gateways[message["gateway"]]
                        print("Bridging join event")
                        # Connect puppet if it does not exist already
                        puppet = connect_new_puppet(message["username"], message["userid"], message["account"])

                        if puppet:
                            # Have the puppet join the channel
                            puppet.send("JOIN", channel)
                    elif message["event"] == "leave":
                        channel = gateways[message["gateway"]]
                        # Check if puppet exists
                        puppet = get_puppet_client(message["account"], message["userid"])

                        if puppet == None:
                            # We have nothing to do
                            continue
                        puppet.send("PART", channel, "Leaving")
        except (requests.exceptions.ConnectionError, 
                requests.exceptions.ChunkedEncodingError):
            print("Disconnected from Matterbridge")

            # Set away status for all puppets
            if away == False:
                away = True
                for gw in puppets:
                    for puppet in puppets[gw]:
                        puppets[gw][puppet].send("AWAY", "Matterbridge disconnected")
            continue

def fail_on_pm(irc, hostmask, args):
    nick    = hostmask[0]
    target  = args[0]

    if "\x01" in args[1]:
        return

    if target == irc.nick:
        irc.msg(nick, "[Automated message] This is a bridged puppet, private messages are currently unsupported - message not sent.")

def on_irc_kick(irc, hostmask, args):
    print("* Got kick")
    if irc.nick == hostmask[0]:
        print("* Trying to rejoin channel")
        irc.join(args[0])

def clear_ident_file(irc, hostmask, args):
    if ident_enabled:
        print("* Clearing ident file")
        # Clear ident file
        with open(ident_file, 'w') as file:
            file.write("\n")

def main():
    # Set CTCP VERSION reply
    miniirc.version = "Matterpuppeter ยท https://git.vitali64.duckdns.org/utils/matterpuppeter.git"

    # Set handlers
    miniirc.Handler('001')(clear_ident_file) # Global handler
    # Write ident for the bot
    write_ident_file(ident_bot)

    # Connect to IRC
    irc = miniirc.IRC(
        irc_host,
        irc_port,
        irc_nick,
        realname=irc_gecos,
        debug=False,
        ssl=irc_tls,
        ns_identity=sasl_auth if sasl_enabled else None,
        quit_message="shutting down",
        auto_connect=True,
        persist=True
    )
    irc.Handler('PRIVMSG', colon=False)(on_irc_msg)
    irc.Handler('NOTICE', colon=False)(on_irc_msg)
    irc.Handler('KICK', colon=False)(on_irc_kick)


    for gateway in gateways:
        irc.send("JOIN", gateways[gateway])

    api_loop()

if __name__ == '__main__':
    with open("config.yaml") as file:
        config = yaml.load(file, Loader=yaml.FullLoader)
        try:
            mb_account     = config["api"]["account"]
            irc_host       = config["irc"]["host"]
            irc_port       = config["irc"]["port"]
            irc_tls        = config["irc"]["tls"]
            irc_nick       = config["irc"]["nick"]
            irc_gecos      = config["irc"]["gecos"]
            irc_msglen     = config["irc"]["message_limit"]
            api_host       = config["api"]["host"]
            ident_enabled  = config["ident"]["enable"]
            if ident_enabled:
                ident_file     = config["ident"]["file"]
                ident_fmt      = config["ident"]["format"]
                ident_bot      = config["ident"]["username"]
            client_limit   = config["irc"]["client_limit"]
            paste_enabled  = config["paste"]["enable"]
            if paste_enabled:
                paste_dir      = config["paste"]["dir"]
                paste_domain   = config["paste"]["domain"]
                paste_maxlines = config["paste"]["maxlines"]
            sasl_enabled   = config["irc"]["sasl"]["enable"]
            if sasl_enabled:
                sasl_auth  = (config["irc"]["sasl"]["username"],
                              config["irc"]["sasl"]["password"])
            gateways       = config["gateway"]
        except KeyError:
            print("Error: Configuration file is missing options")
            exit(1)
    main()