Projects : yrc : yrc_input_history

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