Projects : yrc : yrc_input_fixes

yrc/yrc.py

Dir - Raw

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