""" yrc: the Unix IRC client Jacob Welsh February 2017 - June 2019 """ __version__ = '97 Kelvin' # Knobs that one might conceivably want to tweak DEFAULT_PORT = 6667 RECONN_DELAY_MIN = 4 # seconds RECONN_DELAY_MAX = 256 PING_INTERVAL = 120 PING_TIMEOUT = 240 BUFLIST_WIDTH_MIN = len('* =#a$|') BUFLIST_WIDTH_STEP = 4 BUFLIST_WIDTH_START = BUFLIST_WIDTH_MIN + 3*BUFLIST_WIDTH_STEP format_time = lambda tt: '%02d:%02d:%02d' % (tt.tm_hour, tt.tm_min, tt.tm_sec) from os import ( O_APPEND, O_CREAT, O_NONBLOCK, O_WRONLY, chdir, close, getenv, mkdir, open as os_open, pipe, read, strerror, write ) from time import localtime, time from errno import EAGAIN, EWOULDBLOCK, EEXIST, EINPROGRESS, EINTR, ENOENT from select import select, error as SelectError from signal import signal, SIG_DFL, SIGINT, SIGWINCH from socket import socket, SOL_SOCKET, SO_ERROR, SHUT_RDWR, error as SockError from fcntl import fcntl, F_GETFL, F_SETFL import yterm as t from yterm import NUL, BS, LF, CR, SO, SI, ESC, CSI, cseq STDIN = 0 STDOUT = 1 self_pipe_rd, self_pipe_wr = pipe() is_ctrl = lambda c: c < '\x20' or c == '\x7f' ctrl = lambda c: chr(ord(c) ^ 0x40) meta = lambda c: ESC + c BEL = chr(7) CRLF = CR + LF C_X = ctrl('X') C_G = ctrl('G') # aka BEL # Cursor control keys LEFT = t.CURSOR_BACK1 RIGHT = t.CURSOR_FORWARD1 UP = t.CURSOR_UP1 DOWN = t.CURSOR_DOWN1 HOME = t.CURSOR_HOME # Not sure where these originate; vt220 perhaps? Seen on xterm, linux, screen END = cseq('F') INS = cseq('~', 2) DEL = cseq('~', 3) PGUP = cseq('~', 5) PGDN = cseq('~', 6) # Likewise; so far seen on linux, screen LNX_HOME = cseq('~', 1) LNX_END = cseq('~', 4) GLOBAL_KEYS = { C_X + 'n': 'buflist-switch-next', C_X + 'p': 'buflist-switch-prev', C_X + 'l': 'buflist-last-selected', C_X + 'w': 'buflist-enter', ctrl('L'): 'redraw', } PROMPT_KEYS = { CR: 'prompt-submit', INS: 'prompt-exit', ctrl('F'): 'prompt-forward', RIGHT: 'prompt-forward', ctrl('B'): 'prompt-backward', LEFT: 'prompt-backward', ctrl('A'): 'prompt-start', HOME: 'prompt-start', LNX_HOME: 'prompt-start', ctrl('E'): 'prompt-end', END: 'prompt-end', LNX_END: 'prompt-end', ctrl('H'): 'prompt-backspace', ctrl('?'): 'prompt-backspace', ctrl('D'): 'prompt-delete', DEL: 'prompt-delete', meta('v'): 'scroll-up-page', PGUP: 'scroll-up-page', ctrl('V'): 'scroll-down-page', PGDN: 'scroll-down-page', } BUFLIST_KEYS = { CR: 'buflist-submit', ctrl('N'): 'buflist-next', DOWN: 'buflist-next', 'j': 'buflist-next', ctrl('P'): 'buflist-prev', UP: 'buflist-prev', 'k': 'buflist-prev', meta('<'): 'buflist-top', HOME: 'buflist-top', LNX_HOME: 'buflist-top', 'g': 'buflist-top', meta('>'): 'buflist-bottom', END: 'buflist-bottom', LNX_END: 'buflist-bottom', 'G': 'buflist-bottom', 'h': 'buflist-shrink', LEFT: 'buflist-shrink', 'l': 'buflist-grow', RIGHT: 'buflist-grow', } BUF_KEYS = { 'i': 'prompt-enter', INS: 'prompt-enter', meta('v'): 'scroll-up-page', PGUP: 'scroll-up-page', ctrl('B'): 'scroll-up-page', 'b': 'scroll-up-page', ctrl('V'): 'scroll-down-page', PGDN: 'scroll-down-page', ctrl('F'): 'scroll-down-page', 'f': 'scroll-down-page', ' ': 'scroll-down-page', ctrl('P'): 'scroll-up-line', UP: 'scroll-up-line', 'k': 'scroll-up-line', ctrl('N'): 'scroll-down-line', DOWN: 'scroll-down-line', 'j': 'scroll-down-line', meta('<'): 'scroll-top', HOME: 'scroll-top', LNX_HOME: 'scroll-top', 'g': 'scroll-top', meta('>'): 'scroll-bottom', END: 'scroll-bottom', LNX_END: 'scroll-bottom', 'G': 'scroll-bottom', } CMD_ABBREVS = { 'c': 'connect', 'd': 'disconnect', 'j': 'join', 'k': 'kick', 'l': 'list', 'm': 'mode', 'n': 'nick', 'nam': 'names', 'p': 'part', 'q': 'quit', 's': 'send', 't': 'topic', 'st': 'set-topic', 'w': 'whois', 'ww': 'whowas', 'x': 'close', 'xn': 'close-net', } # file.write can barf up EINTR and unclear how to retry due to buffering. Nuts. def write_all(fd, s): n = 0 while n < len(s): try: n += write(fd, s[n:]) except EnvironmentError, e: if e.errno != EINTR: raise out_buf = bytearray() write_out = out_buf.extend def flush_out(): write_all(STDOUT, out_buf) del out_buf[:] # MLtronics def variant(vtype, name, nargs): tag = (vtype, len(vtype), name) def cons(*args): if len(args) != nargs: raise TypeError('%s takes %d args (%d given)' % (name, nargs, len(args))) return (tag, args) vtype.append((name, cons)) return cons variant_name = lambda val: val[0][2] variant_args = lambda val: val[1] def matcher(vtype, cases): def receiver(name, cons): for case, recv in cases: if case is cons: return recv raise TypeError('missing case %s' % name) tbl = [receiver(name, cons) for name, cons in vtype] def match(val): tag, args = val if tag[0] is not vtype: raise TypeError return tbl[tag[1]](*args) return match message = [] m_privmsg = variant(message, 'PRIVMSG', 2) m_notice = variant(message, 'NOTICE', 2) m_join = variant(message, 'JOIN', 2) m_part = variant(message, 'PART', 3) m_quit = variant(message, 'QUIT', 2) m_nick = variant(message, 'NICK', 2) m_kick = variant(message, 'KICK', 4) # kick of specified user m_kicked = variant(message, 'KICKED', 3) # kick of self m_topic = variant(message, 'TOPIC', 2) # unifies TOPIC and RPL_(NO)TOPIC m_chantopic = variant(message, 'CHANTOPIC', 3) # for unjoined channels m_mode = variant(message, 'MODE', 2) m_chanmode = variant(message, 'CHANMODE', 3) m_names = variant(message, 'NAMES', 3) m_endnames = variant(message, 'ENDNAMES', 2) m_error = variant(message, 'ERROR', 2) # ERROR from server m_client = variant(message, 'CLIENT', 1) # generated by client, not logged m_server = variant(message, 'SERVER', 2) # catch-all for other server msgs scr_height = None scr_width = None def sequence(*thunks): def run(): for f in thunks: f() return run def flatten(iterables): for it in iterables: for val in it: yield val def char_range(pair): a, b = pair return ''.join(map(chr, range(ord(a), ord(b) + 1))) def partition(l, pred): left = [] right = [] for elem in l: if pred(elem): left.append(elem) else: right.append(elem) return left, right def split_pair(s, sep=' '): pair = s.split(sep, 1) if len(pair) == 1: pair.append('') return pair def make_encoder(f): tbl = [f(i) for i in range(0x100)] return lambda s: ''.join(tbl[ord(c)] for c in s) # Non-invertible terminal sanitizing asciify = make_encoder(lambda i: '^' + ctrl(chr(i)) if i < 0x20 or i == 0x7f else chr(i) if 0x20 <= i <= 0x7e else '\\x%X' % i) # Uglier, injective encoding for file names fs_encode = make_encoder(lambda i: chr(i) if 0x20 <= i <= 0x7e and chr(i) not in './%' else '%' + ('%02X' % i)) make_casemapper = lambda upper_bound: make_encoder(lambda i: chr(i + 0x20) if 0x41 <= i <= upper_bound else chr(i)) # We currently support only the original IRC case mapping (as implemented, not # as specified; one of many howlers in the RFC). The RPL_ISUPPORT reply that # some servers use to advertise alternate mappings was specified in a draft # long since expired # (http://www.irc.org/tech_docs/draft-brocklesby-irc-isupport-03.txt). Besides, # a case-insensitive protocol that can't agree on what "case" means is # preposterous. This is an IRC client; if a server does not behave like an IRC # server, it doesn't fall to us to decipher its nonsense. IRC_CASEMAP = {'rfc1459': make_casemapper(0x5e)} casemap_ascii = make_casemapper(0x5a) clip = lambda min_, max_, x: max(min_, min(max_, x)) clip_to = lambda l, i: clip(0, len(l) - 1, i) get_clipped = lambda l, i: l[clip_to(l, i)] def clip_str(s, width): if width == 0: return '' if len(s) > width: return '%s$' % s[:width - 1] return s def pad_or_clip_str(s, width, pad=' '): if len(s) < width: return s + pad*(width - len(s)) return clip_str(s, width) def wrap(line, width, sep=' '): start = 0 lines = [] while start + width < len(line): end = start + width cut = line.rfind(sep, start, end + 1) if cut == -1: lines.append(line[start:end]) start = end else: lines.append(line[start:cut]) start = cut + 1 lines.append(line[start:]) return lines def is_digit(c): return '0' <= c <= '9' def parse_address(addr): addr = addr.rsplit(':', 1) host = addr[0] if len(addr) == 1: port = DEFAULT_PORT else: port = addr[1] if not all(map(is_digit, port)): raise ValueError('port not a positive integer: %r' % port) port = int(port) if port >= 2**16: raise ValueError('port out of range: %d' % port) return host, port format_address = lambda host_port: '%s:%d' % host_port int_of_bytes = lambda b: reduce(lambda acc, byte: acc*256 + ord(byte), b, 0) def rand_int(n): """Get a random integer from 0 (inclusive) to n (exclusive) using the system's nonblocking entropy pool. More-or-less deterministic run time, at the cost of a small modulo bias.""" nbytes = (n.bit_length() + 7) / 8 with open('/dev/urandom', 'rb') as f: return int_of_bytes(f.read(2*nbytes)) % n # # Binary min-heap, used as priority queue for scheduling # def heap_insert(h, key, value): h.append((key, value)) # Percolate up i = len(h) - 1 while i > 0: i_parent = (i - 1)/2 item = h[i] parent = h[i_parent] if parent[0] <= item[0]: break h[i] = parent h[i_parent] = item i = i_parent heap_peek = lambda h: h[0] def heap_extract(h): if len(h) == 1: return h.pop() result = h[0] h[0] = h.pop() # Percolate down i = 0 while True: i_child = 2*i + 1 if i_child >= len(h): break i_right = i_child + 1 if i_right < len(h) and h[i_right][0] < h[i_child][0]: i_child = i_right item = h[i] child = h[i_child] if item[0] <= child[0]: break h[i] = child h[i_child] = item i = i_child return result # purely for testing def heapsort(iterable): h = [] for item in iterable: heap_insert(h, item, None) while h: yield heap_extract(h)[0] # # Config # def safe_filename(name): return '/' not in name and name not in ('', '.', '..') def get_config(key, paths=(()), default=None): assert safe_filename(key) for path in paths: assert all(map(safe_filename, path)) fspath = [] fspath.extend(path) fspath.append(key) try: with open('/'.join(fspath), 'rb') as f: return f.read().rstrip('\n') except EnvironmentError, e: if e.errno != ENOENT: error(e) return default def config_lines(text): if text is None: return None lines = text.split('\n') lines = [l.strip() for l in lines if not l.startswith('#')] lines = [l for l in lines if len(l) > 0] return lines if lines else None # # UI stuff # class CommandError(Exception): pass commands = {} def command(name, min_args=0, max_args=None, extended_arg=False): if max_args is None: max_args = min_args def register(func): if name in commands: raise ValueError('duplicate command %s' % name) def run(arg_str=''): args = [] while len(args) != max_args: arg, arg_str = split_pair(arg_str.lstrip()) if arg == '': break args.append(arg) arg_str = arg_str.lstrip() if extended_arg: args.append(arg_str) elif arg_str: raise CommandError('%s: too many arguments' % name) if len(args) < min_args: raise CommandError('%s: too few arguments' % name) return func(*args) commands[name] = run return func return register def check_command_dicts(dicts): for d in dicts: for cmd in d.itervalues(): assert cmd in commands, cmd def run_command(line): cmd, args = split_pair(line) cmd = CMD_ABBREVS.get(cmd, cmd) func = commands.get(cmd) if func is None: raise CommandError('bad command: %s' % cmd) return func(args) flags = [False]*12 _getf = lambda i: (lambda: flags[i]) _setf = lambda i: (lambda: flags.__setitem__(i, True)) _clrf = lambda i: (lambda: flags.__setitem__(i, False)) refresh_flag = _getf(0) schedule_refresh = _setf(0) refresh_done = _clrf(0) redraw_flag = _getf(1) schedule_redraw = _setf(1) redraw_done = _clrf(1) buflist_draw_flag = _getf(2) schedule_buflist_draw = _setf(2) buflist_draw_done = _clrf(2) buf_draw_flag = _getf(3) schedule_buf_draw = _setf(3) buf_draw_done = _clrf(3) prompt_draw_flag = _getf(4) schedule_prompt_draw = _setf(4) prompt_draw_done = _clrf(4) status_draw_flag = _getf(5) schedule_status_draw = _setf(5) status_draw_done = _clrf(5) quit_flag = _getf(6) schedule_quit = _setf(6) # 7-8: available buflist_flag = _getf(9) buflist_enter = _setf(9) buflist_exit = _clrf(9) prompt_flag = _getf(10) prompt_enter = _setf(10) prompt_exit = _clrf(10) ping_draw_flag = _getf(11) set_ping_draw = _setf(11) clr_ping_draw = _clrf(11) new_buf = lambda name, parent, title: [name, parent, title, 0, [], 0, True] buf_name = lambda b: b[0] buf_parent = lambda b: b[1] buf_title = lambda b: b[2] buf_vscroll = lambda b: b[3] buf_lines = lambda b: b[4] buf_num_read = lambda b: b[5] buf_unread = lambda b: len(buf_lines(b)) - buf_num_read(b) buf_at_end = lambda b: b[6] buf_set_title = lambda b, title: b.__setitem__(2, title) buf_set_vscroll = lambda b, i: b.__setitem__(3, clip_to(buf_lines(b), i)) buf_set_num_read = lambda b, n: b.__setitem__(5, max(buf_num_read(b), n)) buf_set_at_end = lambda b: b.__setitem__(6, True) buf_clr_at_end = lambda b: b.__setitem__(6, False) format_buf_msg = matcher(message, ( (m_privmsg, lambda sender, msg: '<%s> %s' % (sender, msg)), (m_server, lambda sender, msg: '<%s> %s' % (sender, msg)), (m_notice, lambda sender, msg: '<-%s-> %s' % (sender, msg)), (m_join, lambda sender, chan: '%s joins %s' % (sender, chan)), (m_part, lambda sender, chan, msg: '%s parts %s [%s]' % (sender, chan, msg)), (m_quit, lambda sender, msg: '%s quits [%s]' % (sender, msg)), (m_nick, lambda sender, nick: '%s is now known as %s' % (sender, nick)), (m_kick, lambda sender, chan, name, msg: '%s kicked from %s by %s [%s]' % (name, chan, sender, msg)), (m_kicked, lambda sender, chan, msg: 'kicked from %s by %s [%s]' % (chan, sender, msg)), (m_topic, lambda sender, topic: '%s sets topic to %s' % (sender, topic) if topic else '%s removes topic' % sender), (m_chantopic, lambda sender, chan, topic: '<%s> Topic for %s: %s' % (sender, chan, topic) if topic is not None else '<%s> No topic for %s' % (sender, chan)), (m_mode, lambda sender, modes: 'MODE %s by %s' % (modes, sender)), (m_chanmode, lambda sender, chan, modes: 'MODE %s on %s by %s' % (modes, chan, sender)), (m_names, lambda sender, chan, names: '<%s> NAMES in %s: %s' % (sender, chan, names)), (m_endnames, lambda sender, chan: '<%s> end NAMES in %s' % (sender, chan)), (m_error, lambda sender, msg: '<%s> server error: %s' % (sender, msg)), (m_client, lambda msg: msg))) def buf_log_msg(buf, m): buf_lines(buf).append(format_time(localtime()) + ' ' + format_buf_msg(m)) schedule_buflist_draw() # for unread counts if buf is cur_buf and buf_at_end(buf): schedule_buf_draw() def buf_privmsg(buf, msg): if buf_parent(buf) is None: raise CommandError("can't send messages here") conn_privmsg(buf_registered_conn(buf), buf_name(buf), msg) def render_lines(lines, width, start, row_limit): """Returns list of wrapped line lists, and the number of *complete* source lines in the output""" rows = 0 groups = [] while rows < row_limit and start < len(lines): msg = lines[start] group = wrap(asciify(msg), width) rows += len(group) if rows > row_limit: group = group[:row_limit - rows] groups.append(group) return groups, len(groups) - 1 groups.append(group) start += 1 return groups, len(groups) def check_buf_at_end(): width = scr_width - buflist_width() height = scr_height - 3 if height < 1 or width < 1: return lines = buf_lines(cur_buf) scroll = buf_vscroll(cur_buf) lines_shown = render_lines(lines, width, scroll, height)[1] if scroll + lines_shown == len(lines): buf_set_at_end(cur_buf) else: buf_clr_at_end(cur_buf) def buf_draw(buf): if scr_height < 2: return schedule_refresh() write_out(t.CURSOR_HOME) write_out(t.render((t.A_REVERSE, pad_or_clip_str(asciify(buf_title(buf)), scr_width)))) left_column = buflist_width() + 1 width = scr_width - buflist_width() top_row = 2 height = scr_height - 3 if height < 1 or width < 1: return lines = buf_lines(buf) while True: scroll = buf_vscroll(buf) groups, lines_shown = render_lines(lines, width, scroll, height) lines_shown += scroll if lines_shown == len(lines): buf_set_at_end(buf) break if not buf_at_end(buf): break # Was at end until now: autoscroll if scroll + 1 == len(lines): break # Couldn't even fit one complete line buf_set_vscroll(buf, max(scroll + 1, len(lines) - height)) row = 0 for row, text in enumerate(flatten(groups)): write_out(t.cursor_pos(top_row + row, left_column) + text) if len(text) != width: write_out(t.ERASE_LINE_TO_END) for row in range(row + 1, height): write_out(t.cursor_pos(top_row + row, left_column)) write_out(t.ERASE_LINE_TO_END) buf_set_num_read(buf, lines_shown) buf_draw_done() cur_buf = new_buf('yrc', None, 'yrc general messages') buffers = [cur_buf] buffer_index = {} is_child_of = lambda parent: lambda buf: buf_parent(buf) is parent def sort_buffers(): global buffers schedule_buflist_draw() # "yrc" is always first acc, buffers = buffers[:1], buffers[1:] roots, buffers = partition(buffers, is_child_of(None)) roots.sort(key=buf_name) for root in roots: children, buffers = partition(buffers, is_child_of(root)) children.sort(key=buf_name) acc.append(root) acc.extend(children) buffers = acc buffer_index.clear() for b in buffers: parent = buf_parent(b) parent_name = buf_name(parent) if parent else None buffer_index[(buf_name(b), parent_name)] = b def get_buf(name, parent_name): try: return buffer_index[(name, parent_name)] except KeyError: if parent_name is None: parent = None title = 'Network messages: %s' % name else: parent = get_buf(parent_name, None) title = name if is_chan(name) else 'Private messages: %s' % name b = new_buf(name, parent, title) buffers.append(b) sort_buffers() return b def find_buf(buf): for i, b in enumerate(buffers): if b is buf: return i raise ValueError("not in buffer list") def close_buf(buf): i = find_buf(buf) assert i > 0 prev = buffers[i - 1] buflist_set_cursor(prev) last = buflist_last() if last is buf: # not sure if this is possible but just in case last = prev buflist_select(prev) buflist_set_last(last) parent = buf_parent(buf) parent_name = buf_name(parent) if parent else None del buffer_index[(buf_name(buf), parent_name)] buffers.pop(i) command('scroll-down-page')(sequence( schedule_buf_draw, schedule_buflist_draw, # update unread counts lambda: buf_set_vscroll(cur_buf, buf_vscroll(cur_buf) + (scr_height-3)/2))) command('scroll-up-page')(sequence( schedule_buf_draw, lambda: buf_set_vscroll(cur_buf, buf_vscroll(cur_buf) - (scr_height-3)/2), check_buf_at_end)) # stop autoscrolling if no longer at end command('scroll-down-line')(sequence( schedule_buf_draw, schedule_buflist_draw, lambda: buf_set_vscroll(cur_buf, buf_vscroll(cur_buf) + 1))) command('scroll-up-line')(sequence( schedule_buf_draw, lambda: buf_set_vscroll(cur_buf, buf_vscroll(cur_buf) - 1), check_buf_at_end)) command('scroll-bottom')(sequence( schedule_buf_draw, schedule_buflist_draw, lambda: buf_set_vscroll(cur_buf, len(buf_lines(cur_buf))))) command('scroll-top')(sequence( schedule_buf_draw, lambda: buf_set_vscroll(cur_buf, 0), check_buf_at_end)) def info(msg, buf=None): buf_log_msg(buf or buffers[0], m_client(msg)) def error(msg_or_exc, buf=None): buf_log_msg(buf or buffers[0], m_client('ERROR: ' + str(msg_or_exc))) prompt = [[], 0, 0, 1] prompt_chars = lambda: prompt[0] prompt_cursor = lambda: prompt[1] prompt_hscroll = lambda: prompt[2] prompt_cursor_column = lambda: prompt[3] def prompt_clear(): schedule_prompt_draw() prompt[0] = [] prompt_set_cursor(0) prompt_set_hscroll(0) def prompt_set_cursor(c): schedule_prompt_draw() if 0 <= c <= len(prompt_chars()): prompt[1] = c return True return False def prompt_set_hscroll(s): schedule_prompt_draw() prompt[2] = s def prompt_set_cursor_column(c): prompt[3] = c def prompt_insert(char): schedule_prompt_draw() c = prompt_cursor() prompt_chars().insert(c, char) prompt_set_cursor(c + 1) @command('prompt-delete') def prompt_delete(): schedule_prompt_draw() c = prompt_cursor() chars = prompt_chars() if c < len(chars): chars.pop(c) @command('prompt-backspace') def prompt_backspace(): schedule_prompt_draw() c = prompt_cursor() if prompt_set_cursor(c - 1): prompt_delete() @command('prompt-submit') def prompt_submit(): schedule_prompt_draw() line = ''.join(prompt_chars()) prompt_clear() if len(line) == 0: return try: if line.startswith('/'): line = line[1:] if not line: raise CommandError('empty command') if not line.startswith('/'): run_command(line) return buf_privmsg(cur_buf, line) except CommandError, e: error(e, cur_buf) command('prompt-forward')(lambda: prompt_set_cursor(prompt_cursor() + 1)) command('prompt-backward')(lambda: prompt_set_cursor(prompt_cursor() - 1)) command('prompt-start')(lambda: prompt_set_cursor(0)) command('prompt-end')(lambda: prompt_set_cursor(len(prompt_chars()))) def prompt_draw(): if scr_height < 1: return write_out(t.cursor_pos(scr_height, 1)) if scr_width < 4: write_out(t.ERASE_LINE_TO_END) return prompt_str = '> ' write_out(prompt_str) chars = prompt_chars() cursor = prompt_cursor() hscr = prompt_hscroll() before_cursor = asciify(chars[:cursor]) cursor_offset = len(before_cursor) - hscr max_width = scr_width - len(prompt_str) if not 0 <= cursor_offset < max_width: hscr = max(0, hscr + cursor_offset - max_width/2) prompt_set_hscroll(hscr) cursor_offset = len(before_cursor) - hscr prompt_set_cursor_column(1 + len(prompt_str) + cursor_offset) printable = (before_cursor[hscr:] + asciify(chars[cursor:])[:max_width - cursor_offset]) write_out(printable) write_out(t.ERASE_LINE_TO_END) schedule_refresh() prompt_draw_done() def conn_ping_status(c): t0 = conn_ping_ts(c) t1 = conn_pong_ts(c) if t1 < t0: if not ping_draw_flag(): set_ping_draw() run_in(5, sequence(clr_ping_draw, status_draw)) delta = int(mono_time() - t0) return '%d...' % (delta - delta%5) return '%.3f' % (t1 - t0) def status_draw(): row = scr_height - 1 if row < 1: return if kbd_state is ks_esc: status = 'ESC-' elif kbd_state is ks_cx: status = 'C-x-' elif kbd_state in (ks_cseq, ks_cs_intermed): status = ' '.join(['CSI'] + list(str(kbd_accum[len(CSI):]))) + '-' elif cur_buf is buffers[0]: status = '' else: parent = buf_parent(cur_buf) network = buf_name(cur_buf if parent is None else parent) status = asciify(network) c = network_conns.get(network) if c: status = asciify(conn_nick(c) + '@') + status if parent: venue = buf_name(cur_buf) status += ' | ' + asciify(venue) if c and conn_sock(c): if not conn_registered(c): status += ' | unregistered' elif parent and is_chan(venue) and venue not in conn_channels(c): status += ' | unjoined' status += ' | ping: %s' % conn_ping_status(c) else: status += ' | offline' write_out(t.cursor_pos(row, 1)) write_out(t.ATTR_REVERSE) write_out(pad_or_clip_str(status, scr_width)) write_out(t.ATTR_NONE) schedule_refresh() status_draw_done() # buflist singleton: lists the open buffers in a left-hand pane buflist = [0, BUFLIST_WIDTH_START, cur_buf, cur_buf, cur_buf] buflist_vscroll = lambda: buflist[0] buflist_width = lambda: buflist[1] buflist_cursor = lambda: buflist[2] buflist_last = lambda: buflist[3] def buflist_set_width(w): schedule_redraw() if w > scr_width: w = scr_width - (scr_width - w) % BUFLIST_WIDTH_STEP buflist[1] = max(BUFLIST_WIDTH_MIN, w) def buflist_set_cursor(b): schedule_buflist_draw() buflist[2] = b def buflist_set_last(b): buflist[3] = b def buflist_select(b): global cur_buf schedule_buflist_draw() schedule_buf_draw() schedule_status_draw() if b is not cur_buf: buflist_set_last(cur_buf) # don't autoscroll a newly selected window (at_end will get restored on # draw if it's still at the end) buf_clr_at_end(b) buflist_set_cursor(b) cur_buf = b command('buflist-switch-next')( lambda: buflist_select( get_clipped(buffers, find_buf(cur_buf) + 1))) command('buflist-switch-prev')( lambda: buflist_select( get_clipped(buffers, find_buf(cur_buf) - 1))) command('buflist-last-selected')(lambda: buflist_select(buflist_last())) command('buflist-next')( lambda: buflist_set_cursor( get_clipped(buffers, find_buf(buflist_cursor()) + 1))) command('buflist-prev')( lambda: buflist_set_cursor( get_clipped(buffers, find_buf(buflist_cursor()) - 1))) command('buflist-top')(lambda: buflist_set_cursor(buffers[0])) command('buflist-bottom')(lambda: buflist_set_cursor(buffers[-1])) command('buflist-shrink')(lambda: buflist_set_width(buflist_width() - BUFLIST_WIDTH_STEP)) command('buflist-grow')(lambda: buflist_set_width(buflist_width() + BUFLIST_WIDTH_STEP)) @command('buflist-submit') def buflist_submit(): buflist_exit() buflist_select(buflist_cursor()) def buflist_draw(): schedule_refresh() top_row = 2 h = scr_height - 3 w = min(scr_width, buflist_width() - 1) cursor = buflist_cursor() def network_status(network): c = network_conns.get(network) if c is None: # not connected return ' ' sock = conn_sock(c) if sock is None: # down, awaiting reconnection return '~' fd = conn_sock(c).fileno() if fd in opening_conns: # DNS / TCP handshake return '~' if conn_registered(c): return '=' else: # TCP connected but unregistered (no WELCOME yet) return '-' def get_label(buf): parts = [] if buf is cur_buf: parts.append('*') elif buf is buflist_last(): parts.append('-') else: parts.append(' ') if buf_parent(buf) is None: if buf is buffers[0]: parts.append(' ') else: parts.append(network_status(buf_name(buf))) else: # channel/pm parts.append(' ') parts.append(asciify(buf_name(buf))) unread = buf_unread(buf) if unread > 0: parts.append(' +%d' % unread) label = ''.join(parts) if buf is cursor and buflist_flag(): return t.render((t.A_REVERSE, pad_or_clip_str(label, w))) else: return clip_str(label, w) write_out(t.cursor_pos(2, 1)) scroll = buflist_vscroll() for row in range(h): write_out(t.cursor_pos(top_row + row, w) + t.ERASE_LINE_FROM_START) i = scroll + row if i < len(buffers): write_out(CR + get_label(buffers[i])) buflist_draw_done() def buflist_vline_draw(): top_row = 2 height = scr_height - 3 column = buflist_width() if column > scr_width: return move_down = LF if column == scr_width else BS + LF write_out(t.cursor_pos(top_row, column)) write_out(SO) write_out(move_down.join(t.SGC_VBAR*height)) write_out(SI) command('prompt-enter')(sequence(schedule_refresh, prompt_enter)) command('prompt-exit')(sequence(schedule_refresh, prompt_exit)) command('buflist-enter')(sequence(schedule_buflist_draw, buflist_enter)) command('redraw')(schedule_redraw) # Terminal input state machine # Only valid control sequences per ECMA-48 5ed. sec. 5.4 are accepted. # Esc always cancels any sequence in progress and moves to ks_esc, to avoid # control sequences leaking through as text input. C-g always cancels and # returns to ks_start. kbd_accum = bytearray() kaccum = kbd_accum.extend def kaccept(sym=''): kaccum(sym) seq = str(kbd_accum) ktrans(ks_start) def try_keymap(km): cmd = km.get(seq) if cmd is None: return False run_command(cmd) return True try: if try_keymap(GLOBAL_KEYS): return elif buflist_flag(): try_keymap(BUFLIST_KEYS) elif prompt_flag(): if not try_keymap(PROMPT_KEYS): if len(seq) == 1 and not is_ctrl(seq): prompt_insert(seq) else: try_keymap(BUF_KEYS) except CommandError, e: error(e, cur_buf) def ktrans(state): global kbd_state kbd_state = state schedule_status_draw() if state in (ks_start, ks_esc): del kbd_accum[:] elif state is ks_cseq: del kbd_accum[:] kaccum(CSI) # States def ks_start(sym): if sym == C_X: kaccum(C_X) ktrans(ks_cx) elif sym == ESC: ktrans(ks_esc) else: kaccept(sym) def ks_cx(sym): if sym == C_G: ktrans(ks_start) elif sym == ESC: ktrans(ks_esc) else: kaccept(casemap_ascii(ctrl(sym)) if is_ctrl(sym) else sym) def ks_esc(sym): if sym == C_G: ktrans(ks_start) elif sym == ESC: pass elif sym == '[': ktrans(ks_cseq) else: kaccept(meta(sym)) def ks_cseq(sym): if sym == ESC: ktrans(ks_esc) elif '\x20' <= sym <= '\x2F': kaccum(sym) ktrans(ks_cs_intermed) elif '\x30' <= sym <= '\x3F': kaccum(sym) schedule_status_draw() elif '\x40' <= sym <= '\x7E': kaccept(sym) else: ktrans(ks_start) def ks_cs_intermed(sym): if sym == ESC: ktrans(ks_esc) elif '\x20' <= sym <= '\x2F': kaccum(sym) schedule_status_draw() elif '\x40' <= sym <= '\x7E': kaccept(sym) else: ktrans(ks_start) kbd_state = ks_start def buf_conn(buf): if buf is buffers[0]: raise CommandError('this window not associated with a network') parent = buf_parent(buf) network = buf_name(buf if parent is None else parent) try: return network_conns[network] except KeyError: raise CommandError('not connected to %s' % network) def buf_registered_conn(buf): c = buf_conn(buf) if conn_sock(c) is None: raise CommandError('connection to %s is down' % conn_network(c)) if not conn_registered(c): raise CommandError('connection to %s not registered' % conn_network(c)) return c @command('connect', 1, 4) def connect_cmd(*args): net = args[0] if net in network_conns: raise CommandError('connect: connection already active for %s' % net) if not safe_filename(net): raise CommandError('connect: bad network name: %s' % net) conf_paths = (('nets', net), ()) if len(args) > 1: addrs = [args[1]] else: addrs = config_lines(get_config('addrs', conf_paths)) if addrs is None: raise CommandError('connect: no addrs for network %s' % net) try: addrs = map(parse_address, addrs) except ValueError, e: raise CommandError('connect: %s' % e) if len(args) > 2: nick = args[2] else: nick = get_config('nick', conf_paths) if nick is None: raise CommandError('connect: no nick for %s' % net) if not valid_nick(nick): raise CommandError('connect: bad nick: %s' % nick) if len(args) > 3: pw = args[3] else: pw = get_config('pass', conf_paths) if not valid_password(pw): raise CommandError( 'connect: illegal character in password for %s' % net) conn_start(new_conn(net, addrs, nick, pw)) @command('disconnect', 0, 1) def disconnect_cmd(net=None): if net is None: c = buf_conn(cur_buf) net = conn_network(c) else: try: c = network_conns[net] except KeyError: raise CommandError('no connection for network %s' % net) del network_conns[net] conn_info(c, 'disconnected') conn_close(c) @command('join', 1, 2) def join_cmd(chan, key=None): if not valid_chan(chan): raise CommandError('join: bad channel name: %s' % chan) conn_join(buf_registered_conn(cur_buf), chan, key) @command('kick', 1, extended_arg=True) def kick_cmd(user, msg=''): chan = buf_name(cur_buf) if buf_parent(cur_buf) is None or not is_chan(chan): raise CommandError('kick: this window not a channel') conn_send(buf_registered_conn(cur_buf), 'KICK', [chan, user, msg]) @command('list', 0, 1) def list_cmd(*args): conn_send(buf_registered_conn(cur_buf), 'LIST', args) @command('me', extended_arg=True) def me_cmd(msg=''): buf_privmsg(cur_buf, '\x01ACTION %s\x01' % msg) @command('mode', 1, 3) def mode_cmd(*args): conn_send(buf_registered_conn(cur_buf), 'MODE', args) @command('msg', 1, extended_arg=True) def msg_cmd(target, msg=None): c = buf_registered_conn(cur_buf) if is_chan(target) and not valid_chan(target): raise CommandError('msg: bad channel name: %s' % target) if msg: conn_privmsg(c, target, msg) buflist_select(get_buf(target, conn_network(c))) @command('names', 0, 1) def names_cmd(*args): conn_send(buf_registered_conn(cur_buf), 'NAMES', args) @command('nick', 1) def nick_cmd(nick): c = buf_conn(cur_buf) if conn_sock(c) is None: schedule_status_draw() conn_set_nick(c, nick) conn_info(c, 'nick changed to %s for next reconnection' % nick) else: if not conn_registered(c): schedule_status_draw() conn_set_nick(c, nick) conn_info(c, 'nick changed to %s' % nick) conn_set_ping_ts(c, mono_time()) conn_run_in(c, PING_TIMEOUT, conn_reg_timeout) conn_send(c, 'NICK', [nick]) @command('part', extended_arg=True) def part_cmd(msg=''): chan = buf_name(cur_buf) if buf_parent(cur_buf) is None or not is_chan(chan): raise CommandError('part: this window not a channel') conn_send(buf_registered_conn(cur_buf), 'PART', [chan, msg]) @command('quit', extended_arg=True) def quit_cmd(*msg): schedule_quit() for c in open_conns.itervalues(): conn_send(c, 'QUIT', msg) @command('send', extended_arg=True) def send_cmd(line): if len(line) > MAX_MSG_LEN: raise CommandError('send: line too long') conn_write(buf_registered_conn(cur_buf), line) @command('topic', 0, 1) def topic_cmd(chan=None): if chan is None: chan = buf_name(cur_buf) if buf_parent(cur_buf) is None or not is_chan(chan): raise CommandError( 'topic: this window not a channel and none specified') conn_send(buf_registered_conn(cur_buf), 'TOPIC', [chan]) @command('set-topic', extended_arg=True) def set_topic_cmd(topic=''): chan = buf_name(cur_buf) if buf_parent(cur_buf) is None or not is_chan(chan): raise CommandError('set-topic: this window not a channel') conn_send(buf_registered_conn(cur_buf), 'TOPIC', [chan, topic]) @command('whois', 1) def whois_cmd(nick): conn_send(buf_registered_conn(cur_buf), 'WHOIS', [nick]) @command('whowas', 1) def whowas_cmd(nick): conn_send(buf_registered_conn(cur_buf), 'WHOWAS', [nick]) @command('close') def close_cmd(): parent = buf_parent(cur_buf) if parent is None: raise CommandError( "close: won't close a top-level window (try close-net)") venue = buf_name(cur_buf) try: c = buf_registered_conn(cur_buf) except CommandError: pass else: if venue in conn_channels(c): conn_send(c, 'PART', [venue, '']) del conn_channels(c)[venue] close_buf(cur_buf) @command('close-net') def close_net_cmd(): raise CommandError('stub') # TODO def place_cursor(): if buflist_flag(): write_out(t.cursor_pos(max(1, scr_height - 2), clip(1, scr_width, buflist_width() - 1))) elif prompt_flag(): write_out(t.cursor_pos(scr_height, prompt_cursor_column())) else: write_out(t.cursor_pos(max(1, scr_height - 2), max(1, scr_width))) def refresh_if_needed(): if redraw_flag(): write_out(t.ERASE_FULL) buf_draw(cur_buf) buflist_vline_draw() buflist_draw() status_draw() prompt_draw() redraw_done() else: if buf_draw_flag(): buf_draw(cur_buf) if buflist_draw_flag(): buflist_draw() if status_draw_flag(): status_draw() if prompt_draw_flag(): prompt_draw() if refresh_flag(): place_cursor() flush_out() refresh_done() # # IRC stuff # RPL_WELCOME = 1 RPL_NOTOPIC = 331 RPL_TOPIC = 332 RPL_NAMREPLY = 353 RPL_ENDOFNAMES = 366 ERR_NICKNAMEINUSE = 433 ERR_NICKCOLLISION = 436 ERR_NOTREGISTERED = 451 MAX_MSG_LEN = 510 MAX_CHAN_LEN = 50 MAX_NICK_LEN = 31 IRC_ILLEGAL = NUL + CR + LF CHAN_ILLEGAL = IRC_ILLEGAL + BEL + ' ,:' CHAN_START = '&#+!' IRC_SPECIAL = '[]\\`_^{|}' LETTER = char_range('AZ') + char_range('az') DIGIT = char_range('09') NICK_START = LETTER + IRC_SPECIAL NICK_BODY = NICK_START + DIGIT + '-' class ProtocolError(Exception): pass def build_msg(prefix, cmd, params): """Build an IRC wire message. Conditions caller must enforce: * No args may contain NUL, CR, or LF * Only last param may be empty, contain spaces, or start with : * Valid cmd * 15 parameters max """ parts = [] if prefix is not None: parts.append(':' + prefix) parts.append(cmd) if len(params): parts.extend(params[:-1]) parts.append(':' + params[-1]) return ' '.join(parts) def max_param_len(cmd, prefix=None): # colon before prefix + space after prefix prefix_len = 0 if prefix is None else len(prefix) + 2 # space after cmd + colon before last param return MAX_MSG_LEN - prefix_len - len(cmd) - 2 def parse_msg(msg): if any(c in msg for c in IRC_ILLEGAL): raise ProtocolError('illegal character in message') start = 0 end = len(msg) def next_word(): cut = msg.find(' ', start) if cut == -1: cut = end return cut + 1, msg[start:cut] if msg.startswith(':'): start = 1 start, prefix = next_word() else: prefix = None start, cmd = next_word() if not cmd: raise ProtocolError('message with no command') params = [] while start < end: if msg[start] == ':': params.append(msg[start+1:]) break start, param = next_word() params.append(param) return prefix, casemap_ascii(cmd), params is_chan = lambda n: len(n) > 0 and n[0] in CHAN_START def valid_chan(n): return is_chan(n) and len(n) <= MAX_CHAN_LEN \ and not any(c in CHAN_ILLEGAL for c in n) def valid_nick(n): return 0 < len(n) <= MAX_NICK_LEN \ and n[0] in NICK_START \ and all(c in NICK_BODY for c in n[1:]) def valid_password(p): return p is None or ( len(p) <= max_param_len('PASS') and not any(c in IRC_ILLEGAL for c in p)) sender_nick = lambda s: s.split('!', 1)[0] # # Networking / main loop # set_nonblock = lambda fd: fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK) def read_all(fd): chunks = [] try: chunk = read(fd, 4096) if not chunk: raise EOFError while chunk: chunks.append(chunk) chunk = read(fd, 4096) except EnvironmentError, e: if e.errno not in (EAGAIN, EWOULDBLOCK): raise return ''.join(chunks) def new_conn(network, addrs, nick, password=None): i = rand_int(len(addrs)) addrs = addrs[i:] + addrs[:i] return [network, None, '', '', addrs, nick, password, False, dict(), IRC_CASEMAP['rfc1459'], RECONN_DELAY_MIN, 0, 0, 0] conn_network = lambda c: c[0] conn_sock = lambda c: c[1] conn_rdbuf = lambda c: c[2] conn_wrbuf = lambda c: c[3] conn_addrs = lambda c: c[4] conn_nick = lambda c: c[5] conn_password = lambda c: c[6] conn_registered = lambda c: c[7] conn_channels = lambda c: c[8] conn_casemap = lambda c, s: c[9](s) conn_count = lambda c: c[11] conn_ping_ts = lambda c: c[12] conn_pong_ts = lambda c: c[13] conn_set_sock = lambda c, s: c.__setitem__(1, s) conn_rdbuf_add = lambda c, b: c.__setitem__(2, c[2] + b) conn_rdbuf_rm = lambda c, n: c.__setitem__(2, c[2][n:]) conn_wrbuf_add = lambda c, b: c.__setitem__(3, c[3] + b) conn_wrbuf_rm = lambda c, n: c.__setitem__(3, c[3][n:]) conn_set_nick = lambda c, n: c.__setitem__(5, n) conn_set_registered = lambda c: c.__setitem__(7, True) conn_clr_registered = lambda c: c.__setitem__(7, False) conn_reset_reconn_delay = lambda c: c.__setitem__(10, RECONN_DELAY_MIN) conn_set_count = lambda c, n: c.__setitem__(11, n) conn_set_ping_ts = lambda c, t: c.__setitem__(12, t) conn_set_pong_ts = lambda c, t: c.__setitem__(13, t) def conn_reconn_delay(c): # limited exponential backoff d = c[10] c[10] = min(2*d, RECONN_DELAY_MAX) return d conn_nick_lc = lambda c: conn_casemap(c, conn_nick(c)) def conn_run_in(c, delay, method, run_if_down=False): count = conn_count(c) def run(): # Drop leftover tasks from old connections if c is network_conns.get(conn_network(c)) and conn_count(c) == count \ and (run_if_down or conn_sock(c) is not None): method(c) run_in(delay, run) def conn_log_msg(c, venue, m): network = conn_network(c) if venue is None: buf = get_buf(network, None) else: buf = get_buf(venue, network) buf_log_msg(buf, m) file_log_msg(network, venue, m) def conn_info(c, msg): conn_log_msg(c, None, m_client(msg)) def conn_error(c, msg): conn_log_msg(c, None, m_client('ERROR: ' + msg)) opening_conns = {} network_conns = {} def conn_start(c): schedule_buflist_draw() schedule_status_draw() net = conn_network(c) assert conn_sock(c) is None, 'socket exists when starting connection' sock = socket() set_nonblock(sock.fileno()) conn_set_sock(c, sock) conn_set_count(c, conn_count(c) + 1) addrs = conn_addrs(c) addrs.append(addrs.pop(0)) conn_info(c, 'connecting to %s' % format_address(addrs[0])) network_conns[net] = opening_conns[sock.fileno()] = c try: sock.connect(addrs[0]) # TODO async DNS except SockError, e: if e.errno != EINPROGRESS: del opening_conns[sock.fileno()] conn_error(c, e.strerror) else: conn_handle_connected(c) def conn_write(c, msg): if len(msg) > MAX_MSG_LEN: msg = msg[:MAX_MSG_LEN] conn_error(c, 'outbound message truncated') conn_wrbuf_add(c, msg + CRLF) def conn_send(c, cmd, params, prefix=None): conn_write(c, build_msg(prefix, cmd, params)) open_conns = {} def conn_handle_connected(c): n = conn_nick(c) p = conn_password(c) s = conn_sock(c) e = s.getsockopt(SOL_SOCKET, SO_ERROR) if e == EINPROGRESS: return schedule_buflist_draw() schedule_status_draw() del opening_conns[s.fileno()] if e != 0: conn_error(c, strerror(e)) conn_close(c) return conn_reset_reconn_delay(c) open_conns[s.fileno()] = c conn_info(c, 'connection established') conn_set_ping_ts(c, mono_time()) conn_run_in(c, PING_TIMEOUT, conn_reg_timeout) if p is not None: conn_send(c, 'PASS', [p]) conn_send(c, 'NICK', [n]) conn_send(c, 'USER', [n, '0', '*', n]) def conn_close(c): sock = conn_sock(c) if sock is None: return schedule_buflist_draw() schedule_status_draw() fd = sock.fileno() if fd in open_conns: del open_conns[fd] elif fd in opening_conns: del opening_conns[fd] try: sock.shutdown(SHUT_RDWR) except SockError: pass sock.close() conn_set_sock(c, None) conn_rdbuf_rm(c, len(conn_rdbuf(c))) conn_wrbuf_rm(c, len(conn_wrbuf(c))) conn_clr_registered(c) conn_info(c, 'connection closed') if conn_network(c) in network_conns: delay = conn_reconn_delay(c) conn_run_in(c, delay, conn_start, True) conn_info(c, 'reconnecting in %d seconds' % delay) def conn_handle_data(c, data): conn_rdbuf_add(c, data) data = conn_rdbuf(c) start = 0 while start < len(data): end = data.find(CRLF, start) if end == -1: if len(data) - start >= MAX_MSG_LEN: conn_error(c, 'received oversize message') conn_close(c) return break if end > start: try: conn_handle_msg(c, data[start:end]) except ProtocolError, e: conn_error(c, e) start = end + 2 conn_rdbuf_rm(c, start) def conn_handle_msg(c, msg): #pylint: disable=unbalanced-tuple-unpacking,too-many-locals prefix, cmd, params = parse_msg(msg) def welcome(): if destination != conn_nick(c): # for pre-welcome nick change schedule_status_draw() conn_info(c, 'nick changed to %s' % destination) conn_set_nick(c, destination) conn_log_msg(c, None, m_server(prefix or '', ' '.join(params))) if not conn_registered(c): schedule_buflist_draw() conn_set_registered(c) pong() for chan in conn_channels(c): conn_join(c, chan) conn_channels(c).clear() def names_reply(): if len(params) != 3: conn_error(c, 'RPL_NAMREPLY with bad parameter count: %s' % msg) return _, chan, names = params chan_lc = conn_casemap(c, chan) members = conn_channels(c).get(chan_lc) conn_log_msg(c, None if members is None else chan_lc, m_names(prefix or '', chan, names)) if members is not None: for nick in names.split(' '): if not nick: conn_error(c, 'RPL_NAMREPLY with empty nick') break if nick[0] in '@+': nick = nick[1:] members.add(conn_casemap(c, nick)) def end_names(): if len(params) != 2: conn_error(c, 'RPL_ENDOFNAMES with bad parameter count: %s' % msg) return chan = params[0] chan_lc = conn_casemap(c, chan) conn_log_msg(c, chan_lc if chan_lc in conn_channels(c) else None, m_endnames(prefix or '', chan)) def error_msg(): if len(params) != 1: conn_error(c, 'ERROR with bad parameter count: %s' % msg) return conn_log_msg(c, None, m_error(prefix or '', params[0])) def ping(): conn_send(c, 'PONG', params) def pong(): schedule_status_draw() conn_set_pong_ts(c, mono_time()) conn_run_in(c, PING_INTERVAL, conn_ping) def privmsg(): if len(params) != 2: conn_error(c, 'message with bad parameter count: %s' % msg) return target, content = params target_lc = conn_casemap(c, target) if prefix is None: conn_error(c, 'message without sender: %s' % msg) return sender = sender_nick(prefix) if target_lc == conn_nick_lc(c): # PM venue = conn_casemap(c, sender) elif valid_chan(target_lc): if target_lc in conn_channels(c): venue = target_lc else: return # drop messages to unknown channels elif target_lc == '*': # not sure if standard but freenode does this venue = None else: conn_error(c, 'message to unknown target: %s' % msg) return conn_log_msg(c, venue, (m_notice if cmd == 'notice' else m_privmsg)(sender, content)) def join(): if len(params) != 1: conn_error(c, 'JOIN with bad parameter count: %s' % msg) return chan, = params chan_lc = conn_casemap(c, chan) if prefix is None: conn_error(c, 'JOIN without sender: %s' % msg) return sender_lc = conn_casemap(c, sender_nick(prefix)) channels_dict = conn_channels(c) if sender_lc == conn_nick_lc(c): if chan_lc in channels_dict: conn_error(c, 'JOIN to already joined channel %s' % chan) return channels_dict[chan_lc] = set() else: if chan_lc not in channels_dict: conn_error(c, 'JOIN %s to unknown channel %s' % (prefix, chan)) return channels_dict[chan_lc].add(sender_lc) conn_log_msg(c, chan_lc, m_join(prefix, chan)) def mode(): if len(params) < 1: conn_error(c, 'MODE with bad parameter count: %s' % msg) return target = params[0] modes = ' '.join(params[1:]) target_lc = conn_casemap(c, target) if prefix is None: conn_error(c, 'MODE without sender: %s' % msg) return if is_chan(target_lc): if target_lc not in conn_channels(c): conn_error(c, 'MODE to unknown channel: %s' % msg) return conn_log_msg(c, target_lc, m_chanmode(prefix, target, modes)) else: if not target_lc == prefix == conn_nick(c): conn_error(c, 'MODE to unknown target: %s' % msg) return conn_log_msg(c, None, m_mode(prefix, modes)) def part(): if len(params) == 1: comment = '' elif len(params) == 2: comment = params[1] else: conn_error(c, 'PART with bad parameter count: %s' % msg) return parted_chans = params[0].split(',') if prefix is None: conn_error(c, 'PART without sender: %s' % msg) return sender_lc = conn_casemap(c, sender_nick(prefix)) channels_dict = conn_channels(c) me = (sender_lc == conn_nick_lc(c)) for chan in parted_chans: chan_lc = conn_casemap(c, chan) if chan_lc not in channels_dict: # drop PARTs from unknown channels (e.g. closed window) continue try: if me: del channels_dict[chan_lc] conn_info(c, 'parted %s' % chan) schedule_status_draw() else: channels_dict[chan_lc].remove(sender_lc) except KeyError: conn_error(c, 'PART non-member %s from %s' % (prefix, chan)) continue conn_log_msg(c, chan_lc, m_part(prefix, chan, comment)) def quit_msg(): if len(params) != 1: conn_error(c, 'QUIT with bad parameter count: %s' % msg) return quit_msg, = params if prefix is None: conn_error(c, 'QUIT without sender [%s]' % quit_msg) return sender_lc = conn_casemap(c, sender_nick(prefix)) for chan_lc, members in conn_channels(c).items(): if sender_lc in members: conn_log_msg(c, chan_lc, m_quit(prefix, quit_msg)) members.remove(sender_lc) def kick(): if len(params) < 2 or len(params) > 3: conn_error(c, 'KICK with bad parameter count: %s' % msg) return if prefix is None: conn_error(c, 'KICK without sender: %s' % msg) return chan = params[0] chan_lc = conn_casemap(c, chan) kicked_user = params[1] kicked_user_lc = conn_casemap(c, kicked_user) comment = params[2] if len(params) == 3 else '' channels_dict = conn_channels(c) if kicked_user_lc == conn_nick_lc(c): try: del channels_dict[chan_lc] except KeyError: conn_error(c, 'KICK from unknown channel %s by %s [%s]' % (chan, prefix, comment)) return schedule_status_draw() conn_log_msg(c, chan_lc, m_kicked(prefix, chan, comment)) else: if chan_lc not in channels_dict: conn_error(c, 'KICK %s from unknown channel %s by %s [%s]' % (kicked_user, chan, prefix, comment)) return try: channels_dict[chan_lc].remove(kicked_user_lc) except KeyError: conn_error(c, 'KICK non-member %s from %s by %s [%s]' % (kicked_user, chan, prefix, comment)) return conn_log_msg(c, chan_lc, m_kick(prefix, chan, kicked_user, comment)) def nick(): if len(params) != 1: conn_error(c, 'NICK with bad parameter count: %s' % msg) return new_nick, = params if prefix is None: conn_error(c, 'NICK without sender: %s' % msg) return sender = sender_nick(prefix) new_nick_lc = conn_casemap(c, new_nick) sender_lc = conn_casemap(c, sender) if sender_lc == conn_nick_lc(c): conn_info(c, 'nick changed to %s' % new_nick) conn_set_nick(c, new_nick) schedule_status_draw() for chan_lc, members in conn_channels(c).items(): if sender_lc in members: members.remove(sender_lc) members.add(new_nick_lc) conn_log_msg(c, chan_lc, m_nick(sender, new_nick)) def topic(): if len(params) != 2: conn_error(c, '(RPL_(NO))TOPIC with bad parameter count: %s' % msg) chan = params[0] topic = None if cmd != str(RPL_NOTOPIC): topic = params[1] chan_lc = conn_casemap(c, chan) if chan_lc in conn_channels(c): buf_set_title(get_buf(chan_lc, conn_network(c)), topic or chan_lc) conn_log_msg(c, chan_lc, m_topic(prefix or '', topic)) else: conn_log_msg(c, None, m_chantopic(prefix or '', chan, topic)) def unknown_command(): conn_info(c, 'unknown command from server: %s' % msg) def unknown_reply(): conn_log_msg(c, None, m_server(prefix or '', ' '.join([cmd] + params))) if len(cmd) == 3 and all(map(is_digit, cmd)): # Replies if not params: conn_error(c, 'reply %s with no destination' % cmd) return destination = params.pop(0) { RPL_WELCOME: welcome, RPL_NAMREPLY: names_reply, RPL_ENDOFNAMES: end_names, RPL_TOPIC: topic, RPL_NOTOPIC: topic, #ERR_NICKNAMEINUSE: #ERR_NICKCOLLISION: #ERR_NOTREGISTERED: }.get(int(cmd), unknown_reply)() else: { 'error': error_msg, 'ping': ping, 'pong': pong, 'privmsg': privmsg, 'notice': privmsg, 'join': join, 'mode': mode, 'part': part, 'quit': quit_msg, 'kick': kick, 'nick': nick, 'topic': topic, }.get(cmd, unknown_command)() def conn_join(c, chan, key=None): conn_info(c, 'joining %s' % chan) conn_send(c, 'JOIN', [chan] if key is None else [chan, key]) def conn_privmsg(c, target, msg): # There is NO SANE WAY to deduce the max privmsg length, go figure. for line in wrap(msg, 400): conn_log_msg(c, target, m_privmsg(conn_nick(c), line)) conn_send(c, 'PRIVMSG', [target, line]) def conn_ping(c): conn_set_ping_ts(c, mono_time()) conn_send(c, 'PING', [conn_nick(c)]) conn_run_in(c, PING_TIMEOUT, conn_timeout) def conn_timeout(c): if mono_time() - conn_pong_ts(c) > PING_TIMEOUT: conn_error(c, 'ping timed out') conn_close(c) def conn_reg_timeout(c): if mono_time() - max(conn_pong_ts(c), conn_ping_ts(c)) > PING_TIMEOUT: conn_error(c, 'registration timed out') conn_close(c) # Log file message formatting is simpler than UI as the message type and # sender are always shown explicitly. arg2 = lambda a, b: b arg3 = lambda a, b, c: c empty2 = lambda a, b: '' def lterr(*_): raise TypeError('bad log message type') format_log_msg = matcher(message, ( (m_privmsg, arg2), (m_notice, arg2), (m_join, empty2), (m_part, arg3), (m_quit, arg2), (m_nick, arg2), (m_kick, lambda sender, chan, name, msg: name + ' ' + msg), (m_kicked, arg3), (m_topic, lambda sender, topic: topic or ''), (m_chantopic, lterr), (m_mode, lterr), (m_chanmode, arg3), (m_names, arg3), (m_endnames, empty2), (m_error, arg2), (m_client, lterr), (m_server, arg2))) def file_log_msg(network, venue, m): if venue is None or len(venue) == 0: return path = ('logs', network, fs_encode(venue) + '.log') # Possible optimization: cache log FDs and check freshness by comparing # (dev, inode) with stat() on each write. Exceeding the max FDs rlimit # would need to be handled, e.g. by LRU eviction using a minheap with # global write counter. def open_log(): return os_open('/'.join(path), O_WRONLY | O_APPEND | O_CREAT, 0666) try: try: fd = open_log() except EnvironmentError, e: if e.errno != ENOENT: raise try: mkdir(path[0]) except EnvironmentError, e: if e.errno != EEXIST: raise mkdir('/'.join(path[:2])) fd = open_log() try: write_all(fd, ''.join(( str(int(time()*100)), ' ', variant_name(m), ' <', variant_args(m)[0], '> ', format_log_msg(m), LF))) except EnvironmentError, e: error(e) close(fd) except EnvironmentError, e: error(e) def handle_resize(*_): global scr_height, scr_width scr_height, scr_width = t.screen_size() schedule_redraw() try: write(self_pipe_wr, '\0') except EnvironmentError, e: if e.errno not in (EAGAIN, EWOULDBLOCK): raise # Poor man's monotonic clock. Doesn't give real time, but at least avoids # overly delayed events if the clock is stepped backward. mono_last = mono_offset = 0 def mono_time(time_=time): global mono_last, mono_offset now = time_() + mono_offset if now < mono_last: mono_offset += mono_last - now now = mono_last mono_last = now return now schedule = [] run_in = lambda seconds, thunk: heap_insert(schedule, mono_time() + seconds, thunk) def main(): yrc_home = getenv('HOME', '') + '/.yrc' try: chdir(yrc_home) except EnvironmentError: mkdir(yrc_home) chdir(yrc_home) set_nonblock(self_pipe_rd) set_nonblock(self_pipe_wr) signal(SIGINT, SIG_DFL) signal(SIGWINCH, handle_resize) handle_resize() info('Welcome to yrc: the Unix IRC client') info('Version: %s' % __version__) info('Documentation is included in the distribution: refer to README and ' + "manual.txt to get started, or NEWS for what's new in this release") info('Type /quit to exit') prompt_enter() while not quit_flag(): refresh_if_needed() timeout = None if schedule: timeout = max(0, heap_peek(schedule)[0] - mono_time()) try: readers, writers, _ = select( [STDIN, self_pipe_rd] + open_conns.keys(), opening_conns.keys() + [fd for fd, c in open_conns.items() if conn_wrbuf(c)], (), timeout) except SelectError, e: if e[0] == EINTR: continue raise for fd in readers: if fd == STDIN: try: data = read_all(fd) except (EOFError, EnvironmentError): schedule_quit() for c in data: kbd_state(c) elif fd == self_pipe_rd: read_all(self_pipe_rd) elif fd in open_conns: c = open_conns[fd] try: data = read_all(fd) except EOFError: conn_info(c, 'connection closed by server') conn_close(c) except EnvironmentError, e: conn_error(c, str(e)) conn_close(c) else: conn_handle_data(c, data) for fd in writers: if fd in opening_conns: conn_handle_connected(opening_conns[fd]) elif fd in open_conns: c = open_conns[fd] try: n = write(fd, conn_wrbuf(c)) except EnvironmentError, e: if e.errno not in (EAGAIN, EWOULDBLOCK): conn_error(c, str(e)) conn_close(c) else: conn_wrbuf_rm(c, n) while schedule and heap_peek(schedule)[0] < mono_time(): heap_extract(schedule)[1]() def crash_handler(): cleanup = t.init(nonblock_read=True) try: main() finally: cleanup() check_command_dicts(( GLOBAL_KEYS, PROMPT_KEYS, BUFLIST_KEYS, BUF_KEYS, CMD_ABBREVS ))