Projects : yrc : yrc_connect_join_streamlining

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