Projects : yrc : yrc_kill_yank

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