Projects : yrc : yrc_minor_command_reorder_2

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