Projects : yrc : yrc_net_level_commands

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 conn_start(new_conn(net, addrs, nick, pw))
1565
1566@command('/disconnect')
1567def disconnect_cmd():
1568 # 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.
1569 c = buf_conn(cur_buf)
1570 del network_conns[conn_network(c)]
1571 conn_close(c)
1572
1573@command('/join', 1, 2)
1574def join_cmd(chan, key=None):
1575 if not valid_chan(chan):
1576 raise CommandError('join: bad channel name: %s' % chan)
1577 conn_join(buf_registered_conn(cur_buf), chan, key)
1578
1579@command('/kick', 1, extended_arg=True)
1580def kick_cmd(user, msg=''):
1581 chan = buf_name(cur_buf)
1582 if buf_parent(cur_buf) is None or not is_chan(chan):
1583 raise CommandError('kick: this window not a channel')
1584 conn_send(buf_registered_conn(cur_buf), 'KICK', [chan, user, msg])
1585
1586@command('/list', 0, 1)
1587def list_cmd(*args):
1588 conn_send(buf_registered_conn(cur_buf), 'LIST', args)
1589
1590@command('/me', extended_arg=True)
1591def me_cmd(msg=''):
1592 buf_privmsg(cur_buf, '\x01ACTION %s\x01' % msg)
1593
1594@command('/mode', 1, 3)
1595def mode_cmd(*args):
1596 conn_send(buf_registered_conn(cur_buf), 'MODE', args)
1597
1598@command('/msg', 1, extended_arg=True)
1599def msg_cmd(target, msg=None):
1600 c = buf_registered_conn(cur_buf)
1601 if is_chan(target) and not valid_chan(target):
1602 raise CommandError('msg: bad channel name: %s' % target)
1603 if msg:
1604 conn_privmsg(c, target, msg)
1605 buflist_select(get_buf(target, conn_network(c)))
1606
1607@command('/names', 0, 1)
1608def names_cmd(*args):
1609 conn_send(buf_registered_conn(cur_buf), 'NAMES', args)
1610
1611@command('/nick', 1)
1612def nick_cmd(nick):
1613 c = buf_conn(cur_buf)
1614 if conn_sock(c) is None:
1615 schedule_status_draw()
1616 conn_set_nick(c, nick)
1617 conn_info(c, 'nick changed to %s for next reconnection' % nick)
1618 else:
1619 if not conn_registered(c):
1620 schedule_status_draw()
1621 conn_set_nick(c, nick)
1622 conn_info(c, 'nick changed to %s' % nick)
1623 conn_set_ping_ts(c, mono_time())
1624 conn_run_in(c, PING_TIMEOUT, conn_reg_timeout)
1625 conn_send(c, 'NICK', [nick])
1626
1627@command('/part', extended_arg=True)
1628def part_cmd(msg=''):
1629 chan = buf_name(cur_buf)
1630 if buf_parent(cur_buf) is None or not is_chan(chan):
1631 raise CommandError('part: this window not a channel')
1632 conn_send(buf_registered_conn(cur_buf), 'PART', [chan, msg])
1633
1634@command('/quit')
1635def quit_cmd():
1636 # 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.
1637 schedule_quit()
1638
1639@command('/reconnect')
1640def reconnect_cmd():
1641 conn_close(buf_conn(cur_buf))
1642
1643@command('/send', extended_arg=True)
1644def send_cmd(line):
1645 if len(line) > MAX_MSG_LEN:
1646 raise CommandError('send: line too long')
1647 conn_write(buf_registered_conn(cur_buf), line)
1648
1649@command('/topic', 0, 1)
1650def topic_cmd(chan=None):
1651 if chan is None:
1652 chan = buf_name(cur_buf)
1653 if buf_parent(cur_buf) is None or not is_chan(chan):
1654 raise CommandError(
1655 'topic: this window not a channel and none specified')
1656 conn_send(buf_registered_conn(cur_buf), 'TOPIC', [chan])
1657
1658@command('/set-topic', extended_arg=True)
1659def set_topic_cmd(topic=''):
1660 chan = buf_name(cur_buf)
1661 if buf_parent(cur_buf) is None or not is_chan(chan):
1662 raise CommandError('set-topic: this window not a channel')
1663 conn_send(buf_registered_conn(cur_buf), 'TOPIC', [chan, topic])
1664
1665@command('/whois', 1)
1666def whois_cmd(nick):
1667 conn_send(buf_registered_conn(cur_buf), 'WHOIS', [nick])
1668
1669@command('/whowas', 1)
1670def whowas_cmd(nick):
1671 conn_send(buf_registered_conn(cur_buf), 'WHOWAS', [nick])
1672
1673@command('/close', extended_arg=True)
1674def close_cmd(msg=''):
1675 parent = buf_parent(cur_buf)
1676 if parent is None:
1677 raise CommandError(
1678 "close: won't close a top-level window (try close-net)")
1679 venue = buf_name(cur_buf)
1680 try:
1681 c = buf_registered_conn(cur_buf)
1682 except CommandError:
1683 pass
1684 else:
1685 if venue in conn_channels(c):
1686 conn_send(c, 'PART', [venue, msg])
1687 del conn_channels(c)[venue]
1688 close_buf(cur_buf)
1689
1690@command('/close-net')
1691def close_net_cmd():
1692 disconnect_cmd()
1693 # Get the top (network) level window
1694 parent = buf_parent(cur_buf)
1695 if parent is None:
1696 # Already on it
1697 parent = cur_buf
1698 # Close it
1699 close_buf(parent)
1700 # Close the children (chats and PMs)
1701 for b in buffers[:]: # Copy because we mutate original list as we go
1702 if buf_parent(b) is parent:
1703 close_buf(b)
1704
1705def place_cursor():
1706 if buflist_flag():
1707 write_out(t.cursor_pos(
1708 max(1, scr_height - 2),
1709 clip(1, scr_width, buflist_width() - 1)))
1710 elif prompt_flag():
1711 write_out(t.cursor_pos(scr_height, prompt_cursor_column()))
1712 else:
1713 write_out(t.cursor_pos(max(1, scr_height - 2), max(1, scr_width)))
1714
1715def refresh_if_needed():
1716 if redraw_flag():
1717 write_out(t.ERASE_FULL)
1718 buf_draw(cur_buf)
1719 buflist_vline_draw()
1720 buflist_draw()
1721 status_draw()
1722 prompt_draw()
1723 redraw_done()
1724 else:
1725 if buf_draw_flag():
1726 buf_draw(cur_buf)
1727 if buflist_draw_flag():
1728 buflist_draw()
1729 if status_draw_flag():
1730 status_draw()
1731 if prompt_draw_flag():
1732 prompt_draw()
1733 if refresh_flag():
1734 place_cursor()
1735 flush_out()
1736 refresh_done()
1737
1738#
1739# IRC stuff
1740#
1741
1742RPL_WELCOME = 1
1743RPL_NOTOPIC = 331
1744RPL_TOPIC = 332
1745RPL_NAMREPLY = 353
1746RPL_ENDOFNAMES = 366
1747ERR_NICKNAMEINUSE = 433
1748ERR_NICKCOLLISION = 436
1749ERR_NOTREGISTERED = 451
1750
1751MAX_MSG_LEN = 510
1752MAX_CHAN_LEN = 50
1753MAX_NICK_LEN = 31
1754
1755IRC_ILLEGAL = NUL + CR + LF
1756# Oddly, HT or other controls are allowed in chan names by both rfc1459 and rfc2812.
1757CHAN_ILLEGAL = IRC_ILLEGAL + BEL + ' ,:'
1758# '+!' are found in rfc2812 but not rfc1459.
1759CHAN_START = '&#+!'
1760# rfc1459 allows -[]\`^{} in nicks but requires a letter to start.
1761# rfc2812 allows specials at start too except for '-', and adds '_|'.
1762IRC_SPECIAL = '[]\\`_^{|}'
1763
1764is_nick_start = lambda c: is_alpha(c) or c in IRC_SPECIAL
1765is_nick_body = lambda c: is_nick_start(c) or is_digit(c) or c == '-'
1766
1767class ProtocolError(Exception):
1768 pass
1769
1770def build_msg(prefix, cmd, params):
1771 """Build an IRC wire message.
1772
1773 Conditions caller must enforce:
1774 * No args may contain NUL, CR, or LF
1775 * Only last param may be empty, contain spaces, or start with :
1776 * Valid cmd
1777 * 15 parameters max
1778 """
1779 parts = []
1780 if prefix is not None:
1781 parts.append(':' + prefix)
1782 parts.append(cmd)
1783 if len(params):
1784 parts.extend(params[:-1])
1785 parts.append(':' + params[-1])
1786 return ' '.join(parts)
1787
1788def max_param_len(cmd, prefix=None):
1789 # colon before prefix + space after prefix
1790 prefix_len = 0 if prefix is None else len(prefix) + 2
1791 # space after cmd + colon before last param
1792 return MAX_MSG_LEN - prefix_len - len(cmd) - 2
1793
1794def parse_msg(msg):
1795 if any(c in msg for c in IRC_ILLEGAL):
1796 raise ProtocolError('illegal character in message')
1797 start = 0
1798 end = len(msg)
1799 def next_word():
1800 cut = msg.find(' ', start)
1801 if cut == -1:
1802 cut = end
1803 return cut + 1, msg[start:cut]
1804 if msg.startswith(':'):
1805 start = 1
1806 start, prefix = next_word()
1807 else:
1808 prefix = None
1809 start, cmd = next_word()
1810 if not cmd:
1811 raise ProtocolError('message with no command')
1812 params = []
1813 while start < end:
1814 if msg[start] == ':':
1815 params.append(msg[start+1:])
1816 break
1817 start, param = next_word()
1818 params.append(param)
1819 return prefix, casemap_ascii(cmd), params
1820
1821is_chan = lambda n: len(n) > 0 and n[0] in CHAN_START
1822
1823def valid_chan(n):
1824 return is_chan(n) and len(n) <= MAX_CHAN_LEN \
1825 and not any(c in CHAN_ILLEGAL for c in n)
1826
1827def valid_nick(n):
1828 return 0 < len(n) <= MAX_NICK_LEN \
1829 and is_nick_start(n[0]) \
1830 and all(is_nick_body(c) for c in n[1:])
1831
1832def valid_password(p):
1833 return p is None or (
1834 len(p) <= max_param_len('PASS')
1835 and not any(c in IRC_ILLEGAL for c in p))
1836
1837sender_nick = lambda s: s.split('!', 1)[0]
1838
1839#
1840# Networking / main loop
1841#
1842
1843set_nonblock = lambda fd: fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK)
1844
1845def read_all(fd):
1846 chunks = []
1847 try:
1848 chunk = read(fd, 4096)
1849 if not chunk:
1850 raise EOFError
1851 while chunk:
1852 chunks.append(chunk)
1853 chunk = read(fd, 4096)
1854 except EnvironmentError, e:
1855 if e.errno not in (EAGAIN, EWOULDBLOCK):
1856 raise
1857 return ''.join(chunks)
1858
1859def new_conn(network, addrs, nick, password=None):
1860 i = rand_int(len(addrs))
1861 addrs = addrs[i:] + addrs[:i]
1862 return [network, None, '', '', addrs, nick, password, False, dict(),
1863 IRC_CASEMAP['rfc1459'], RECONN_DELAY_MIN, 0, 0, 0]
1864
1865conn_network = lambda c: c[0]
1866conn_sock = lambda c: c[1]
1867conn_rdbuf = lambda c: c[2]
1868conn_wrbuf = lambda c: c[3]
1869conn_addrs = lambda c: c[4]
1870conn_nick = lambda c: c[5]
1871conn_password = lambda c: c[6]
1872conn_registered = lambda c: c[7]
1873conn_channels = lambda c: c[8]
1874conn_casemap = lambda c, s: c[9](s)
1875conn_count = lambda c: c[11]
1876conn_ping_ts = lambda c: c[12]
1877conn_pong_ts = lambda c: c[13]
1878
1879conn_set_sock = lambda c, s: c.__setitem__(1, s)
1880conn_rdbuf_add = lambda c, b: c.__setitem__(2, c[2] + b)
1881conn_rdbuf_rm = lambda c, n: c.__setitem__(2, c[2][n:])
1882conn_wrbuf_add = lambda c, b: c.__setitem__(3, c[3] + b)
1883conn_wrbuf_rm = lambda c, n: c.__setitem__(3, c[3][n:])
1884conn_set_nick = lambda c, n: c.__setitem__(5, n)
1885conn_set_registered = lambda c: c.__setitem__(7, True)
1886conn_clr_registered = lambda c: c.__setitem__(7, False)
1887conn_reset_reconn_delay = lambda c: c.__setitem__(10, RECONN_DELAY_MIN)
1888conn_set_count = lambda c, n: c.__setitem__(11, n)
1889conn_set_ping_ts = lambda c, t: c.__setitem__(12, t)
1890conn_set_pong_ts = lambda c, t: c.__setitem__(13, t)
1891
1892def conn_reconn_delay(c):
1893 # limited exponential backoff
1894 d = c[10]
1895 c[10] = min(2*d, RECONN_DELAY_MAX)
1896 return d
1897
1898conn_nick_lc = lambda c: conn_casemap(c, conn_nick(c))
1899
1900def conn_run_in(c, delay, method, run_if_down=False):
1901 count = conn_count(c)
1902 def run():
1903 # Drop leftover tasks from old connections
1904 if c is network_conns.get(conn_network(c)) and conn_count(c) == count \
1905 and (run_if_down or conn_sock(c) is not None):
1906 method(c)
1907 run_in(delay, run)
1908
1909def conn_log_msg(c, venue, m):
1910 network = conn_network(c)
1911 if venue is None:
1912 buf = get_buf(network, None)
1913 else:
1914 buf = get_buf(venue, network)
1915 buf_log_msg(buf, m)
1916 file_log_msg(network, venue, m)
1917
1918def conn_info(c, msg):
1919 conn_log_msg(c, None, m_client(msg))
1920
1921def conn_error(c, msg):
1922 conn_log_msg(c, None, m_client('ERROR: ' + msg))
1923
1924opening_conns = {}
1925network_conns = {}
1926def conn_start(c):
1927 schedule_buflist_draw()
1928 schedule_status_draw()
1929 net = conn_network(c)
1930 assert conn_sock(c) is None, 'socket exists when starting connection'
1931 sock = socket()
1932 set_nonblock(sock.fileno())
1933 conn_set_sock(c, sock)
1934 conn_set_count(c, conn_count(c) + 1)
1935 addrs = conn_addrs(c)
1936 addrs.append(addrs.pop(0))
1937 conn_info(c, 'connecting to %s' % format_address(addrs[0]))
1938 network_conns[net] = opening_conns[sock.fileno()] = c
1939 try:
1940 sock.connect(addrs[0]) # TODO async DNS
1941 except SockError, e:
1942 if e.errno != EINPROGRESS:
1943 del opening_conns[sock.fileno()]
1944 conn_error(c, e.strerror)
1945 else:
1946 conn_handle_connected(c)
1947
1948def conn_write(c, msg):
1949 if len(msg) > MAX_MSG_LEN:
1950 msg = msg[:MAX_MSG_LEN]
1951 conn_error(c, 'outbound message truncated')
1952 conn_wrbuf_add(c, msg + CRLF)
1953
1954def conn_send(c, cmd, params, prefix=None):
1955 conn_write(c, build_msg(prefix, cmd, params))
1956
1957open_conns = {}
1958def conn_handle_connected(c):
1959 n = conn_nick(c)
1960 p = conn_password(c)
1961 s = conn_sock(c)
1962 e = s.getsockopt(SOL_SOCKET, SO_ERROR)
1963 if e == EINPROGRESS:
1964 return
1965 schedule_buflist_draw()
1966 schedule_status_draw()
1967 del opening_conns[s.fileno()]
1968 if e != 0:
1969 conn_error(c, strerror(e))
1970 conn_close(c)
1971 return
1972 conn_reset_reconn_delay(c)
1973 open_conns[s.fileno()] = c
1974 conn_info(c, 'connection established')
1975 conn_set_ping_ts(c, mono_time())
1976 conn_run_in(c, PING_TIMEOUT, conn_reg_timeout)
1977 if p is not None:
1978 conn_send(c, 'PASS', [p])
1979 conn_send(c, 'NICK', [n])
1980 conn_send(c, 'USER', [n, '0', '*', n])
1981
1982def conn_close(c):
1983 sock = conn_sock(c)
1984 if sock is None:
1985 return
1986 schedule_buflist_draw()
1987 schedule_status_draw()
1988 fd = sock.fileno()
1989 if fd in open_conns:
1990 del open_conns[fd]
1991 elif fd in opening_conns:
1992 del opening_conns[fd]
1993 try:
1994 sock.shutdown(SHUT_RDWR)
1995 except SockError:
1996 pass
1997 sock.close()
1998 conn_set_sock(c, None)
1999 conn_rdbuf_rm(c, len(conn_rdbuf(c)))
2000 conn_wrbuf_rm(c, len(conn_wrbuf(c)))
2001 conn_clr_registered(c)
2002 conn_info(c, 'connection closed')
2003 if conn_network(c) in network_conns:
2004 delay = conn_reconn_delay(c)
2005 conn_run_in(c, delay, conn_start, True)
2006 conn_info(c, 'reconnecting in %d s' % delay)
2007
2008def conn_handle_data(c, data):
2009 conn_rdbuf_add(c, data)
2010 data = conn_rdbuf(c)
2011 start = 0
2012 while start < len(data):
2013 end = data.find(CRLF, start)
2014 if end == -1:
2015 if len(data) - start >= MAX_MSG_LEN:
2016 conn_error(c, 'received oversize message')
2017 conn_close(c)
2018 return
2019 break
2020 if end > start:
2021 try:
2022 conn_handle_msg(c, data[start:end])
2023 except ProtocolError, e:
2024 conn_error(c, e)
2025 start = end + 2
2026 conn_rdbuf_rm(c, start)
2027
2028def conn_handle_msg(c, msg):
2029 #pylint: disable=unbalanced-tuple-unpacking,too-many-locals
2030 prefix, cmd, params = parse_msg(msg)
2031
2032 def welcome():
2033 if destination != conn_nick(c): # for pre-welcome nick change
2034 schedule_status_draw()
2035 conn_info(c, 'nick changed to %s' % destination)
2036 conn_set_nick(c, destination)
2037 conn_log_msg(c, None, m_server(prefix or '', ' '.join(params)))
2038 if not conn_registered(c):
2039 schedule_buflist_draw()
2040 conn_set_registered(c)
2041 pong()
2042 for chan in conn_channels(c):
2043 conn_join(c, chan)
2044 conn_channels(c).clear()
2045
2046 def names_reply():
2047 if len(params) != 3:
2048 conn_error(c, 'RPL_NAMREPLY with bad parameter count: %s' % msg)
2049 return
2050 _, chan, names = params
2051 chan_lc = conn_casemap(c, chan)
2052 members = conn_channels(c).get(chan_lc)
2053 conn_log_msg(c, None if members is None else chan_lc,
2054 m_names(prefix or '', chan, names))
2055 if members is not None:
2056 for nick in names.split(' '):
2057 if not nick:
2058 conn_error(c, 'RPL_NAMREPLY with empty nick')
2059 break
2060 if nick[0] in '@+':
2061 nick = nick[1:]
2062 members.add(conn_casemap(c, nick))
2063
2064 def end_names():
2065 if len(params) != 2:
2066 conn_error(c, 'RPL_ENDOFNAMES with bad parameter count: %s' % msg)
2067 return
2068 chan = params[0]
2069 chan_lc = conn_casemap(c, chan)
2070 conn_log_msg(c, chan_lc if chan_lc in conn_channels(c) else None,
2071 m_endnames(prefix or '', chan))
2072
2073 def error_msg():
2074 if len(params) != 1:
2075 conn_error(c, 'ERROR with bad parameter count: %s' % msg)
2076 return
2077 conn_log_msg(c, None, m_error(prefix or '', params[0]))
2078
2079 def ping():
2080 conn_send(c, 'PONG', params)
2081
2082 def pong():
2083 schedule_status_draw()
2084 conn_set_pong_ts(c, mono_time())
2085 conn_run_in(c, PING_INTERVAL, conn_ping)
2086
2087 def privmsg():
2088 if len(params) != 2:
2089 conn_error(c, 'message with bad parameter count: %s' % msg)
2090 return
2091 target, content = params
2092 target_lc = conn_casemap(c, target)
2093 if prefix is None:
2094 conn_error(c, 'message without sender: %s' % msg)
2095 return
2096 sender = sender_nick(prefix)
2097 if target_lc == conn_nick_lc(c): # PM
2098 venue = conn_casemap(c, sender)
2099 elif valid_chan(target_lc):
2100 if target_lc in conn_channels(c):
2101 venue = target_lc
2102 else:
2103 return # drop messages to unknown channels
2104 elif target_lc == '*': # not sure if standard but freenode does this
2105 venue = None
2106 else:
2107 conn_error(c, 'message to unknown target: %s' % msg)
2108 return
2109 conn_log_msg(c, venue,
2110 (m_notice if cmd == 'notice' else m_privmsg)(sender, content))
2111
2112 def join():
2113 if len(params) != 1:
2114 conn_error(c, 'JOIN with bad parameter count: %s' % msg)
2115 return
2116 chan, = params
2117 chan_lc = conn_casemap(c, chan)
2118 if prefix is None:
2119 conn_error(c, 'JOIN without sender: %s' % msg)
2120 return
2121 sender_lc = conn_casemap(c, sender_nick(prefix))
2122 channels_dict = conn_channels(c)
2123 if sender_lc == conn_nick_lc(c):
2124 if chan_lc in channels_dict:
2125 conn_error(c, 'JOIN to already joined channel %s' % chan)
2126 return
2127 channels_dict[chan_lc] = set()
2128 else:
2129 if chan_lc not in channels_dict:
2130 conn_error(c, 'JOIN %s to unknown channel %s' % (prefix, chan))
2131 return
2132 channels_dict[chan_lc].add(sender_lc)
2133 conn_log_msg(c, chan_lc, m_join(prefix, chan))
2134
2135 def mode():
2136 if len(params) < 1:
2137 conn_error(c, 'MODE with bad parameter count: %s' % msg)
2138 return
2139 target = params[0]
2140 modes = ' '.join(params[1:])
2141 target_lc = conn_casemap(c, target)
2142 if prefix is None:
2143 conn_error(c, 'MODE without sender: %s' % msg)
2144 return
2145 if is_chan(target_lc):
2146 if target_lc not in conn_channels(c):
2147 conn_error(c, 'MODE to unknown channel: %s' % msg)
2148 return
2149 conn_log_msg(c, target_lc, m_chanmode(prefix, target, modes))
2150 else:
2151 if not target_lc == prefix == conn_nick(c):
2152 conn_error(c, 'MODE to unknown target: %s' % msg)
2153 return
2154 conn_log_msg(c, None, m_mode(prefix, modes))
2155
2156 def part():
2157 if len(params) == 1:
2158 comment = ''
2159 elif len(params) == 2:
2160 comment = params[1]
2161 else:
2162 conn_error(c, 'PART with bad parameter count: %s' % msg)
2163 return
2164 parted_chans = params[0].split(',')
2165 if prefix is None:
2166 conn_error(c, 'PART without sender: %s' % msg)
2167 return
2168 sender_lc = conn_casemap(c, sender_nick(prefix))
2169 channels_dict = conn_channels(c)
2170 me = (sender_lc == conn_nick_lc(c))
2171 for chan in parted_chans:
2172 chan_lc = conn_casemap(c, chan)
2173 if chan_lc not in channels_dict:
2174 # drop PARTs from unknown channels (e.g. closed window)
2175 continue
2176 try:
2177 if me:
2178 del channels_dict[chan_lc]
2179 conn_info(c, 'parted %s' % chan)
2180 schedule_status_draw()
2181 else:
2182 channels_dict[chan_lc].remove(sender_lc)
2183 except KeyError:
2184 conn_error(c, 'PART non-member %s from %s' % (prefix, chan))
2185 continue
2186 conn_log_msg(c, chan_lc, m_part(prefix, chan, comment))
2187
2188 def quit_msg():
2189 if len(params) != 1:
2190 conn_error(c, 'QUIT with bad parameter count: %s' % msg)
2191 return
2192 quit_msg, = params
2193 if prefix is None:
2194 conn_error(c, 'QUIT without sender [%s]' % quit_msg)
2195 return
2196 sender_lc = conn_casemap(c, sender_nick(prefix))
2197 for chan_lc, members in conn_channels(c).items():
2198 if sender_lc in members:
2199 conn_log_msg(c, chan_lc, m_quit(prefix, quit_msg))
2200 members.remove(sender_lc)
2201
2202 def kick():
2203 if len(params) < 2 or len(params) > 3:
2204 conn_error(c, 'KICK with bad parameter count: %s' % msg)
2205 return
2206 if prefix is None:
2207 conn_error(c, 'KICK without sender: %s' % msg)
2208 return
2209 chan = params[0]
2210 chan_lc = conn_casemap(c, chan)
2211 kicked_user = params[1]
2212 kicked_user_lc = conn_casemap(c, kicked_user)
2213 comment = params[2] if len(params) == 3 else ''
2214 channels_dict = conn_channels(c)
2215 if kicked_user_lc == conn_nick_lc(c):
2216 try:
2217 del channels_dict[chan_lc]
2218 except KeyError:
2219 conn_error(c, 'KICK from unknown channel %s by %s [%s]'
2220 % (chan, prefix, comment))
2221 return
2222 schedule_status_draw()
2223 conn_log_msg(c, chan_lc, m_kicked(prefix, chan, comment))
2224 else:
2225 if chan_lc not in channels_dict:
2226 conn_error(c, 'KICK %s from unknown channel %s by %s [%s]'
2227 % (kicked_user, chan, prefix, comment))
2228 return
2229 try:
2230 channels_dict[chan_lc].remove(kicked_user_lc)
2231 except KeyError:
2232 conn_error(c, 'KICK non-member %s from %s by %s [%s]'
2233 % (kicked_user, chan, prefix, comment))
2234 return
2235 conn_log_msg(c, chan_lc,
2236 m_kick(prefix, chan, kicked_user, comment))
2237
2238 def nick():
2239 if len(params) != 1:
2240 conn_error(c, 'NICK with bad parameter count: %s' % msg)
2241 return
2242 new_nick, = params
2243 if prefix is None:
2244 conn_error(c, 'NICK without sender: %s' % msg)
2245 return
2246 sender = sender_nick(prefix)
2247 new_nick_lc = conn_casemap(c, new_nick)
2248 sender_lc = conn_casemap(c, sender)
2249 if sender_lc == conn_nick_lc(c):
2250 conn_info(c, 'nick changed to %s' % new_nick)
2251 conn_set_nick(c, new_nick)
2252 schedule_status_draw()
2253 for chan_lc, members in conn_channels(c).items():
2254 if sender_lc in members:
2255 members.remove(sender_lc)
2256 members.add(new_nick_lc)
2257 conn_log_msg(c, chan_lc, m_nick(sender, new_nick))
2258
2259 def topic():
2260 if len(params) != 2:
2261 conn_error(c, '(RPL_(NO))TOPIC with bad parameter count: %s' % msg)
2262 chan = params[0]
2263 topic = None
2264 if cmd != str(RPL_NOTOPIC):
2265 topic = params[1]
2266 chan_lc = conn_casemap(c, chan)
2267 if chan_lc in conn_channels(c):
2268 buf_set_title(get_buf(chan_lc, conn_network(c)), topic or chan_lc)
2269 conn_log_msg(c, chan_lc, m_topic(prefix or '', topic))
2270 else:
2271 conn_log_msg(c, None, m_chantopic(prefix or '', chan, topic))
2272
2273 def unknown_command():
2274 conn_info(c, 'unknown command from server: %s' % msg)
2275
2276 def unknown_reply():
2277 conn_log_msg(c, None, m_server(prefix or '', ' '.join([cmd] + params)))
2278
2279 if len(cmd) == 3 and all(map(is_digit, cmd)):
2280 # Replies
2281 if not params:
2282 conn_error(c, 'reply %s with no destination' % cmd)
2283 return
2284 destination = params.pop(0)
2285 {
2286 RPL_WELCOME: welcome,
2287 RPL_NAMREPLY: names_reply,
2288 RPL_ENDOFNAMES: end_names,
2289 RPL_TOPIC: topic,
2290 RPL_NOTOPIC: topic,
2291 #ERR_NICKNAMEINUSE:
2292 #ERR_NICKCOLLISION:
2293 #ERR_NOTREGISTERED:
2294 }.get(int(cmd), unknown_reply)()
2295 else:
2296 {
2297 'error': error_msg,
2298 'ping': ping,
2299 'pong': pong,
2300 'privmsg': privmsg,
2301 'notice': privmsg,
2302 'join': join,
2303 'mode': mode,
2304 'part': part,
2305 'quit': quit_msg,
2306 'kick': kick,
2307 'nick': nick,
2308 'topic': topic,
2309 }.get(cmd, unknown_command)()
2310
2311def conn_join(c, chan, key=None):
2312 conn_info(c, 'joining %s' % chan)
2313 conn_send(c, 'JOIN', [chan] if key is None else [chan, key])
2314
2315def conn_privmsg(c, target, msg):
2316 # There is NO SANE WAY to deduce the max privmsg length, go figure.
2317 for line in wrap(msg, 400):
2318 conn_log_msg(c, target, m_privmsg(conn_nick(c), line))
2319 conn_send(c, 'PRIVMSG', [target, line])
2320
2321def conn_ping(c):
2322 conn_set_ping_ts(c, mono_time())
2323 conn_send(c, 'PING', [conn_nick(c)])
2324 conn_run_in(c, PING_TIMEOUT, conn_timeout)
2325
2326def conn_timeout(c):
2327 if mono_time() - conn_pong_ts(c) > PING_TIMEOUT:
2328 conn_error(c, 'ping timed out')
2329 conn_close(c)
2330
2331def conn_reg_timeout(c):
2332 if mono_time() - max(conn_pong_ts(c), conn_ping_ts(c)) > PING_TIMEOUT:
2333 conn_error(c, 'registration timed out')
2334 conn_close(c)
2335
2336# Log file message formatting is simpler than UI as the message type and
2337# sender are always shown explicitly.
2338arg2 = lambda a, b: b
2339arg3 = lambda a, b, c: c
2340empty2 = lambda a, b: ''
2341def lterr(*_):
2342 raise TypeError('bad log message type')
2343
2344format_log_msg = matcher(message, (
2345 (m_privmsg, arg2),
2346 (m_notice, arg2),
2347 (m_join, empty2),
2348 (m_part, arg3),
2349 (m_quit, arg2),
2350 (m_nick, arg2),
2351 (m_kick, lambda sender, chan, name, msg: name + ' ' + msg),
2352 (m_kicked, arg3),
2353 (m_topic, lambda sender, topic: topic or ''),
2354 (m_chantopic, lterr),
2355 (m_mode, lterr),
2356 (m_chanmode, arg3),
2357 (m_names, arg3),
2358 (m_endnames, empty2),
2359 (m_error, arg2),
2360 (m_client, lterr),
2361 (m_server, arg2)))
2362
2363def file_log_msg(network, venue, m):
2364 if venue is None or len(venue) == 0:
2365 return
2366 path = ('logs', network, fs_encode(venue) + '.log')
2367 # Possible optimization: cache log FDs and check freshness by comparing
2368 # (dev, inode) with stat() on each write. Exceeding the max FDs rlimit
2369 # would need to be handled, e.g. by LRU eviction using a minheap with
2370 # global write counter.
2371 def open_log():
2372 return os_open('/'.join(path), O_WRONLY | O_APPEND | O_CREAT, 0666)
2373 try:
2374 try:
2375 fd = open_log()
2376 except EnvironmentError, e:
2377 if e.errno != ENOENT:
2378 raise
2379 try:
2380 mkdir(path[0])
2381 except EnvironmentError, e:
2382 if e.errno != EEXIST:
2383 raise
2384 mkdir('/'.join(path[:2]))
2385 fd = open_log()
2386 try:
2387 write_all(fd, ''.join((
2388 str(int(time()*100)),
2389 ' ', variant_name(m),
2390 ' <', variant_args(m)[0], '> ', format_log_msg(m), LF)))
2391 except EnvironmentError, e:
2392 error(e)
2393 close(fd)
2394 except EnvironmentError, e:
2395 error(e)
2396
2397def handle_resize(*_):
2398 global scr_height, scr_width
2399 scr_height, scr_width = t.screen_size()
2400 # Re-clip scrolling as last line may now fit above viewport
2401 max_vsc = buf_max_vscroll(cur_buf)
2402 if buf_vscroll(cur_buf) > max_vsc:
2403 buf_set_vscroll(cur_buf, max_vsc)
2404 # Stop autoscrolling, to be resumed by buf_draw if it turns out the last line is still on screen
2405 buf_clr_at_end(cur_buf)
2406 schedule_redraw()
2407 # Wake main event loop
2408 try:
2409 write(self_pipe_wr, '\0')
2410 except EnvironmentError, e:
2411 if e.errno not in (EAGAIN, EWOULDBLOCK):
2412 raise
2413
2414# Poor man's monotonic clock. Doesn't give real time, but at least avoids
2415# overly delayed events if the clock is stepped backward.
2416mono_last = mono_offset = 0
2417def mono_time(time_=time):
2418 global mono_last, mono_offset
2419 now = time_() + mono_offset
2420 if now < mono_last:
2421 mono_offset += mono_last - now
2422 now = mono_last
2423 mono_last = now
2424 return now
2425
2426schedule = []
2427run_in = lambda seconds, thunk: heap_insert(schedule, mono_time() + seconds,
2428 thunk)
2429
2430def main():
2431 yrc_home = getenv('HOME', '') + '/.yrc'
2432 try:
2433 chdir(yrc_home)
2434 except EnvironmentError:
2435 mkdir(yrc_home)
2436 chdir(yrc_home)
2437
2438 set_nonblock(self_pipe_rd)
2439 set_nonblock(self_pipe_wr)
2440 signal(SIGINT, SIG_DFL)
2441 signal(SIGWINCH, handle_resize)
2442 handle_resize()
2443
2444 info('Welcome to yrc: the Unix IRC client')
2445 info('Version: %s' % __version__)
2446 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")
2447 info('Type /quit<Enter> to exit')
2448 prompt_enter()
2449
2450 while not quit_flag():
2451 refresh_if_needed()
2452 timeout = None
2453 if schedule:
2454 timeout = max(0, heap_peek(schedule)[0] - mono_time())
2455 try:
2456 readers, writers, _ = select(
2457 [STDIN, self_pipe_rd] + open_conns.keys(),
2458 opening_conns.keys() +
2459 [fd for fd, c in open_conns.items() if conn_wrbuf(c)],
2460 (), timeout)
2461 except SelectError, e:
2462 if e[0] == EINTR:
2463 continue
2464 raise
2465 for fd in readers:
2466 if fd == STDIN:
2467 try:
2468 data = read_all(fd)
2469 except (EOFError, EnvironmentError):
2470 schedule_quit()
2471 for c in data:
2472 kbd_state(c)
2473 elif fd == self_pipe_rd:
2474 read_all(self_pipe_rd)
2475 elif fd in open_conns:
2476 c = open_conns[fd]
2477 try:
2478 data = read_all(fd)
2479 except EOFError:
2480 conn_info(c, 'connection closed by server')
2481 conn_close(c)
2482 except EnvironmentError, e:
2483 conn_error(c, str(e))
2484 conn_close(c)
2485 else:
2486 conn_handle_data(c, data)
2487 for fd in writers:
2488 if fd in opening_conns:
2489 conn_handle_connected(opening_conns[fd])
2490 elif fd in open_conns:
2491 c = open_conns[fd]
2492 try:
2493 n = write(fd, conn_wrbuf(c))
2494 except EnvironmentError, e:
2495 if e.errno not in (EAGAIN, EWOULDBLOCK):
2496 conn_error(c, str(e))
2497 conn_close(c)
2498 else:
2499 conn_wrbuf_rm(c, n)
2500 while schedule and heap_peek(schedule)[0] < mono_time():
2501 heap_extract(schedule)[1]()
2502
2503def crash_handler():
2504 cleanup = t.init(nonblock_read=True)
2505 try:
2506 main()
2507 finally:
2508 cleanup()
2509
2510check_command_dicts((
2511 GLOBAL_KEYS,
2512 PROMPT_KEYS,
2513 BUFLIST_KEYS,
2514 BUF_KEYS,
2515 CMD_ABBREVS
2516))