Projects : yrc : yrc_minor_command_reorder

yrc/yrc.py

Dir - Raw

1"""
2yrc: the Unix IRC client
3Jacob Welsh
4February 2017 - June 2019
5"""
6
7__version__ = '96 Kelvin'
8
9# Knobs that one might conceivably want to tweak
10DEFAULT_PORT = 6667
11RECONN_DELAY_MIN = 4 # seconds
12RECONN_DELAY_MAX = 256
13PING_INTERVAL = 120
14PING_TIMEOUT = 240
15BUFLIST_WIDTH_MIN = len('* =#a$|')
16BUFLIST_WIDTH_STEP = 4
17BUFLIST_WIDTH_START = BUFLIST_WIDTH_MIN + 3*BUFLIST_WIDTH_STEP
18format_time = lambda tt: '%02d:%02d:%02d' % (tt.tm_hour, tt.tm_min, tt.tm_sec)
19
20from os import (
21 O_APPEND, O_CREAT, O_NONBLOCK, O_WRONLY, chdir, close, getenv, mkdir,
22 open as os_open, pipe, read, strerror, write
23)
24from time import localtime, time
25from errno import EAGAIN, EWOULDBLOCK, EEXIST, EINPROGRESS, EINTR, ENOENT
26from select import select, error as SelectError
27from signal import signal, SIG_DFL, SIGINT, SIGWINCH
28from socket import socket, SOL_SOCKET, SO_ERROR, SHUT_RDWR, error as SockError
29
30from fcntl import fcntl, F_GETFL, F_SETFL
31
32import yterm as t
33from yterm import NUL, BS, LF, CR, SO, SI, ESC, CSI, cseq
34STDIN = 0
35STDOUT = 1
36self_pipe_rd, self_pipe_wr = pipe()
37
38is_ctrl = lambda c: c < '\x20' or c == '\x7f'
39ctrl = lambda c: chr(ord(c) ^ 0x40)
40
41meta = lambda c: ESC + c
42
43BEL = chr(7)
44CRLF = CR + LF
45C_X = ctrl('X')
46C_G = ctrl('G') # aka BEL
47
48# Cursor control keys
49LEFT = t.CURSOR_BACK1
50RIGHT = t.CURSOR_FORWARD1
51UP = t.CURSOR_UP1
52DOWN = t.CURSOR_DOWN1
53HOME = t.CURSOR_HOME
54# Not sure where these originate; vt220 perhaps? Seen on xterm, linux, screen
55END = cseq('F')
56INS = cseq('~', 2)
57DEL = cseq('~', 3)
58PGUP = cseq('~', 5)
59PGDN = cseq('~', 6)
60# Likewise; so far seen on linux, screen
61LNX_HOME = cseq('~', 1)
62LNX_END = cseq('~', 4)
63
64GLOBAL_KEYS = {
65 C_X + 'n': 'buflist-switch-next',
66 C_X + 'p': 'buflist-switch-prev',
67 C_X + 'l': 'buflist-last-selected',
68 C_X + 'w': 'buflist-enter',
69 ctrl('L'): 'redraw',
70}
71
72PROMPT_KEYS = {
73 CR: 'prompt-submit',
74
75 INS: 'prompt-exit',
76
77 ctrl('F'): 'prompt-forward',
78 RIGHT: 'prompt-forward',
79
80 ctrl('B'): 'prompt-backward',
81 LEFT: 'prompt-backward',
82
83 ctrl('A'): 'prompt-start',
84 HOME: 'prompt-start',
85 LNX_HOME: 'prompt-start',
86
87 ctrl('E'): 'prompt-end',
88 END: 'prompt-end',
89 LNX_END: 'prompt-end',
90
91 ctrl('H'): 'prompt-backspace',
92 ctrl('?'): 'prompt-backspace',
93
94 ctrl('D'): 'prompt-delete',
95 DEL: 'prompt-delete',
96
97 meta('v'): 'scroll-up-page',
98 PGUP: 'scroll-up-page',
99
100 ctrl('V'): 'scroll-down-page',
101 PGDN: 'scroll-down-page',
102
103 meta('<'): 'scroll-top',
104
105 meta('>'): 'scroll-bottom',
106}
107
108BUFLIST_KEYS = {
109 CR: 'buflist-submit',
110
111 ctrl('N'): 'buflist-next',
112 DOWN: 'buflist-next',
113 'j': 'buflist-next',
114
115 ctrl('P'): 'buflist-prev',
116 UP: 'buflist-prev',
117 'k': 'buflist-prev',
118
119 meta('<'): 'buflist-top',
120 HOME: 'buflist-top',
121 LNX_HOME: 'buflist-top',
122 'g': 'buflist-top',
123
124 meta('>'): 'buflist-bottom',
125 END: 'buflist-bottom',
126 LNX_END: 'buflist-bottom',
127 'G': 'buflist-bottom',
128
129 'h': 'buflist-shrink',
130 LEFT: 'buflist-shrink',
131
132 'l': 'buflist-grow',
133 RIGHT: 'buflist-grow',
134}
135
136BUF_KEYS = {
137 'i': 'prompt-enter',
138 INS: 'prompt-enter',
139
140 meta('v'): 'scroll-up-page',
141 PGUP: 'scroll-up-page',
142 ctrl('B'): 'scroll-up-page',
143 'b': 'scroll-up-page',
144
145 ctrl('V'): 'scroll-down-page',
146 PGDN: 'scroll-down-page',
147 ctrl('F'): 'scroll-down-page',
148 'f': 'scroll-down-page',
149 ' ': 'scroll-down-page',
150
151 ctrl('P'): 'scroll-up-line',
152 UP: 'scroll-up-line',
153 'k': 'scroll-up-line',
154
155 ctrl('N'): 'scroll-down-line',
156 DOWN: 'scroll-down-line',
157 'j': 'scroll-down-line',
158
159 meta('<'): 'scroll-top',
160 HOME: 'scroll-top',
161 LNX_HOME: 'scroll-top',
162 'g': 'scroll-top',
163
164 meta('>'): 'scroll-bottom',
165 END: 'scroll-bottom',
166 LNX_END: 'scroll-bottom',
167 'G': 'scroll-bottom',
168}
169
170CMD_ABBREVS = {
171 'c': 'connect',
172 'd': 'disconnect',
173 'j': 'join',
174 'k': 'kick',
175 'l': 'list',
176 'm': 'mode',
177 'n': 'nick',
178 'nam': 'names',
179 'p': 'part',
180 'q': 'quit',
181 's': 'send',
182 't': 'topic',
183 'st': 'set-topic',
184 'w': 'whois',
185 'ww': 'whowas',
186 'x': 'close',
187 'xn': 'close-net',
188}
189
190# file.write can barf up EINTR and unclear how to retry due to buffering. Nuts.
191def write_all(fd, s):
192 n = 0
193 while n < len(s):
194 try:
195 n += write(fd, s[n:])
196 except EnvironmentError, e:
197 if e.errno != EINTR:
198 raise
199
200out_buf = bytearray()
201write_out = out_buf.extend
202def flush_out():
203 write_all(STDOUT, out_buf)
204 del out_buf[:]
205
206# MLtronics
207def variant(vtype, name, nargs):
208 tag = (vtype, len(vtype), name)
209 def cons(*args):
210 if len(args) != nargs:
211 raise TypeError('%s takes %d args (%d given)'
212 % (name, nargs, len(args)))
213 return (tag, args)
214 vtype.append((name, cons))
215 return cons
216
217variant_name = lambda val: val[0][2]
218variant_args = lambda val: val[1]
219
220def matcher(vtype, cases):
221 def receiver(name, cons):
222 for case, recv in cases:
223 if case is cons:
224 return recv
225 raise TypeError('missing case %s' % name)
226 tbl = [receiver(name, cons) for name, cons in vtype]
227 def match(val):
228 tag, args = val
229 if tag[0] is not vtype:
230 raise TypeError
231 return tbl[tag[1]](*args)
232 return match
233
234message = []
235m_privmsg = variant(message, 'PRIVMSG', 2)
236m_notice = variant(message, 'NOTICE', 2)
237m_join = variant(message, 'JOIN', 2)
238m_part = variant(message, 'PART', 3)
239m_quit = variant(message, 'QUIT', 2)
240m_nick = variant(message, 'NICK', 2)
241m_kick = variant(message, 'KICK', 4) # kick of specified user
242m_kicked = variant(message, 'KICKED', 3) # kick of self
243m_topic = variant(message, 'TOPIC', 2) # unifies TOPIC and RPL_(NO)TOPIC
244m_chantopic = variant(message, 'CHANTOPIC', 3) # for unjoined channels
245m_mode = variant(message, 'MODE', 2)
246m_chanmode = variant(message, 'CHANMODE', 3)
247m_names = variant(message, 'NAMES', 3)
248m_endnames = variant(message, 'ENDNAMES', 2)
249m_error = variant(message, 'ERROR', 2) # ERROR from server
250m_client = variant(message, 'CLIENT', 1) # generated by client, not logged
251m_server = variant(message, 'SERVER', 2) # catch-all for other server msgs
252
253scr_height = None
254scr_width = None
255
256def sequence(*thunks):
257 def run():
258 for f in thunks:
259 f()
260 return run
261
262def char_range(pair):
263 a, b = pair
264 return ''.join(map(chr, range(ord(a), ord(b) + 1)))
265
266def partition(l, pred):
267 left = []
268 right = []
269 for elem in l:
270 if pred(elem):
271 left.append(elem)
272 else:
273 right.append(elem)
274 return left, right
275
276def split_pair(s, sep=' '):
277 pair = s.split(sep, 1)
278 if len(pair) == 1:
279 pair.append('')
280 return pair
281
282def make_encoder(f):
283 tbl = [f(i) for i in range(0x100)]
284 return lambda s: ''.join(tbl[ord(c)] for c in s)
285
286# Non-invertible terminal sanitizing
287asciify = make_encoder(lambda i:
288 '^' + ctrl(chr(i)) if i < 0x20 or i == 0x7f
289 else chr(i) if 0x20 <= i <= 0x7e
290 else '\\x%X' % i)
291
292# Uglier, injective encoding for file names
293fs_encode = make_encoder(lambda i:
294 chr(i) if 0x20 <= i <= 0x7e and chr(i) not in './%'
295 else '%' + ('%02X' % i))
296
297make_casemapper = lambda upper_bound: make_encoder(lambda i:
298 chr(i + 0x20) if 0x41 <= i <= upper_bound
299 else chr(i))
300
301# We currently support only the original IRC case mapping (as implemented, not
302# as specified; one of many howlers in the RFC). The RPL_ISUPPORT reply that
303# some servers use to advertise alternate mappings was specified in a draft
304# long since expired
305# (http://www.irc.org/tech_docs/draft-brocklesby-irc-isupport-03.txt). Besides,
306# a case-insensitive protocol that can't agree on what "case" means is
307# preposterous. This is an IRC client; if a server does not behave like an IRC
308# server, it doesn't fall to us to decipher its nonsense.
309
310IRC_CASEMAP = {'rfc1459': make_casemapper(0x5e)}
311
312casemap_ascii = make_casemapper(0x5a)
313
314clip = lambda min_, max_, x: max(min_, min(max_, x))
315clip_to = lambda l, i: clip(0, len(l) - 1, i)
316get_clipped = lambda l, i: l[clip_to(l, i)]
317
318def clip_str(s, width):
319 if width == 0:
320 return ''
321 if len(s) > width:
322 return '%s$' % s[:width - 1]
323 return s
324
325def pad_or_clip_str(s, width, pad=' '):
326 if len(s) < width:
327 return s + pad*(width - len(s))
328 return clip_str(s, width)
329
330def wrap(line, width, sep=' '):
331 width = max(1, width)
332 start = 0
333 lines = []
334 while start + width < len(line):
335 end = start + width
336 cut = line.rfind(sep, start, end + 1)
337 if cut == -1:
338 lines.append(line[start:end])
339 start = end
340 else:
341 lines.append(line[start:cut])
342 start = cut + 1
343 lines.append(line[start:])
344 return lines
345
346def is_digit(c):
347 return '0' <= c <= '9'
348
349def parse_address(addr):
350 addr = addr.rsplit(':', 1)
351 host = addr[0]
352 if len(addr) == 1:
353 port = DEFAULT_PORT
354 else:
355 port = addr[1]
356 if not all(map(is_digit, port)):
357 raise ValueError('port not a positive integer: %r' % port)
358 port = int(port)
359 if port >= 2**16:
360 raise ValueError('port out of range: %d' % port)
361 return host, port
362
363format_address = lambda host_port: '%s:%d' % host_port
364
365int_of_bytes = lambda b: reduce(lambda acc, byte: acc*256 + ord(byte), b, 0)
366
367# int.bit_length() added in Python 2.7
368bit_length = lambda i: len(bin(i).lstrip('-0b'))
369
370def rand_int(n):
371 """Get a random integer from 0 (inclusive) to n (exclusive) using the
372 system's nonblocking entropy pool. More-or-less deterministic run time, at
373 the cost of a small modulo bias."""
374 nbytes = (bit_length(n) + 7) / 8
375 with open('/dev/urandom', 'rb') as f:
376 return int_of_bytes(f.read(2*nbytes)) % n
377
378#
379# Binary min-heap, used as priority queue for scheduling
380#
381
382def heap_insert(h, key, value):
383 h.append((key, value))
384 # Percolate up
385 i = len(h) - 1
386 while i > 0:
387 i_parent = (i - 1)/2
388 item = h[i]
389 parent = h[i_parent]
390 if parent[0] <= item[0]:
391 break
392 h[i] = parent
393 h[i_parent] = item
394 i = i_parent
395
396heap_peek = lambda h: h[0]
397
398def heap_extract(h):
399 if len(h) == 1:
400 return h.pop()
401 result = h[0]
402 h[0] = h.pop()
403 # Percolate down
404 i = 0
405 while True:
406 i_child = 2*i + 1
407 if i_child >= len(h):
408 break
409 i_right = i_child + 1
410 if i_right < len(h) and h[i_right][0] < h[i_child][0]:
411 i_child = i_right
412 item = h[i]
413 child = h[i_child]
414 if item[0] <= child[0]:
415 break
416 h[i] = child
417 h[i_child] = item
418 i = i_child
419 return result
420
421# purely for testing
422def heapsort(iterable):
423 h = []
424 for item in iterable:
425 heap_insert(h, item, None)
426 while h:
427 yield heap_extract(h)[0]
428
429#
430# Config
431#
432
433def safe_filename(name):
434 return '/' not in name and name not in ('', '.', '..')
435
436def get_config(key, paths=(()), default=None):
437 assert safe_filename(key)
438 for path in paths:
439 assert all(map(safe_filename, path))
440 fspath = []
441 fspath.extend(path)
442 fspath.append(key)
443 try:
444 with open('/'.join(fspath), 'rb') as f:
445 return f.read().rstrip('\n')
446 except EnvironmentError, e:
447 if e.errno != ENOENT:
448 error(e)
449 return default
450
451def config_lines(text):
452 if text is None:
453 return None
454 lines = text.split('\n')
455 lines = [l.strip() for l in lines if not l.startswith('#')]
456 lines = [l for l in lines if len(l) > 0]
457 return lines if lines else None
458
459#
460# UI stuff
461#
462
463class CommandError(Exception):
464 pass
465
466commands = {}
467def command(name, min_args=0, max_args=None, extended_arg=False):
468 if max_args is None:
469 max_args = min_args
470 def register(func):
471 if name in commands:
472 raise ValueError('duplicate command %s' % name)
473 def run(arg_str=''):
474 args = []
475 while len(args) != max_args:
476 arg, arg_str = split_pair(arg_str.lstrip())
477 if arg == '':
478 break
479 args.append(arg)
480 arg_str = arg_str.lstrip()
481 if extended_arg:
482 args.append(arg_str)
483 elif arg_str:
484 raise CommandError('%s: too many arguments' % name)
485 if len(args) < min_args:
486 raise CommandError('%s: too few arguments' % name)
487 return func(*args)
488 commands[name] = run
489 return func
490 return register
491
492def check_command_dicts(dicts):
493 for d in dicts:
494 for cmd in d.itervalues():
495 assert cmd in commands, cmd
496
497def run_command(line):
498 cmd, args = split_pair(line)
499 cmd = CMD_ABBREVS.get(cmd, cmd)
500 func = commands.get(cmd)
501 if func is None:
502 raise CommandError('bad command: %s' % cmd)
503 return func(args)
504
505flags = [False]*12
506_getf = lambda i: (lambda: flags[i])
507_setf = lambda i: (lambda: flags.__setitem__(i, True))
508_clrf = lambda i: (lambda: flags.__setitem__(i, False))
509
510refresh_flag = _getf(0)
511schedule_refresh = _setf(0)
512refresh_done = _clrf(0)
513
514redraw_flag = _getf(1)
515schedule_redraw = _setf(1)
516redraw_done = _clrf(1)
517
518buflist_draw_flag = _getf(2)
519schedule_buflist_draw = _setf(2)
520buflist_draw_done = _clrf(2)
521
522buf_draw_flag = _getf(3)
523schedule_buf_draw = _setf(3)
524buf_draw_done = _clrf(3)
525
526prompt_draw_flag = _getf(4)
527schedule_prompt_draw = _setf(4)
528prompt_draw_done = _clrf(4)
529
530status_draw_flag = _getf(5)
531schedule_status_draw = _setf(5)
532status_draw_done = _clrf(5)
533
534quit_flag = _getf(6)
535schedule_quit = _setf(6)
536
537# 7-8: available
538
539buflist_flag = _getf(9)
540buflist_enter = _setf(9)
541buflist_exit = _clrf(9)
542
543prompt_flag = _getf(10)
544prompt_enter = _setf(10)
545prompt_exit = _clrf(10)
546
547ping_draw_flag = _getf(11)
548set_ping_draw = _setf(11)
549clr_ping_draw = _clrf(11)
550
551new_buf = lambda name, parent, title: [name, parent, title,
552 (0, 0), [], 0, True]
553
554buf_name = lambda b: b[0]
555buf_parent = lambda b: b[1]
556buf_title = lambda b: b[2]
557buf_vscroll = lambda b: b[3]
558buf_lines = lambda b: b[4]
559buf_num_read = lambda b: b[5]
560buf_unread = lambda b: len(buf_lines(b)) - buf_num_read(b)
561buf_at_end = lambda b: b[6]
562
563buf_set_title = lambda b, title: b.__setitem__(2, title)
564buf_set_vscroll = lambda b, coords: b.__setitem__(3,
565 (clip_to(buf_lines(b), coords[0]), coords[1]))
566buf_set_num_read = lambda b, n: b.__setitem__(5, max(buf_num_read(b), n))
567buf_set_at_end = lambda b: b.__setitem__(6, True)
568buf_clr_at_end = lambda b: b.__setitem__(6, False)
569
570format_buf_msg = matcher(message, (
571 (m_privmsg, lambda sender, msg: '<%s> %s' % (sender, msg)),
572 (m_server, lambda sender, msg: '<%s> %s' % (sender, msg)),
573 (m_notice, lambda sender, msg: '<-%s-> %s' % (sender, msg)),
574 (m_join, lambda sender, chan: '%s joins %s' % (sender, chan)),
575 (m_part, lambda sender, chan, msg: '%s parts %s [%s]'
576 % (sender, chan, msg)),
577 (m_quit, lambda sender, msg: '%s quits [%s]' % (sender, msg)),
578 (m_nick, lambda sender, nick: '%s is now known as %s' % (sender, nick)),
579 (m_kick, lambda sender, chan, name, msg: '%s kicked from %s by %s [%s]'
580 % (name, chan, sender, msg)),
581 (m_kicked, lambda sender, chan, msg: 'kicked from %s by %s [%s]'
582 % (chan, sender, msg)),
583 (m_topic, lambda sender, topic:
584 '%s sets topic to %s' % (sender, topic)
585 if topic else
586 '%s removes topic' % sender),
587 (m_chantopic, lambda sender, chan, topic:
588 '<%s> Topic for %s: %s' % (sender, chan, topic)
589 if topic is not None else
590 '<%s> No topic for %s' % (sender, chan)),
591 (m_mode, lambda sender, modes: 'MODE %s by %s' % (modes, sender)),
592 (m_chanmode, lambda sender, chan, modes: 'MODE %s on %s by %s'
593 % (modes, chan, sender)),
594 (m_names, lambda sender, chan, names: '<%s> NAMES in %s: %s'
595 % (sender, chan, names)),
596 (m_endnames, lambda sender, chan: '<%s> end NAMES in %s'
597 % (sender, chan)),
598 (m_error, lambda sender, msg: '<%s> server error: %s' % (sender, msg)),
599 (m_client, lambda msg: msg)))
600
601def buf_log_msg(buf, m):
602 buf_lines(buf).append(format_time(localtime()) + ' ' + format_buf_msg(m))
603 schedule_buflist_draw() # for unread counts
604 if buf is cur_buf and buf_at_end(buf):
605 schedule_buf_draw()
606
607def buf_privmsg(buf, msg):
608 if buf_parent(buf) is None:
609 raise CommandError("can't send messages here")
610 conn_privmsg(buf_registered_conn(buf), buf_name(buf), msg)
611
612buf_width = lambda: scr_width - buflist_width()
613buf_height = lambda: scr_height - 3
614render_line = lambda line: wrap(asciify(line), buf_width())
615
616def buf_max_vscroll(buf):
617 lines = buf_lines(buf)
618 if len(lines) == 0:
619 return 0, 0
620 origin = len(lines) - 1
621 return origin, len(render_line(lines[origin])) - 1
622
623def add_scroll_coords(lines, coords, delta):
624 origin, offset = coords
625 if origin >= len(lines):
626 return max(1, len(lines)) - 1, 0
627 delta += offset
628 while delta > 0:
629 n_rows = len(render_line(lines[origin]))
630 if n_rows > delta:
631 break
632 delta -= n_rows
633 origin += 1
634 if origin == len(lines): # past the last line
635 return origin - 1, n_rows - 1
636 return origin, delta
637
638def sub_scroll_coords(lines, coords, delta):
639 origin, offset = coords
640 if offset >= delta:
641 return origin, offset - delta
642 delta -= offset
643 while origin > 0:
644 origin -= 1
645 n_rows = len(render_line(lines[origin]))
646 if n_rows >= delta:
647 return origin, n_rows - delta
648 delta -= n_rows
649 return 0, 0
650
651def render_lines(lines, coords, row_limit):
652 origin, offset = coords
653 rows = []
654 row_limit += offset
655 while len(rows) < row_limit and origin < len(lines):
656 group = render_line(lines[origin])
657 space = row_limit - len(rows)
658 if len(group) > space:
659 group = group[:space]
660 else:
661 origin += 1
662 rows.extend(group)
663 return rows[offset:], origin
664
665def buf_draw(buf):
666 if scr_height < 2:
667 return
668 schedule_refresh()
669 write_out(t.CURSOR_HOME)
670 write_out(t.render((
671 t.A_REVERSE, pad_or_clip_str(asciify(buf_title(buf)), scr_width))))
672 left_column = buflist_width() + 1
673 w = buf_width()
674 h = buf_height()
675 if w < 1 or h < 1:
676 return
677 top_row = 2
678 lines = buf_lines(buf)
679 rows, next_origin = render_lines(lines, buf_vscroll(buf), h)
680 if next_origin == len(lines):
681 buf_set_at_end(buf)
682 elif buf_at_end(buf):
683 # no longer at end: autoscroll
684 buf_set_vscroll(buf,
685 sub_scroll_coords(lines, buf_max_vscroll(buf), h - 1))
686 rows, next_origin = render_lines(lines, buf_vscroll(buf), h)
687 buf_set_num_read(buf, next_origin)
688 n = -1
689 for n, row in enumerate(rows):
690 write_out(t.cursor_pos(top_row + n, left_column))
691 write_out(row)
692 if len(row) < w:
693 write_out(t.ERASE_LINE_TO_END)
694 for n in range(n + 1, h):
695 write_out(t.cursor_pos(top_row + n, left_column))
696 write_out(t.ERASE_LINE_TO_END)
697 buf_draw_done()
698
699cur_buf = new_buf('yrc', None, 'yrc general messages')
700buffers = [cur_buf]
701buffer_index = {}
702
703is_child_of = lambda parent: lambda buf: buf_parent(buf) is parent
704
705def sort_buffers():
706 global buffers
707 schedule_buflist_draw()
708 # "yrc" is always first
709 acc, buffers = buffers[:1], buffers[1:]
710 roots, buffers = partition(buffers, is_child_of(None))
711 roots.sort(key=buf_name)
712 for root in roots:
713 children, buffers = partition(buffers, is_child_of(root))
714 children.sort(key=buf_name)
715 acc.append(root)
716 acc.extend(children)
717 buffers = acc
718 buffer_index.clear()
719 for b in buffers:
720 parent = buf_parent(b)
721 parent_name = buf_name(parent) if parent else None
722 buffer_index[(buf_name(b), parent_name)] = b
723
724def get_buf(name, parent_name):
725 try:
726 return buffer_index[(name, parent_name)]
727 except KeyError:
728 if parent_name is None:
729 parent = None
730 title = 'Network messages: %s' % name
731 else:
732 parent = get_buf(parent_name, None)
733 title = name if is_chan(name) else 'Private messages: %s' % name
734 b = new_buf(name, parent, title)
735 buffers.append(b)
736 sort_buffers()
737 return b
738
739def find_buf(buf):
740 for i, b in enumerate(buffers):
741 if b is buf:
742 return i
743 raise ValueError("not in buffer list")
744
745def close_buf(buf):
746 i = find_buf(buf)
747 assert i > 0
748 last = buflist_last()
749 if last is buf:
750 last = buffers[i - 1]
751 buflist_select(last)
752 buflist_set_last(last)
753 parent = buf_parent(buf)
754 parent_name = buf_name(parent) if parent else None
755 del buffer_index[(buf_name(buf), parent_name)]
756 buffers.pop(i)
757
758command('scroll-down-page')(sequence(
759 schedule_buf_draw,
760 schedule_buflist_draw, # update unread counts
761 lambda: buf_set_vscroll(cur_buf,
762 add_scroll_coords(buf_lines(cur_buf),
763 buf_vscroll(cur_buf), buf_height() - 1))))
764
765command('scroll-up-page')(sequence(
766 schedule_buf_draw,
767 lambda: buf_set_vscroll(cur_buf,
768 sub_scroll_coords(buf_lines(cur_buf),
769 buf_vscroll(cur_buf), buf_height() - 1)),
770 # Stop autoscrolling, to be resumed by buf_draw if it turns out the last line is still on screen
771 lambda: buf_clr_at_end(cur_buf)))
772
773command('scroll-down-line')(sequence(
774 schedule_buf_draw,
775 schedule_buflist_draw,
776 lambda: buf_set_vscroll(cur_buf,
777 add_scroll_coords(buf_lines(cur_buf),
778 buf_vscroll(cur_buf), 1))))
779
780command('scroll-up-line')(sequence(
781 schedule_buf_draw,
782 lambda: buf_set_vscroll(cur_buf,
783 sub_scroll_coords(buf_lines(cur_buf),
784 buf_vscroll(cur_buf), 1)),
785 lambda: buf_clr_at_end(cur_buf)))
786
787command('scroll-bottom')(sequence(
788 schedule_buf_draw,
789 schedule_buflist_draw,
790 lambda: buf_set_vscroll(cur_buf, buf_max_vscroll(cur_buf))))
791
792command('scroll-top')(sequence(
793 schedule_buf_draw,
794 lambda: buf_set_vscroll(cur_buf, (0, 0)),
795 lambda: buf_clr_at_end(cur_buf)))
796
797def info(msg, buf=None):
798 buf_log_msg(buf or buffers[0], m_client(msg))
799
800def error(msg_or_exc, buf=None):
801 buf_log_msg(buf or buffers[0], m_client('ERROR: ' + str(msg_or_exc)))
802
803prompt = [[], 0, 0, 1]
804
805prompt_chars = lambda: prompt[0]
806prompt_cursor = lambda: prompt[1]
807prompt_hscroll = lambda: prompt[2]
808prompt_cursor_column = lambda: prompt[3]
809
810def prompt_set_cursor(c):
811 schedule_prompt_draw()
812 if 0 <= c <= len(prompt_chars()):
813 prompt[1] = c
814 return True
815 return False
816
817def prompt_set_hscroll(s):
818 schedule_prompt_draw()
819 prompt[2] = s
820
821def prompt_set_cursor_column(c):
822 prompt[3] = c
823
824def prompt_clear():
825 schedule_prompt_draw()
826 del prompt_chars()[:]
827 prompt_set_cursor(0)
828 prompt_set_hscroll(0)
829
830def prompt_insert(char):
831 schedule_prompt_draw()
832 c = prompt_cursor()
833 prompt_chars().insert(c, char)
834 prompt_set_cursor(c + 1)
835
836@command('prompt-submit')
837def prompt_submit():
838 line = ''.join(prompt_chars())
839 if len(line) == 0:
840 return
841 prompt_clear()
842 schedule_prompt_draw()
843 try:
844 if line.startswith('/'):
845 line = line[1:]
846 if not line:
847 raise CommandError('empty command')
848 if not line.startswith('/'):
849 run_command(line)
850 return
851 buf_privmsg(cur_buf, line)
852 except CommandError, e:
853 error(e, cur_buf)
854
855command('prompt-forward')(lambda: prompt_set_cursor(prompt_cursor() + 1))
856command('prompt-backward')(lambda: prompt_set_cursor(prompt_cursor() - 1))
857command('prompt-start')(lambda: prompt_set_cursor(0))
858command('prompt-end')(lambda: prompt_set_cursor(len(prompt_chars())))
859
860@command('prompt-backspace')
861def prompt_backspace():
862 c = prompt_cursor() - 1
863 if c >= 0:
864 prompt_set_cursor(c)
865 prompt_chars().pop(c)
866 schedule_prompt_draw()
867
868@command('prompt-delete')
869def prompt_delete():
870 c = prompt_cursor()
871 chars = prompt_chars()
872 if c < len(chars):
873 chars.pop(c)
874 schedule_prompt_draw()
875
876def prompt_draw():
877 if scr_height < 1:
878 return
879 write_out(t.cursor_pos(scr_height, 1))
880 if scr_width < 4:
881 write_out(t.ERASE_LINE_TO_END)
882 return
883 prompt_str = '> '
884 write_out(prompt_str)
885 chars = prompt_chars()
886 cursor = prompt_cursor()
887 hscr = prompt_hscroll()
888 before_cursor = asciify(chars[:cursor])
889 cursor_offset = len(before_cursor) - hscr
890 max_width = scr_width - len(prompt_str)
891 if not 0 <= cursor_offset < max_width:
892 hscr = max(0, hscr + cursor_offset - max_width/2)
893 prompt_set_hscroll(hscr)
894 cursor_offset = len(before_cursor) - hscr
895 prompt_set_cursor_column(1 + len(prompt_str) + cursor_offset)
896 printable = (before_cursor[hscr:] +
897 asciify(chars[cursor:])[:max_width - cursor_offset])
898 write_out(printable)
899 write_out(t.ERASE_LINE_TO_END)
900 schedule_refresh()
901 prompt_draw_done()
902
903def conn_ping_status(c):
904 t0 = conn_ping_ts(c)
905 t1 = conn_pong_ts(c)
906 if t1 < t0:
907 if not ping_draw_flag():
908 set_ping_draw()
909 run_in(5, sequence(clr_ping_draw, status_draw))
910 delta = int(mono_time() - t0)
911 return '%d...' % (delta - delta%5)
912 return '%.3f' % (t1 - t0)
913
914def status_draw():
915 row = scr_height - 1
916 if row < 1:
917 return
918 if kbd_state is ks_esc:
919 status = 'ESC-'
920 elif kbd_state is ks_cx:
921 status = 'C-x-'
922 elif kbd_state in (ks_cseq, ks_cs_intermed):
923 status = ' '.join(['CSI'] + list(str(kbd_accum[len(CSI):]))) + '-'
924 elif cur_buf is buffers[0]:
925 status = ''
926 else:
927 parent = buf_parent(cur_buf)
928 network = buf_name(cur_buf if parent is None else parent)
929 status = asciify(network)
930 c = network_conns.get(network)
931 if c:
932 status = asciify(conn_nick(c) + '@') + status
933 if parent:
934 venue = buf_name(cur_buf)
935 status += ' | ' + asciify(venue)
936 if c and conn_sock(c):
937 if not conn_registered(c):
938 status += ' | unregistered'
939 elif parent and is_chan(venue) and venue not in conn_channels(c):
940 status += ' | unjoined'
941 status += ' | ping: %s' % conn_ping_status(c)
942 else:
943 status += ' | offline'
944 write_out(t.cursor_pos(row, 1))
945 write_out(t.ATTR_REVERSE)
946 write_out(pad_or_clip_str(status, scr_width))
947 write_out(t.ATTR_NONE)
948 schedule_refresh()
949 status_draw_done()
950
951# buflist singleton: lists the open buffers in a left-hand pane
952
953buflist = [0, BUFLIST_WIDTH_START, cur_buf, cur_buf, cur_buf]
954buflist_vscroll = lambda: buflist[0]
955buflist_width = lambda: buflist[1]
956buflist_cursor = lambda: buflist[2]
957buflist_last = lambda: buflist[3]
958
959def buflist_set_width(w):
960 schedule_redraw()
961 if w > scr_width:
962 w = scr_width - (scr_width - w) % BUFLIST_WIDTH_STEP
963 buflist[1] = max(BUFLIST_WIDTH_MIN, w)
964
965def buflist_set_cursor(b):
966 schedule_buflist_draw()
967 buflist[2] = b
968
969def buflist_set_last(b):
970 buflist[3] = b
971
972def buflist_select(b):
973 global cur_buf
974 schedule_buflist_draw()
975 if b is not cur_buf:
976 schedule_buf_draw()
977 schedule_status_draw()
978 buflist_set_last(cur_buf)
979 # Stop autoscrolling for newly selected window, to be resumed by buf_draw if it turns out the last line is still on screen
980 buf_clr_at_end(b)
981 buflist_set_cursor(b)
982 cur_buf = b
983
984command('buflist-switch-next')(
985 lambda: buflist_select(
986 get_clipped(buffers, find_buf(cur_buf) + 1)))
987command('buflist-switch-prev')(
988 lambda: buflist_select(
989 get_clipped(buffers, find_buf(cur_buf) - 1)))
990command('buflist-last-selected')(lambda: buflist_select(buflist_last()))
991
992command('buflist-next')(
993 lambda: buflist_set_cursor(
994 get_clipped(buffers, find_buf(buflist_cursor()) + 1)))
995command('buflist-prev')(
996 lambda: buflist_set_cursor(
997 get_clipped(buffers, find_buf(buflist_cursor()) - 1)))
998command('buflist-top')(lambda: buflist_set_cursor(buffers[0]))
999command('buflist-bottom')(lambda: buflist_set_cursor(buffers[-1]))
1000command('buflist-shrink')(lambda:
1001 buflist_set_width(buflist_width() - BUFLIST_WIDTH_STEP))
1002command('buflist-grow')(lambda:
1003 buflist_set_width(buflist_width() + BUFLIST_WIDTH_STEP))
1004
1005@command('buflist-submit')
1006def buflist_submit():
1007 buflist_exit()
1008 buflist_select(buflist_cursor())
1009
1010def buflist_draw():
1011 schedule_refresh()
1012 top_row = 2
1013 h = scr_height - 3
1014 w = min(scr_width, buflist_width() - 1)
1015 cursor = buflist_cursor()
1016 def network_status(network):
1017 c = network_conns.get(network)
1018 if c is None: # not connected
1019 return ' '
1020 sock = conn_sock(c)
1021 if sock is None: # down, awaiting reconnection
1022 return '~'
1023 fd = conn_sock(c).fileno()
1024 if fd in opening_conns: # DNS / TCP handshake
1025 return '~'
1026 if conn_registered(c):
1027 return '='
1028 else: # TCP connected but unregistered (no WELCOME yet)
1029 return '-'
1030 def get_label(buf):
1031 parts = []
1032 if buf is cur_buf:
1033 parts.append('*')
1034 elif buf is buflist_last():
1035 parts.append('-')
1036 else:
1037 parts.append(' ')
1038 if buf_parent(buf) is None:
1039 if buf is buffers[0]:
1040 parts.append(' ')
1041 else:
1042 parts.append(network_status(buf_name(buf)))
1043 else: # channel/pm
1044 parts.append(' ')
1045 parts.append(asciify(buf_name(buf)))
1046 unread = buf_unread(buf)
1047 if unread > 0:
1048 parts.append(' +%d' % unread)
1049 label = ''.join(parts)
1050 if buf is cursor and buflist_flag():
1051 return t.render((t.A_REVERSE, pad_or_clip_str(label, w)))
1052 else:
1053 return clip_str(label, w)
1054 write_out(t.cursor_pos(2, 1))
1055 scroll = buflist_vscroll()
1056 for row in range(h):
1057 write_out(t.cursor_pos(top_row + row, w) + t.ERASE_LINE_FROM_START)
1058 i = scroll + row
1059 if i < len(buffers):
1060 write_out(CR + get_label(buffers[i]))
1061 buflist_draw_done()
1062
1063def buflist_vline_draw():
1064 top_row = 2
1065 height = scr_height - 3
1066 column = buflist_width()
1067 if column > scr_width:
1068 return
1069 move_down = LF if column == scr_width else BS + LF
1070 write_out(t.cursor_pos(top_row, column))
1071 write_out(SO)
1072 write_out(move_down.join(t.SGC_VBAR*height))
1073 write_out(SI)
1074
1075command('prompt-enter')(sequence(schedule_refresh, prompt_enter))
1076command('prompt-exit')(sequence(schedule_refresh, prompt_exit))
1077command('buflist-enter')(sequence(schedule_buflist_draw, buflist_enter))
1078command('redraw')(schedule_redraw)
1079
1080# Terminal input state machine
1081
1082# Only valid control sequences per ECMA-48 5ed. sec. 5.4 are accepted.
1083# Esc always cancels any sequence in progress and moves to ks_esc, to avoid
1084# control sequences leaking through as text input. C-g always cancels and
1085# returns to ks_start.
1086
1087kbd_accum = bytearray()
1088kaccum = kbd_accum.extend
1089
1090def kaccept(sym=''):
1091 kaccum(sym)
1092 seq = str(kbd_accum)
1093 ktrans(ks_start)
1094 def try_keymap(km):
1095 cmd = km.get(seq)
1096 if cmd is None:
1097 return False
1098 run_command(cmd)
1099 return True
1100 try:
1101 if try_keymap(GLOBAL_KEYS):
1102 return
1103 elif buflist_flag():
1104 try_keymap(BUFLIST_KEYS)
1105 elif prompt_flag():
1106 if not try_keymap(PROMPT_KEYS):
1107 if len(seq) == 1 and not is_ctrl(seq):
1108 prompt_insert(seq)
1109 else:
1110 try_keymap(BUF_KEYS)
1111 except CommandError, e:
1112 error(e, cur_buf)
1113
1114def ktrans(state):
1115 global kbd_state
1116 kbd_state = state
1117 schedule_status_draw()
1118 if state in (ks_start, ks_esc):
1119 del kbd_accum[:]
1120 elif state is ks_cseq:
1121 del kbd_accum[:]
1122 kaccum(CSI)
1123
1124# States
1125
1126def ks_start(sym):
1127 if sym == C_X:
1128 kaccum(C_X)
1129 ktrans(ks_cx)
1130 elif sym == ESC:
1131 ktrans(ks_esc)
1132 else:
1133 kaccept(sym)
1134
1135def ks_cx(sym):
1136 if sym == C_G:
1137 ktrans(ks_start)
1138 elif sym == ESC:
1139 ktrans(ks_esc)
1140 else:
1141 kaccept(casemap_ascii(ctrl(sym)) if is_ctrl(sym) else sym)
1142
1143def ks_esc(sym):
1144 if sym == C_G:
1145 ktrans(ks_start)
1146 elif sym == ESC:
1147 pass
1148 elif sym == '[':
1149 ktrans(ks_cseq)
1150 else:
1151 kaccept(meta(sym))
1152
1153def ks_cseq(sym):
1154 if sym == ESC:
1155 ktrans(ks_esc)
1156 elif '\x20' <= sym <= '\x2F':
1157 kaccum(sym)
1158 ktrans(ks_cs_intermed)
1159 elif '\x30' <= sym <= '\x3F':
1160 kaccum(sym)
1161 schedule_status_draw()
1162 elif '\x40' <= sym <= '\x7E':
1163 kaccept(sym)
1164 else:
1165 ktrans(ks_start)
1166
1167def ks_cs_intermed(sym):
1168 if sym == ESC:
1169 ktrans(ks_esc)
1170 elif '\x20' <= sym <= '\x2F':
1171 kaccum(sym)
1172 schedule_status_draw()
1173 elif '\x40' <= sym <= '\x7E':
1174 kaccept(sym)
1175 else:
1176 ktrans(ks_start)
1177
1178kbd_state = ks_start
1179
1180def buf_conn(buf):
1181 if buf is buffers[0]:
1182 raise CommandError('this window not associated with a network')
1183 parent = buf_parent(buf)
1184 network = buf_name(buf if parent is None else parent)
1185 try:
1186 return network_conns[network]
1187 except KeyError:
1188 raise CommandError('not connected to %s' % network)
1189
1190def buf_registered_conn(buf):
1191 c = buf_conn(buf)
1192 if conn_sock(c) is None:
1193 raise CommandError('connection to %s is down' % conn_network(c))
1194 if not conn_registered(c):
1195 raise CommandError('connection to %s not registered' % conn_network(c))
1196 return c
1197
1198@command('connect', 1, 4)
1199def connect_cmd(*args):
1200 net = args[0]
1201 if net in network_conns:
1202 raise CommandError('connect: connection already active for %s' % net)
1203
1204 if not safe_filename(net):
1205 raise CommandError('connect: bad network name: %s' % net)
1206 conf_paths = (('nets', net), ())
1207
1208 if len(args) > 1:
1209 addrs = [args[1]]
1210 else:
1211 addrs = config_lines(get_config('addrs', conf_paths))
1212 if addrs is None:
1213 raise CommandError('connect: no addrs for network %s' % net)
1214 try:
1215 addrs = map(parse_address, addrs)
1216 except ValueError, e:
1217 raise CommandError('connect: %s' % e)
1218
1219 if len(args) > 2:
1220 nick = args[2]
1221 else:
1222 nick = get_config('nick', conf_paths)
1223 if nick is None:
1224 raise CommandError('connect: no nick for %s' % net)
1225 if not valid_nick(nick):
1226 raise CommandError('connect: bad nick: %s' % nick)
1227
1228 if len(args) > 3:
1229 pw = args[3]
1230 else:
1231 pw = get_config('pass', conf_paths)
1232 if not valid_password(pw):
1233 raise CommandError(
1234 'connect: illegal character in password for %s' % net)
1235
1236 conn_start(new_conn(net, addrs, nick, pw))
1237
1238@command('disconnect', 0, 1)
1239def disconnect_cmd(net=None):
1240 if net is None:
1241 c = buf_conn(cur_buf)
1242 net = conn_network(c)
1243 else:
1244 try:
1245 c = network_conns[net]
1246 except KeyError:
1247 raise CommandError('no connection for network %s' % net)
1248 del network_conns[net]
1249 conn_info(c, 'disconnected')
1250 conn_close(c)
1251
1252@command('join', 1, 2)
1253def join_cmd(chan, key=None):
1254 if not valid_chan(chan):
1255 raise CommandError('join: bad channel name: %s' % chan)
1256 conn_join(buf_registered_conn(cur_buf), chan, key)
1257
1258@command('kick', 1, extended_arg=True)
1259def kick_cmd(user, msg=''):
1260 chan = buf_name(cur_buf)
1261 if buf_parent(cur_buf) is None or not is_chan(chan):
1262 raise CommandError('kick: this window not a channel')
1263 conn_send(buf_registered_conn(cur_buf), 'KICK', [chan, user, msg])
1264
1265@command('list', 0, 1)
1266def list_cmd(*args):
1267 conn_send(buf_registered_conn(cur_buf), 'LIST', args)
1268
1269@command('me', extended_arg=True)
1270def me_cmd(msg=''):
1271 buf_privmsg(cur_buf, '\x01ACTION %s\x01' % msg)
1272
1273@command('mode', 1, 3)
1274def mode_cmd(*args):
1275 conn_send(buf_registered_conn(cur_buf), 'MODE', args)
1276
1277@command('msg', 1, extended_arg=True)
1278def msg_cmd(target, msg=None):
1279 c = buf_registered_conn(cur_buf)
1280 if is_chan(target) and not valid_chan(target):
1281 raise CommandError('msg: bad channel name: %s' % target)
1282 if msg:
1283 conn_privmsg(c, target, msg)
1284 buflist_select(get_buf(target, conn_network(c)))
1285
1286@command('names', 0, 1)
1287def names_cmd(*args):
1288 conn_send(buf_registered_conn(cur_buf), 'NAMES', args)
1289
1290@command('nick', 1)
1291def nick_cmd(nick):
1292 c = buf_conn(cur_buf)
1293 if conn_sock(c) is None:
1294 schedule_status_draw()
1295 conn_set_nick(c, nick)
1296 conn_info(c, 'nick changed to %s for next reconnection' % nick)
1297 else:
1298 if not conn_registered(c):
1299 schedule_status_draw()
1300 conn_set_nick(c, nick)
1301 conn_info(c, 'nick changed to %s' % nick)
1302 conn_set_ping_ts(c, mono_time())
1303 conn_run_in(c, PING_TIMEOUT, conn_reg_timeout)
1304 conn_send(c, 'NICK', [nick])
1305
1306@command('part', extended_arg=True)
1307def part_cmd(msg=''):
1308 chan = buf_name(cur_buf)
1309 if buf_parent(cur_buf) is None or not is_chan(chan):
1310 raise CommandError('part: this window not a channel')
1311 conn_send(buf_registered_conn(cur_buf), 'PART', [chan, msg])
1312
1313@command('quit', extended_arg=True)
1314def quit_cmd(*msg):
1315 schedule_quit()
1316 for c in open_conns.itervalues():
1317 conn_send(c, 'QUIT', msg)
1318
1319@command('send', extended_arg=True)
1320def send_cmd(line):
1321 if len(line) > MAX_MSG_LEN:
1322 raise CommandError('send: line too long')
1323 conn_write(buf_registered_conn(cur_buf), line)
1324
1325@command('topic', 0, 1)
1326def topic_cmd(chan=None):
1327 if chan is None:
1328 chan = buf_name(cur_buf)
1329 if buf_parent(cur_buf) is None or not is_chan(chan):
1330 raise CommandError(
1331 'topic: this window not a channel and none specified')
1332 conn_send(buf_registered_conn(cur_buf), 'TOPIC', [chan])
1333
1334@command('set-topic', extended_arg=True)
1335def set_topic_cmd(topic=''):
1336 chan = buf_name(cur_buf)
1337 if buf_parent(cur_buf) is None or not is_chan(chan):
1338 raise CommandError('set-topic: this window not a channel')
1339 conn_send(buf_registered_conn(cur_buf), 'TOPIC', [chan, topic])
1340
1341@command('whois', 1)
1342def whois_cmd(nick):
1343 conn_send(buf_registered_conn(cur_buf), 'WHOIS', [nick])
1344
1345@command('whowas', 1)
1346def whowas_cmd(nick):
1347 conn_send(buf_registered_conn(cur_buf), 'WHOWAS', [nick])
1348
1349@command('close')
1350def close_cmd():
1351 parent = buf_parent(cur_buf)
1352 if parent is None:
1353 raise CommandError(
1354 "close: won't close a top-level window (try close-net)")
1355 venue = buf_name(cur_buf)
1356 try:
1357 c = buf_registered_conn(cur_buf)
1358 except CommandError:
1359 pass
1360 else:
1361 if venue in conn_channels(c):
1362 conn_send(c, 'PART', [venue, ''])
1363 del conn_channels(c)[venue]
1364 close_buf(cur_buf)
1365
1366@command('close-net')
1367def close_net_cmd():
1368 raise CommandError('stub') # TODO
1369
1370def place_cursor():
1371 if buflist_flag():
1372 write_out(t.cursor_pos(
1373 max(1, scr_height - 2),
1374 clip(1, scr_width, buflist_width() - 1)))
1375 elif prompt_flag():
1376 write_out(t.cursor_pos(scr_height, prompt_cursor_column()))
1377 else:
1378 write_out(t.cursor_pos(max(1, scr_height - 2), max(1, scr_width)))
1379
1380def refresh_if_needed():
1381 if redraw_flag():
1382 write_out(t.ERASE_FULL)
1383 buf_draw(cur_buf)
1384 buflist_vline_draw()
1385 buflist_draw()
1386 status_draw()
1387 prompt_draw()
1388 redraw_done()
1389 else:
1390 if buf_draw_flag():
1391 buf_draw(cur_buf)
1392 if buflist_draw_flag():
1393 buflist_draw()
1394 if status_draw_flag():
1395 status_draw()
1396 if prompt_draw_flag():
1397 prompt_draw()
1398 if refresh_flag():
1399 place_cursor()
1400 flush_out()
1401 refresh_done()
1402
1403#
1404# IRC stuff
1405#
1406
1407RPL_WELCOME = 1
1408RPL_NOTOPIC = 331
1409RPL_TOPIC = 332
1410RPL_NAMREPLY = 353
1411RPL_ENDOFNAMES = 366
1412ERR_NICKNAMEINUSE = 433
1413ERR_NICKCOLLISION = 436
1414ERR_NOTREGISTERED = 451
1415
1416MAX_MSG_LEN = 510
1417MAX_CHAN_LEN = 50
1418MAX_NICK_LEN = 31
1419
1420IRC_ILLEGAL = NUL + CR + LF
1421CHAN_ILLEGAL = IRC_ILLEGAL + BEL + ' ,:'
1422CHAN_START = '&#+!'
1423IRC_SPECIAL = '[]\\`_^{|}'
1424
1425LETTER = char_range('AZ') + char_range('az')
1426DIGIT = char_range('09')
1427NICK_START = LETTER + IRC_SPECIAL
1428NICK_BODY = NICK_START + DIGIT + '-'
1429
1430class ProtocolError(Exception):
1431 pass
1432
1433def build_msg(prefix, cmd, params):
1434 """Build an IRC wire message.
1435
1436 Conditions caller must enforce:
1437 * No args may contain NUL, CR, or LF
1438 * Only last param may be empty, contain spaces, or start with :
1439 * Valid cmd
1440 * 15 parameters max
1441 """
1442 parts = []
1443 if prefix is not None:
1444 parts.append(':' + prefix)
1445 parts.append(cmd)
1446 if len(params):
1447 parts.extend(params[:-1])
1448 parts.append(':' + params[-1])
1449 return ' '.join(parts)
1450
1451def max_param_len(cmd, prefix=None):
1452 # colon before prefix + space after prefix
1453 prefix_len = 0 if prefix is None else len(prefix) + 2
1454 # space after cmd + colon before last param
1455 return MAX_MSG_LEN - prefix_len - len(cmd) - 2
1456
1457def parse_msg(msg):
1458 if any(c in msg for c in IRC_ILLEGAL):
1459 raise ProtocolError('illegal character in message')
1460 start = 0
1461 end = len(msg)
1462 def next_word():
1463 cut = msg.find(' ', start)
1464 if cut == -1:
1465 cut = end
1466 return cut + 1, msg[start:cut]
1467 if msg.startswith(':'):
1468 start = 1
1469 start, prefix = next_word()
1470 else:
1471 prefix = None
1472 start, cmd = next_word()
1473 if not cmd:
1474 raise ProtocolError('message with no command')
1475 params = []
1476 while start < end:
1477 if msg[start] == ':':
1478 params.append(msg[start+1:])
1479 break
1480 start, param = next_word()
1481 params.append(param)
1482 return prefix, casemap_ascii(cmd), params
1483
1484is_chan = lambda n: len(n) > 0 and n[0] in CHAN_START
1485
1486def valid_chan(n):
1487 return is_chan(n) and len(n) <= MAX_CHAN_LEN \
1488 and not any(c in CHAN_ILLEGAL for c in n)
1489
1490def valid_nick(n):
1491 return 0 < len(n) <= MAX_NICK_LEN \
1492 and n[0] in NICK_START \
1493 and all(c in NICK_BODY for c in n[1:])
1494
1495def valid_password(p):
1496 return p is None or (
1497 len(p) <= max_param_len('PASS')
1498 and not any(c in IRC_ILLEGAL for c in p))
1499
1500sender_nick = lambda s: s.split('!', 1)[0]
1501
1502#
1503# Networking / main loop
1504#
1505
1506set_nonblock = lambda fd: fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK)
1507
1508def read_all(fd):
1509 chunks = []
1510 try:
1511 chunk = read(fd, 4096)
1512 if not chunk:
1513 raise EOFError
1514 while chunk:
1515 chunks.append(chunk)
1516 chunk = read(fd, 4096)
1517 except EnvironmentError, e:
1518 if e.errno not in (EAGAIN, EWOULDBLOCK):
1519 raise
1520 return ''.join(chunks)
1521
1522def new_conn(network, addrs, nick, password=None):
1523 i = rand_int(len(addrs))
1524 addrs = addrs[i:] + addrs[:i]
1525 return [network, None, '', '', addrs, nick, password, False, dict(),
1526 IRC_CASEMAP['rfc1459'], RECONN_DELAY_MIN, 0, 0, 0]
1527
1528conn_network = lambda c: c[0]
1529conn_sock = lambda c: c[1]
1530conn_rdbuf = lambda c: c[2]
1531conn_wrbuf = lambda c: c[3]
1532conn_addrs = lambda c: c[4]
1533conn_nick = lambda c: c[5]
1534conn_password = lambda c: c[6]
1535conn_registered = lambda c: c[7]
1536conn_channels = lambda c: c[8]
1537conn_casemap = lambda c, s: c[9](s)
1538conn_count = lambda c: c[11]
1539conn_ping_ts = lambda c: c[12]
1540conn_pong_ts = lambda c: c[13]
1541
1542conn_set_sock = lambda c, s: c.__setitem__(1, s)
1543conn_rdbuf_add = lambda c, b: c.__setitem__(2, c[2] + b)
1544conn_rdbuf_rm = lambda c, n: c.__setitem__(2, c[2][n:])
1545conn_wrbuf_add = lambda c, b: c.__setitem__(3, c[3] + b)
1546conn_wrbuf_rm = lambda c, n: c.__setitem__(3, c[3][n:])
1547conn_set_nick = lambda c, n: c.__setitem__(5, n)
1548conn_set_registered = lambda c: c.__setitem__(7, True)
1549conn_clr_registered = lambda c: c.__setitem__(7, False)
1550conn_reset_reconn_delay = lambda c: c.__setitem__(10, RECONN_DELAY_MIN)
1551conn_set_count = lambda c, n: c.__setitem__(11, n)
1552conn_set_ping_ts = lambda c, t: c.__setitem__(12, t)
1553conn_set_pong_ts = lambda c, t: c.__setitem__(13, t)
1554
1555def conn_reconn_delay(c):
1556 # limited exponential backoff
1557 d = c[10]
1558 c[10] = min(2*d, RECONN_DELAY_MAX)
1559 return d
1560
1561conn_nick_lc = lambda c: conn_casemap(c, conn_nick(c))
1562
1563def conn_run_in(c, delay, method, run_if_down=False):
1564 count = conn_count(c)
1565 def run():
1566 # Drop leftover tasks from old connections
1567 if c is network_conns.get(conn_network(c)) and conn_count(c) == count \
1568 and (run_if_down or conn_sock(c) is not None):
1569 method(c)
1570 run_in(delay, run)
1571
1572def conn_log_msg(c, venue, m):
1573 network = conn_network(c)
1574 if venue is None:
1575 buf = get_buf(network, None)
1576 else:
1577 buf = get_buf(venue, network)
1578 buf_log_msg(buf, m)
1579 file_log_msg(network, venue, m)
1580
1581def conn_info(c, msg):
1582 conn_log_msg(c, None, m_client(msg))
1583
1584def conn_error(c, msg):
1585 conn_log_msg(c, None, m_client('ERROR: ' + msg))
1586
1587opening_conns = {}
1588network_conns = {}
1589def conn_start(c):
1590 schedule_buflist_draw()
1591 schedule_status_draw()
1592 net = conn_network(c)
1593 assert conn_sock(c) is None, 'socket exists when starting connection'
1594 sock = socket()
1595 set_nonblock(sock.fileno())
1596 conn_set_sock(c, sock)
1597 conn_set_count(c, conn_count(c) + 1)
1598 addrs = conn_addrs(c)
1599 addrs.append(addrs.pop(0))
1600 conn_info(c, 'connecting to %s' % format_address(addrs[0]))
1601 network_conns[net] = opening_conns[sock.fileno()] = c
1602 try:
1603 sock.connect(addrs[0]) # TODO async DNS
1604 except SockError, e:
1605 if e.errno != EINPROGRESS:
1606 del opening_conns[sock.fileno()]
1607 conn_error(c, e.strerror)
1608 else:
1609 conn_handle_connected(c)
1610
1611def conn_write(c, msg):
1612 if len(msg) > MAX_MSG_LEN:
1613 msg = msg[:MAX_MSG_LEN]
1614 conn_error(c, 'outbound message truncated')
1615 conn_wrbuf_add(c, msg + CRLF)
1616
1617def conn_send(c, cmd, params, prefix=None):
1618 conn_write(c, build_msg(prefix, cmd, params))
1619
1620open_conns = {}
1621def conn_handle_connected(c):
1622 n = conn_nick(c)
1623 p = conn_password(c)
1624 s = conn_sock(c)
1625 e = s.getsockopt(SOL_SOCKET, SO_ERROR)
1626 if e == EINPROGRESS:
1627 return
1628 schedule_buflist_draw()
1629 schedule_status_draw()
1630 del opening_conns[s.fileno()]
1631 if e != 0:
1632 conn_error(c, strerror(e))
1633 conn_close(c)
1634 return
1635 conn_reset_reconn_delay(c)
1636 open_conns[s.fileno()] = c
1637 conn_info(c, 'connection established')
1638 conn_set_ping_ts(c, mono_time())
1639 conn_run_in(c, PING_TIMEOUT, conn_reg_timeout)
1640 if p is not None:
1641 conn_send(c, 'PASS', [p])
1642 conn_send(c, 'NICK', [n])
1643 conn_send(c, 'USER', [n, '0', '*', n])
1644
1645def conn_close(c):
1646 sock = conn_sock(c)
1647 if sock is None:
1648 return
1649 schedule_buflist_draw()
1650 schedule_status_draw()
1651 fd = sock.fileno()
1652 if fd in open_conns:
1653 del open_conns[fd]
1654 elif fd in opening_conns:
1655 del opening_conns[fd]
1656 try:
1657 sock.shutdown(SHUT_RDWR)
1658 except SockError:
1659 pass
1660 sock.close()
1661 conn_set_sock(c, None)
1662 conn_rdbuf_rm(c, len(conn_rdbuf(c)))
1663 conn_wrbuf_rm(c, len(conn_wrbuf(c)))
1664 conn_clr_registered(c)
1665 conn_info(c, 'connection closed')
1666 if conn_network(c) in network_conns:
1667 delay = conn_reconn_delay(c)
1668 conn_run_in(c, delay, conn_start, True)
1669 conn_info(c, 'reconnecting in %d seconds' % delay)
1670
1671def conn_handle_data(c, data):
1672 conn_rdbuf_add(c, data)
1673 data = conn_rdbuf(c)
1674 start = 0
1675 while start < len(data):
1676 end = data.find(CRLF, start)
1677 if end == -1:
1678 if len(data) - start >= MAX_MSG_LEN:
1679 conn_error(c, 'received oversize message')
1680 conn_close(c)
1681 return
1682 break
1683 if end > start:
1684 try:
1685 conn_handle_msg(c, data[start:end])
1686 except ProtocolError, e:
1687 conn_error(c, e)
1688 start = end + 2
1689 conn_rdbuf_rm(c, start)
1690
1691def conn_handle_msg(c, msg):
1692 #pylint: disable=unbalanced-tuple-unpacking,too-many-locals
1693 prefix, cmd, params = parse_msg(msg)
1694
1695 def welcome():
1696 if destination != conn_nick(c): # for pre-welcome nick change
1697 schedule_status_draw()
1698 conn_info(c, 'nick changed to %s' % destination)
1699 conn_set_nick(c, destination)
1700 conn_log_msg(c, None, m_server(prefix or '', ' '.join(params)))
1701 if not conn_registered(c):
1702 schedule_buflist_draw()
1703 conn_set_registered(c)
1704 pong()
1705 for chan in conn_channels(c):
1706 conn_join(c, chan)
1707 conn_channels(c).clear()
1708
1709 def names_reply():
1710 if len(params) != 3:
1711 conn_error(c, 'RPL_NAMREPLY with bad parameter count: %s' % msg)
1712 return
1713 _, chan, names = params
1714 chan_lc = conn_casemap(c, chan)
1715 members = conn_channels(c).get(chan_lc)
1716 conn_log_msg(c, None if members is None else chan_lc,
1717 m_names(prefix or '', chan, names))
1718 if members is not None:
1719 for nick in names.split(' '):
1720 if not nick:
1721 conn_error(c, 'RPL_NAMREPLY with empty nick')
1722 break
1723 if nick[0] in '@+':
1724 nick = nick[1:]
1725 members.add(conn_casemap(c, nick))
1726
1727 def end_names():
1728 if len(params) != 2:
1729 conn_error(c, 'RPL_ENDOFNAMES with bad parameter count: %s' % msg)
1730 return
1731 chan = params[0]
1732 chan_lc = conn_casemap(c, chan)
1733 conn_log_msg(c, chan_lc if chan_lc in conn_channels(c) else None,
1734 m_endnames(prefix or '', chan))
1735
1736 def error_msg():
1737 if len(params) != 1:
1738 conn_error(c, 'ERROR with bad parameter count: %s' % msg)
1739 return
1740 conn_log_msg(c, None, m_error(prefix or '', params[0]))
1741
1742 def ping():
1743 conn_send(c, 'PONG', params)
1744
1745 def pong():
1746 schedule_status_draw()
1747 conn_set_pong_ts(c, mono_time())
1748 conn_run_in(c, PING_INTERVAL, conn_ping)
1749
1750 def privmsg():
1751 if len(params) != 2:
1752 conn_error(c, 'message with bad parameter count: %s' % msg)
1753 return
1754 target, content = params
1755 target_lc = conn_casemap(c, target)
1756 if prefix is None:
1757 conn_error(c, 'message without sender: %s' % msg)
1758 return
1759 sender = sender_nick(prefix)
1760 if target_lc == conn_nick_lc(c): # PM
1761 venue = conn_casemap(c, sender)
1762 elif valid_chan(target_lc):
1763 if target_lc in conn_channels(c):
1764 venue = target_lc
1765 else:
1766 return # drop messages to unknown channels
1767 elif target_lc == '*': # not sure if standard but freenode does this
1768 venue = None
1769 else:
1770 conn_error(c, 'message to unknown target: %s' % msg)
1771 return
1772 conn_log_msg(c, venue,
1773 (m_notice if cmd == 'notice' else m_privmsg)(sender, content))
1774
1775 def join():
1776 if len(params) != 1:
1777 conn_error(c, 'JOIN with bad parameter count: %s' % msg)
1778 return
1779 chan, = params
1780 chan_lc = conn_casemap(c, chan)
1781 if prefix is None:
1782 conn_error(c, 'JOIN without sender: %s' % msg)
1783 return
1784 sender_lc = conn_casemap(c, sender_nick(prefix))
1785 channels_dict = conn_channels(c)
1786 if sender_lc == conn_nick_lc(c):
1787 if chan_lc in channels_dict:
1788 conn_error(c, 'JOIN to already joined channel %s' % chan)
1789 return
1790 channels_dict[chan_lc] = set()
1791 else:
1792 if chan_lc not in channels_dict:
1793 conn_error(c, 'JOIN %s to unknown channel %s' % (prefix, chan))
1794 return
1795 channels_dict[chan_lc].add(sender_lc)
1796 conn_log_msg(c, chan_lc, m_join(prefix, chan))
1797
1798 def mode():
1799 if len(params) < 1:
1800 conn_error(c, 'MODE with bad parameter count: %s' % msg)
1801 return
1802 target = params[0]
1803 modes = ' '.join(params[1:])
1804 target_lc = conn_casemap(c, target)
1805 if prefix is None:
1806 conn_error(c, 'MODE without sender: %s' % msg)
1807 return
1808 if is_chan(target_lc):
1809 if target_lc not in conn_channels(c):
1810 conn_error(c, 'MODE to unknown channel: %s' % msg)
1811 return
1812 conn_log_msg(c, target_lc, m_chanmode(prefix, target, modes))
1813 else:
1814 if not target_lc == prefix == conn_nick(c):
1815 conn_error(c, 'MODE to unknown target: %s' % msg)
1816 return
1817 conn_log_msg(c, None, m_mode(prefix, modes))
1818
1819 def part():
1820 if len(params) == 1:
1821 comment = ''
1822 elif len(params) == 2:
1823 comment = params[1]
1824 else:
1825 conn_error(c, 'PART with bad parameter count: %s' % msg)
1826 return
1827 parted_chans = params[0].split(',')
1828 if prefix is None:
1829 conn_error(c, 'PART without sender: %s' % msg)
1830 return
1831 sender_lc = conn_casemap(c, sender_nick(prefix))
1832 channels_dict = conn_channels(c)
1833 me = (sender_lc == conn_nick_lc(c))
1834 for chan in parted_chans:
1835 chan_lc = conn_casemap(c, chan)
1836 if chan_lc not in channels_dict:
1837 # drop PARTs from unknown channels (e.g. closed window)
1838 continue
1839 try:
1840 if me:
1841 del channels_dict[chan_lc]
1842 conn_info(c, 'parted %s' % chan)
1843 schedule_status_draw()
1844 else:
1845 channels_dict[chan_lc].remove(sender_lc)
1846 except KeyError:
1847 conn_error(c, 'PART non-member %s from %s' % (prefix, chan))
1848 continue
1849 conn_log_msg(c, chan_lc, m_part(prefix, chan, comment))
1850
1851 def quit_msg():
1852 if len(params) != 1:
1853 conn_error(c, 'QUIT with bad parameter count: %s' % msg)
1854 return
1855 quit_msg, = params
1856 if prefix is None:
1857 conn_error(c, 'QUIT without sender [%s]' % quit_msg)
1858 return
1859 sender_lc = conn_casemap(c, sender_nick(prefix))
1860 for chan_lc, members in conn_channels(c).items():
1861 if sender_lc in members:
1862 conn_log_msg(c, chan_lc, m_quit(prefix, quit_msg))
1863 members.remove(sender_lc)
1864
1865 def kick():
1866 if len(params) < 2 or len(params) > 3:
1867 conn_error(c, 'KICK with bad parameter count: %s' % msg)
1868 return
1869 if prefix is None:
1870 conn_error(c, 'KICK without sender: %s' % msg)
1871 return
1872 chan = params[0]
1873 chan_lc = conn_casemap(c, chan)
1874 kicked_user = params[1]
1875 kicked_user_lc = conn_casemap(c, kicked_user)
1876 comment = params[2] if len(params) == 3 else ''
1877 channels_dict = conn_channels(c)
1878 if kicked_user_lc == conn_nick_lc(c):
1879 try:
1880 del channels_dict[chan_lc]
1881 except KeyError:
1882 conn_error(c, 'KICK from unknown channel %s by %s [%s]'
1883 % (chan, prefix, comment))
1884 return
1885 schedule_status_draw()
1886 conn_log_msg(c, chan_lc, m_kicked(prefix, chan, comment))
1887 else:
1888 if chan_lc not in channels_dict:
1889 conn_error(c, 'KICK %s from unknown channel %s by %s [%s]'
1890 % (kicked_user, chan, prefix, comment))
1891 return
1892 try:
1893 channels_dict[chan_lc].remove(kicked_user_lc)
1894 except KeyError:
1895 conn_error(c, 'KICK non-member %s from %s by %s [%s]'
1896 % (kicked_user, chan, prefix, comment))
1897 return
1898 conn_log_msg(c, chan_lc,
1899 m_kick(prefix, chan, kicked_user, comment))
1900
1901 def nick():
1902 if len(params) != 1:
1903 conn_error(c, 'NICK with bad parameter count: %s' % msg)
1904 return
1905 new_nick, = params
1906 if prefix is None:
1907 conn_error(c, 'NICK without sender: %s' % msg)
1908 return
1909 sender = sender_nick(prefix)
1910 new_nick_lc = conn_casemap(c, new_nick)
1911 sender_lc = conn_casemap(c, sender)
1912 if sender_lc == conn_nick_lc(c):
1913 conn_info(c, 'nick changed to %s' % new_nick)
1914 conn_set_nick(c, new_nick)
1915 schedule_status_draw()
1916 for chan_lc, members in conn_channels(c).items():
1917 if sender_lc in members:
1918 members.remove(sender_lc)
1919 members.add(new_nick_lc)
1920 conn_log_msg(c, chan_lc, m_nick(sender, new_nick))
1921
1922 def topic():
1923 if len(params) != 2:
1924 conn_error(c, '(RPL_(NO))TOPIC with bad parameter count: %s' % msg)
1925 chan = params[0]
1926 topic = None
1927 if cmd != str(RPL_NOTOPIC):
1928 topic = params[1]
1929 chan_lc = conn_casemap(c, chan)
1930 if chan_lc in conn_channels(c):
1931 buf_set_title(get_buf(chan_lc, conn_network(c)), topic or chan_lc)
1932 conn_log_msg(c, chan_lc, m_topic(prefix or '', topic))
1933 else:
1934 conn_log_msg(c, None, m_chantopic(prefix or '', chan, topic))
1935
1936 def unknown_command():
1937 conn_info(c, 'unknown command from server: %s' % msg)
1938
1939 def unknown_reply():
1940 conn_log_msg(c, None, m_server(prefix or '', ' '.join([cmd] + params)))
1941
1942 if len(cmd) == 3 and all(map(is_digit, cmd)):
1943 # Replies
1944 if not params:
1945 conn_error(c, 'reply %s with no destination' % cmd)
1946 return
1947 destination = params.pop(0)
1948 {
1949 RPL_WELCOME: welcome,
1950 RPL_NAMREPLY: names_reply,
1951 RPL_ENDOFNAMES: end_names,
1952 RPL_TOPIC: topic,
1953 RPL_NOTOPIC: topic,
1954 #ERR_NICKNAMEINUSE:
1955 #ERR_NICKCOLLISION:
1956 #ERR_NOTREGISTERED:
1957 }.get(int(cmd), unknown_reply)()
1958 else:
1959 {
1960 'error': error_msg,
1961 'ping': ping,
1962 'pong': pong,
1963 'privmsg': privmsg,
1964 'notice': privmsg,
1965 'join': join,
1966 'mode': mode,
1967 'part': part,
1968 'quit': quit_msg,
1969 'kick': kick,
1970 'nick': nick,
1971 'topic': topic,
1972 }.get(cmd, unknown_command)()
1973
1974def conn_join(c, chan, key=None):
1975 conn_info(c, 'joining %s' % chan)
1976 conn_send(c, 'JOIN', [chan] if key is None else [chan, key])
1977
1978def conn_privmsg(c, target, msg):
1979 # There is NO SANE WAY to deduce the max privmsg length, go figure.
1980 for line in wrap(msg, 400):
1981 conn_log_msg(c, target, m_privmsg(conn_nick(c), line))
1982 conn_send(c, 'PRIVMSG', [target, line])
1983
1984def conn_ping(c):
1985 conn_set_ping_ts(c, mono_time())
1986 conn_send(c, 'PING', [conn_nick(c)])
1987 conn_run_in(c, PING_TIMEOUT, conn_timeout)
1988
1989def conn_timeout(c):
1990 if mono_time() - conn_pong_ts(c) > PING_TIMEOUT:
1991 conn_error(c, 'ping timed out')
1992 conn_close(c)
1993
1994def conn_reg_timeout(c):
1995 if mono_time() - max(conn_pong_ts(c), conn_ping_ts(c)) > PING_TIMEOUT:
1996 conn_error(c, 'registration timed out')
1997 conn_close(c)
1998
1999# Log file message formatting is simpler than UI as the message type and
2000# sender are always shown explicitly.
2001arg2 = lambda a, b: b
2002arg3 = lambda a, b, c: c
2003empty2 = lambda a, b: ''
2004def lterr(*_):
2005 raise TypeError('bad log message type')
2006
2007format_log_msg = matcher(message, (
2008 (m_privmsg, arg2),
2009 (m_notice, arg2),
2010 (m_join, empty2),
2011 (m_part, arg3),
2012 (m_quit, arg2),
2013 (m_nick, arg2),
2014 (m_kick, lambda sender, chan, name, msg: name + ' ' + msg),
2015 (m_kicked, arg3),
2016 (m_topic, lambda sender, topic: topic or ''),
2017 (m_chantopic, lterr),
2018 (m_mode, lterr),
2019 (m_chanmode, arg3),
2020 (m_names, arg3),
2021 (m_endnames, empty2),
2022 (m_error, arg2),
2023 (m_client, lterr),
2024 (m_server, arg2)))
2025
2026def file_log_msg(network, venue, m):
2027 if venue is None or len(venue) == 0:
2028 return
2029 path = ('logs', network, fs_encode(venue) + '.log')
2030 # Possible optimization: cache log FDs and check freshness by comparing
2031 # (dev, inode) with stat() on each write. Exceeding the max FDs rlimit
2032 # would need to be handled, e.g. by LRU eviction using a minheap with
2033 # global write counter.
2034 def open_log():
2035 return os_open('/'.join(path), O_WRONLY | O_APPEND | O_CREAT, 0666)
2036 try:
2037 try:
2038 fd = open_log()
2039 except EnvironmentError, e:
2040 if e.errno != ENOENT:
2041 raise
2042 try:
2043 mkdir(path[0])
2044 except EnvironmentError, e:
2045 if e.errno != EEXIST:
2046 raise
2047 mkdir('/'.join(path[:2]))
2048 fd = open_log()
2049 try:
2050 write_all(fd, ''.join((
2051 str(int(time()*100)),
2052 ' ', variant_name(m),
2053 ' <', variant_args(m)[0], '> ', format_log_msg(m), LF)))
2054 except EnvironmentError, e:
2055 error(e)
2056 close(fd)
2057 except EnvironmentError, e:
2058 error(e)
2059
2060def handle_resize(*_):
2061 global scr_height, scr_width
2062 scr_height, scr_width = t.screen_size()
2063 # Re-clip scrolling as last line may now fit above viewport
2064 max_vsc = buf_max_vscroll(cur_buf)
2065 if buf_vscroll(cur_buf) > max_vsc:
2066 buf_set_vscroll(cur_buf, max_vsc)
2067 # Stop autoscrolling, to be resumed by buf_draw if it turns out the last line is still on screen
2068 buf_clr_at_end(cur_buf)
2069 schedule_redraw()
2070 # Wake main event loop
2071 try:
2072 write(self_pipe_wr, '\0')
2073 except EnvironmentError, e:
2074 if e.errno not in (EAGAIN, EWOULDBLOCK):
2075 raise
2076
2077# Poor man's monotonic clock. Doesn't give real time, but at least avoids
2078# overly delayed events if the clock is stepped backward.
2079mono_last = mono_offset = 0
2080def mono_time(time_=time):
2081 global mono_last, mono_offset
2082 now = time_() + mono_offset
2083 if now < mono_last:
2084 mono_offset += mono_last - now
2085 now = mono_last
2086 mono_last = now
2087 return now
2088
2089schedule = []
2090run_in = lambda seconds, thunk: heap_insert(schedule, mono_time() + seconds,
2091 thunk)
2092
2093def main():
2094 yrc_home = getenv('HOME', '') + '/.yrc'
2095 try:
2096 chdir(yrc_home)
2097 except EnvironmentError:
2098 mkdir(yrc_home)
2099 chdir(yrc_home)
2100
2101 set_nonblock(self_pipe_rd)
2102 set_nonblock(self_pipe_wr)
2103 signal(SIGINT, SIG_DFL)
2104 signal(SIGWINCH, handle_resize)
2105 handle_resize()
2106
2107 info('Welcome to yrc: the Unix IRC client')
2108 info('Version: %s' % __version__)
2109 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")
2110 info('Type /quit<Enter> to exit')
2111 prompt_enter()
2112
2113 while not quit_flag():
2114 refresh_if_needed()
2115 timeout = None
2116 if schedule:
2117 timeout = max(0, heap_peek(schedule)[0] - mono_time())
2118 try:
2119 readers, writers, _ = select(
2120 [STDIN, self_pipe_rd] + open_conns.keys(),
2121 opening_conns.keys() +
2122 [fd for fd, c in open_conns.items() if conn_wrbuf(c)],
2123 (), timeout)
2124 except SelectError, e:
2125 if e[0] == EINTR:
2126 continue
2127 raise
2128 for fd in readers:
2129 if fd == STDIN:
2130 try:
2131 data = read_all(fd)
2132 except (EOFError, EnvironmentError):
2133 schedule_quit()
2134 for c in data:
2135 kbd_state(c)
2136 elif fd == self_pipe_rd:
2137 read_all(self_pipe_rd)
2138 elif fd in open_conns:
2139 c = open_conns[fd]
2140 try:
2141 data = read_all(fd)
2142 except EOFError:
2143 conn_info(c, 'connection closed by server')
2144 conn_close(c)
2145 except EnvironmentError, e:
2146 conn_error(c, str(e))
2147 conn_close(c)
2148 else:
2149 conn_handle_data(c, data)
2150 for fd in writers:
2151 if fd in opening_conns:
2152 conn_handle_connected(opening_conns[fd])
2153 elif fd in open_conns:
2154 c = open_conns[fd]
2155 try:
2156 n = write(fd, conn_wrbuf(c))
2157 except EnvironmentError, e:
2158 if e.errno not in (EAGAIN, EWOULDBLOCK):
2159 conn_error(c, str(e))
2160 conn_close(c)
2161 else:
2162 conn_wrbuf_rm(c, n)
2163 while schedule and heap_peek(schedule)[0] < mono_time():
2164 heap_extract(schedule)[1]()
2165
2166def crash_handler():
2167 cleanup = t.init(nonblock_read=True)
2168 try:
2169 main()
2170 finally:
2171 cleanup()
2172
2173check_command_dicts((
2174 GLOBAL_KEYS,
2175 PROMPT_KEYS,
2176 BUFLIST_KEYS,
2177 BUF_KEYS,
2178 CMD_ABBREVS
2179))