1#!/usr/bin/python3
2
3import json
4import logging
5import re
6import sys
7from collections import defaultdict
8
9MASK_MAGIC_REGEX = re.compile(r'[*?!@$]')
10
11def to_unixnano(timestamp):
12    return int(timestamp) * (10**9)
13
14# include/atheme/channels.h
15CMODE_FLAG_TO_MODE = {
16    0x001: 'i', # CMODE_INVITE
17    0x010: 'n', # CMODE_NOEXT
18    0x080: 's', # CMODE_SEC
19    0x100: 't', # CMODE_TOPIC
20}
21
22def convert(infile):
23    out = {
24        'version': 1,
25        'source': 'atheme',
26        'users': defaultdict(dict),
27        'channels': defaultdict(dict),
28    }
29
30    group_to_founders = defaultdict(list)
31
32    channel_to_founder = defaultdict(lambda: (None, None))
33
34    for line in infile:
35        line = line.rstrip('\r\n')
36        parts = line.split(' ')
37        category = parts[0]
38
39        if category == 'GACL':
40            # Note: all group definitions precede channel access entries (token CA) by design, so it
41            # should be safe to read this in using one pass.
42            groupname = parts[1]
43            user = parts[2]
44            flags = parts[3]
45            if 'F' in flags:
46                group_to_founders[groupname].append(user)
47        elif category == 'MU':
48            # user account
49            # MU AAAAAAAAB shivaram $1$hcspif$nCm4r3S14Me9ifsOPGuJT. user@example.com 1600134392 1600467343 +sC default
50            name = parts[2]
51            user = {'name': name, 'hash': parts[3], 'email': parts[4], 'registeredAt': to_unixnano(parts[5])}
52            out['users'][name].update(user)
53            pass
54        elif category == 'MN':
55            # grouped nick
56            # MN shivaram slingamn 1600218831 1600467343
57            username, groupednick = parts[1], parts[2]
58            if username != groupednick:
59                user = out['users'][username]
60                user.setdefault('additionalnicks', []).append(groupednick)
61        elif category == 'MDU':
62            if parts[2] == 'private:usercloak':
63                username = parts[1]
64                out['users'][username]['vhost'] = parts[3]
65        elif category == 'MC':
66            # channel registration
67            # MC #mychannel 1600134478 1600467343 +v 272 0 0
68            # MC #NEWCHANNELTEST 1602270889 1602270974 +vg 1 0 0 jaeger4
69            chname = parts[1]
70            chdata = out['channels'][chname]
71            # XXX just give everyone +nt, regardless of lock status; they can fix it later
72            chdata.update({'name': chname, 'registeredAt': to_unixnano(parts[2])})
73            if parts[8] != '':
74                chdata['key'] = parts[8]
75            modes = {'n', 't'}
76            mlock_on, mlock_off = int(parts[5]), int(parts[6])
77            for flag, mode in CMODE_FLAG_TO_MODE.items():
78                if flag & mlock_on != 0:
79                    modes.add(mode)
80                elif flag & mlock_off != 0 and mode in modes:
81                    modes.remove(mode)
82            chdata['modes'] = ''.join(sorted(modes))
83            chdata['limit'] = int(parts[7])
84        elif category == 'MDC':
85            # auxiliary data for a channel registration
86            # MDC #mychannel private:topic:setter s
87            # MDC #mychannel private:topic:text hi again
88            # MDC #mychannel private:topic:ts 1600135864
89            chname = parts[1]
90            category = parts[2]
91            if category == 'private:topic:text':
92                out['channels'][chname]['topic'] = line.split(maxsplit=3)[3]
93            elif category == 'private:topic:setter':
94                out['channels'][chname]['topicSetBy'] = parts[3]
95            elif category == 'private:topic:ts':
96                out['channels'][chname]['topicSetAt'] = to_unixnano(parts[3])
97            elif category == 'private:mlockext':
98                # the channel forward mode is +L on insp/unreal, +f on charybdis
99                # charybdis has a +L ("large banlist") taking no argument
100                # and unreal has a +f ("flood limit") taking two colon-delimited numbers,
101                # so check for an argument that starts with a #
102                if parts[3].startswith('L#') or parts[3].startswith('f#'):
103                    out['channels'][chname]['forward'] = parts[3][1:]
104        elif category == 'CA':
105            # channel access lists
106            # CA #mychannel shivaram +AFORafhioqrstv 1600134478 shivaram
107            chname, username, flags, set_at = parts[1], parts[2], parts[3], int(parts[4])
108            chname = parts[1]
109            chdata = out['channels'][chname]
110            flags = parts[3]
111            set_at = int(parts[4])
112            if 'amode' not in chdata:
113                chdata['amode'] = {}
114            # see libathemecore/flags.c: +o is op, +O is autoop, etc.
115            if 'F' in flags:
116                # If the username starts with "!", it's actually a GroupServ group.
117                if username.startswith('!'):
118                    group_founders = group_to_founders.get(username)
119                    if not group_founders:
120                        # skip this and warn about it later
121                        continue
122                    # attempt to promote the first group founder to channel founder
123                    username = group_founders[0]
124                    # but everyone gets the +q flag
125                    for founder in group_founders:
126                        chdata['amode'][founder] = 'q'
127                # there can only be one founder
128                preexisting_founder, preexisting_set_at = channel_to_founder[chname]
129                if preexisting_founder is None or set_at < preexisting_set_at:
130                    chdata['founder'] = username
131                    channel_to_founder[chname] = (username, set_at)
132                # but multiple people can receive the 'q' amode
133                chdata['amode'][username] = 'q'
134                continue
135            if MASK_MAGIC_REGEX.search(username):
136                # ignore groups, masks, etc. for any field other than founder
137                continue
138            # record the first appearing successor, if necessary
139            if 'S' in flags:
140                if not chdata.get('successor'):
141                    chdata['successor'] = username
142            # finally, handle amodes
143            if 'q' in flags:
144                chdata['amode'][username] = 'q'
145            elif 'a' in flags:
146                chdata['amode'][username] = 'a'
147            elif 'o' in flags or 'O' in flags:
148                chdata['amode'][username] = 'o'
149            elif 'h' in flags or 'H' in flags:
150                chdata['amode'][username] = 'h'
151            elif 'v' in flags or 'V' in flags:
152                chdata['amode'][username] = 'v'
153        else:
154            pass
155
156    # do some basic integrity checks
157    def validate_user(name):
158        if not name:
159            return False
160        return bool(out['users'].get(name))
161
162    invalid_channels = []
163
164    for chname, chdata in out['channels'].items():
165        if not validate_user(chdata.get('founder')):
166            if validate_user(chdata.get('successor')):
167                chdata['founder'] = chdata['successor']
168            else:
169                invalid_channels.append(chname)
170
171    for chname in invalid_channels:
172        logging.warning("Unable to find a valid founder for channel %s, discarding it", chname)
173        del out['channels'][chname]
174
175    return out
176
177def main():
178    if len(sys.argv) != 3:
179        raise Exception("Usage: atheme2json.py atheme_db output.json")
180    with open(sys.argv[1]) as infile:
181        output = convert(infile)
182        with open(sys.argv[2], 'w') as outfile:
183            json.dump(output, outfile)
184
185if __name__ == '__main__':
186    logging.basicConfig()
187    sys.exit(main())
188