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