1package main
2
3import (
4	"bytes"
5	"reflect"
6	"strconv"
7	"strings"
8	"sync"
9
10	"golang.org/x/crypto/ssh/terminal"
11)
12
13type uiCommand struct {
14	name      string
15	prototype interface{}
16	desc      string
17}
18
19var uiCommands = []uiCommand{
20	{"add", addCommand{}, "Request a subscription to another user's presence"},
21	{"away", awayCommand{}, "Set your status to Away"},
22	{"chat", chatCommand{}, "Set your status to Available for Chat"},
23	{"close", closeCommand{}, "Forget current chat target"},
24	{"confirm", confirmCommand{}, "Confirm an inbound subscription request"},
25	{"deny", denyCommand{}, "Deny an inbound subscription request"},
26	{"dnd", dndCommand{}, "Set your status to Busy / Do Not Disturb"},
27	{"help", helpCommand{}, "List known commands"},
28	{"ignore", ignoreCommand{}, "Ignore messages from another user"},
29	{"ignore-list", ignoreListCommand{}, "List currently ignored users"},
30	{"nopaste", noPasteCommand{}, "Stop interpreting text verbatim"},
31	{"online", onlineCommand{}, "Set your status to Available / Online"},
32	{"otr-auth", authCommand{}, "Authenticate a secure peer with a mutual, shared secret"},
33	{"otr-authoob", authOobCommand{}, "Authenticate a secure peer with out-of-band fingerprint verification"},
34	{"otr-authqa", authQACommand{}, "Authenticate a secure peer with a question and answer"},
35	{"otr-end", endOTRCommand{}, "End an OTR session"},
36	{"otr-info", otrInfoCommand{}, "Print OTR information such as OTR fingerprint"},
37	{"otr-start", otrCommand{}, "Start an OTR session with the given user"},
38	{"paste", pasteCommand{}, "Start interpreting text verbatim"},
39	{"quit", quitCommand{}, "Quit the program"},
40	{"rostereditdone", rosterEditDoneCommand{}, "Load the edited roster from disk"},
41	{"rosteredit", rosterEditCommand{}, "Write the roster to disk"},
42	{"roster", rosterCommand{}, "Display the current roster"},
43	{"statusupdates", toggleStatusUpdatesCommand{}, "Toggle if status updates are displayed"},
44	{"unignore", unignoreCommand{}, "Stop ignoring messages from another user"},
45	{"version", versionCommand{}, "Ask a Jabber client for its version"},
46	{"xa", xaCommand{}, "Set your status to Extended Away"},
47}
48
49type addCommand struct {
50	User string "uid"
51}
52
53type authCommand struct {
54	User   string "uid"
55	Secret string
56}
57
58type authOobCommand struct {
59	User        string "uid"
60	Fingerprint string
61}
62
63type authQACommand struct {
64	User     string "uid"
65	Question string
66	Secret   string
67}
68
69type awayCommand struct{}
70type chatCommand struct{}
71type closeCommand struct{}
72
73type confirmCommand struct {
74	User string "uid"
75}
76
77type denyCommand struct {
78	User string "uid"
79}
80
81type dndCommand struct{}
82
83type endOTRCommand struct {
84	User string "uid"
85}
86
87type helpCommand struct{}
88
89type ignoreCommand struct {
90	User string "uid"
91}
92
93type ignoreListCommand struct{}
94
95type msgCommand struct {
96	to  string
97	msg string
98	// setPromptIsEncrypted is used to synchonously indicate whether the
99	// prompt should show the contact as encrypted, before the prompt is
100	// redrawn. It may be nil to indicate that the prompt cannot be
101	// updated but otherwise must be sent to.
102	setPromptIsEncrypted chan<- bool
103}
104
105type noPasteCommand struct{}
106type onlineCommand struct{}
107
108type otrCommand struct {
109	User string "uid"
110}
111
112type otrInfoCommand struct{}
113
114type pasteCommand struct{}
115type quitCommand struct{}
116
117type rosterCommand struct {
118	OnlineOnly bool "flag:online"
119}
120
121type rosterEditCommand struct{}
122type rosterEditDoneCommand struct{}
123type toggleStatusUpdatesCommand struct{}
124
125type unignoreCommand struct {
126	User string "uid"
127}
128
129type versionCommand struct {
130	User string "uid"
131}
132
133type xaCommand struct{}
134
135func numPositionalFields(t reflect.Type) int {
136	for i := 0; i < t.NumField(); i++ {
137		if strings.HasPrefix(string(t.Field(i).Tag), "flag:") {
138			return i
139		}
140	}
141	return t.NumField()
142}
143
144func parseCommandForCompletion(commands []uiCommand, line string) (before, prefix string, isCommand, ok bool) {
145	if len(line) == 0 || line[0] != '/' {
146		return
147	}
148
149	spacePos := strings.IndexRune(line, ' ')
150	if spacePos == -1 {
151		// We're completing a command name.
152		before = line[:1]
153		prefix = line[1:]
154		isCommand = true
155		ok = true
156		return
157	}
158
159	command := line[1:spacePos]
160	var prototype interface{}
161
162	for _, cmd := range commands {
163		if cmd.name == command {
164			prototype = cmd.prototype
165			break
166		}
167	}
168	if prototype == nil {
169		return
170	}
171
172	t := reflect.TypeOf(prototype)
173	fieldNum := -1
174	fieldStart := 0
175	inQuotes := false
176	lastWasEscape := false
177	numFields := numPositionalFields(t)
178
179	skippingWhitespace := true
180	for pos, r := range line[spacePos:] {
181		if skippingWhitespace {
182			if r == ' ' {
183				continue
184			}
185			skippingWhitespace = false
186			fieldNum++
187			fieldStart = pos + spacePos
188		}
189
190		if lastWasEscape {
191			lastWasEscape = false
192			continue
193		}
194
195		if r == '\\' {
196			lastWasEscape = true
197			continue
198		}
199
200		if r == '"' {
201			inQuotes = !inQuotes
202		}
203
204		if r == ' ' && !inQuotes {
205			skippingWhitespace = true
206		}
207	}
208
209	if skippingWhitespace {
210		return
211	}
212	if fieldNum >= numFields {
213		return
214	}
215	f := t.Field(fieldNum)
216	if f.Tag != "uid" {
217		return
218	}
219	ok = true
220	isCommand = false
221	before = line[:fieldStart]
222	prefix = line[fieldStart:]
223	return
224}
225
226// setOption updates the uiCommand, v, of type t given an option string with
227// the "--" prefix already removed. It returns true on success.
228func setOption(v reflect.Value, t reflect.Type, option string) bool {
229	for i := 0; i < t.NumField(); i++ {
230		fieldType := t.Field(i)
231		tag := string(fieldType.Tag)
232		if strings.HasPrefix(tag, "flag:") && tag[5:] == option {
233			field := v.Field(i)
234			if field.Bool() {
235				return false // already set
236			} else {
237				field.SetBool(true)
238				return true
239			}
240		}
241	}
242
243	return false
244}
245
246func parseCommand(commands []uiCommand, line []byte) (interface{}, string) {
247	if len(line) == 0 || line[0] != '/' {
248		panic("not a command")
249	}
250
251	spacePos := bytes.IndexByte(line, ' ')
252	if spacePos == -1 {
253		spacePos = len(line)
254	}
255	command := string(line[1:spacePos])
256	var prototype interface{}
257
258	for _, cmd := range commands {
259		if cmd.name == command {
260			prototype = cmd.prototype
261			break
262		}
263	}
264	if prototype == nil {
265		return nil, "Unknown command: " + command
266	}
267
268	t := reflect.TypeOf(prototype)
269	v := reflect.New(t)
270	v = reflect.Indirect(v)
271	pos := spacePos
272	fieldNum := -1
273	inQuotes := false
274	lastWasEscape := false
275	numFields := numPositionalFields(t)
276	var field []byte
277
278	skippingWhitespace := true
279	for ; pos <= len(line); pos++ {
280		if !skippingWhitespace && (pos == len(line) || (line[pos] == ' ' && !inQuotes && !lastWasEscape)) {
281			skippingWhitespace = true
282			strField := string(field)
283
284			switch {
285			case fieldNum < numFields:
286				f := v.Field(fieldNum)
287				f.Set(reflect.ValueOf(strField))
288			case strings.HasPrefix(strField, "--"):
289				if !setOption(v, t, strField[2:]) {
290					return nil, "No such option " + strField + " for command"
291				}
292			default:
293				return nil, "Too many arguments for command " + command + ". Expected " + strconv.Itoa(v.NumField())
294			}
295			field = field[:0]
296			continue
297		}
298
299		if pos == len(line) {
300			break
301		}
302
303		if lastWasEscape {
304			field = append(field, line[pos])
305			lastWasEscape = false
306			continue
307		}
308
309		if skippingWhitespace {
310			if line[pos] == ' ' {
311				continue
312			}
313			skippingWhitespace = false
314			fieldNum++
315		}
316
317		if line[pos] == '\\' {
318			lastWasEscape = true
319			continue
320		}
321
322		if line[pos] == '"' {
323			inQuotes = !inQuotes
324			continue
325		}
326
327		field = append(field, line[pos])
328	}
329
330	if fieldNum < numFields-1 {
331		return nil, "Too few arguments for command " + command + ". Expected " + strconv.Itoa(v.NumField()) + ", but found " + strconv.Itoa(fieldNum+1)
332	}
333
334	return v.Interface(), ""
335}
336
337type Input struct {
338	term                 *terminal.Terminal
339	commands             *priorityList
340	lastKeyWasCompletion bool
341
342	// lock protects uids, uidComplete and lastTarget.
343	lock        sync.Mutex
344	uids        []string
345	uidComplete *priorityList
346	lastTarget  string
347}
348
349func (i *Input) AddUser(uid string) {
350	i.lock.Lock()
351	defer i.lock.Unlock()
352
353	for _, existingUid := range i.uids {
354		if existingUid == uid {
355			return
356		}
357	}
358
359	i.uidComplete.Insert(uid)
360	i.uids = append(i.uids, uid)
361}
362
363func (i *Input) ProcessCommands(commandsChan chan<- interface{}) {
364	i.commands = new(priorityList)
365	for _, command := range uiCommands {
366		i.commands.Insert(command.name)
367	}
368
369	autoCompleteCallback := func(line string, pos int, key rune) (string, int, bool) {
370		return i.AutoComplete(line, pos, key)
371	}
372
373	paste := false
374	setPromptIsEncrypted := make(chan bool)
375
376	for {
377		if paste {
378			i.term.AutoCompleteCallback = nil
379		} else {
380			i.term.AutoCompleteCallback = autoCompleteCallback
381		}
382
383		line, err := i.term.ReadLine()
384		if err == terminal.ErrPasteIndicator {
385			if len(i.lastTarget) == 0 {
386				alert(i.term, "Pasted line ignored. Send a message to someone to select the destination.")
387			} else {
388				commandsChan <- msgCommand{i.lastTarget, string(line), nil}
389			}
390			continue
391		}
392		if err != nil {
393			close(commandsChan)
394			return
395		}
396		if paste {
397			l := string(line)
398			if l == "/nopaste" {
399				paste = false
400			} else {
401				commandsChan <- msgCommand{i.lastTarget, l, nil}
402			}
403			continue
404		}
405		if len(line) == 0 {
406			continue
407		}
408		if line[0] == '/' {
409			cmd, err := parseCommand(uiCommands, []byte(line))
410			if len(err) != 0 {
411				alert(i.term, err)
412				continue
413			}
414			// authCommand is turned into authQACommand with an
415			// empty question.
416			if authCmd, ok := cmd.(authCommand); ok {
417				cmd = authQACommand{
418					User:   authCmd.User,
419					Secret: authCmd.Secret,
420				}
421			}
422			if _, ok := cmd.(helpCommand); ok {
423				i.showHelp()
424				continue
425			}
426			if _, ok := cmd.(pasteCommand); ok {
427				if len(i.lastTarget) == 0 {
428					alert(i.term, "Can't enter paste mode without a destination. Send a message to someone to select the destination.")
429					continue
430				}
431				paste = true
432				continue
433			}
434			if _, ok := cmd.(noPasteCommand); ok {
435				paste = false
436				continue
437			}
438			if _, ok := cmd.(closeCommand); ok {
439				i.lastTarget = ""
440				i.term.SetPrompt("> ")
441				continue
442			}
443			if cmd != nil {
444				commandsChan <- cmd
445			}
446			continue
447		}
448
449		i.lock.Lock()
450		if pos := strings.Index(line, string(nameTerminator)); pos > 0 {
451			possibleName := line[:pos]
452			for _, uid := range i.uids {
453				if possibleName == uid {
454					i.lastTarget = possibleName
455					line = line[pos+2:]
456					break
457				}
458			}
459		}
460		i.lock.Unlock()
461
462		if len(i.lastTarget) == 0 {
463			warn(i.term, "Start typing a Jabber address and hit tab to send a message to someone")
464			continue
465		}
466		commandsChan <- msgCommand{i.lastTarget, string(line), setPromptIsEncrypted}
467		isEncrypted := <-setPromptIsEncrypted
468		i.SetPromptForTarget(i.lastTarget, isEncrypted)
469	}
470}
471
472func (input *Input) SetPromptForTarget(target string, isEncrypted bool) {
473	input.lock.Lock()
474	isCurrent := input.lastTarget == target
475	input.lock.Unlock()
476
477	if !isCurrent {
478		return
479	}
480
481	prompt := make([]byte, 0, len(target)+16)
482	if isEncrypted {
483		prompt = append(prompt, input.term.Escape.Green...)
484	} else {
485		prompt = append(prompt, input.term.Escape.Red...)
486	}
487
488	prompt = append(prompt, target...)
489	prompt = append(prompt, input.term.Escape.Reset...)
490	prompt = append(prompt, '>', ' ')
491	input.term.SetPrompt(string(prompt))
492}
493
494func (input *Input) showHelp() {
495	examples := make([]string, len(uiCommands))
496	maxLen := 0
497
498	for i, cmd := range uiCommands {
499		line := "/" + cmd.name
500		prototype := reflect.TypeOf(cmd.prototype)
501		for j := 0; j < prototype.NumField(); j++ {
502			if strings.HasPrefix(string(prototype.Field(j).Tag), "flag:") {
503				line += " [--" + strings.ToLower(string(prototype.Field(j).Tag[5:])) + "]"
504			} else {
505				line += " <" + strings.ToLower(prototype.Field(j).Name) + ">"
506			}
507		}
508		if l := len(line); l > maxLen {
509			maxLen = l
510		}
511		examples[i] = line
512	}
513
514	for i, cmd := range uiCommands {
515		line := examples[i]
516		numSpaces := 1 + (maxLen - len(line))
517		for j := 0; j < numSpaces; j++ {
518			line += " "
519		}
520		line += cmd.desc
521		info(input.term, line)
522	}
523}
524
525const nameTerminator = ": "
526
527func (i *Input) AutoComplete(line string, pos int, key rune) (string, int, bool) {
528	const keyTab = 9
529
530	if key != keyTab {
531		i.lastKeyWasCompletion = false
532		return "", -1, false
533	}
534
535	i.lock.Lock()
536	defer i.lock.Unlock()
537
538	prefix := line[:pos]
539	if i.lastKeyWasCompletion {
540		// The user hit tab right after a completion, so we got
541		// it wrong.
542		if len(prefix) > 0 && prefix[0] == '/' {
543			if strings.IndexRune(prefix, ' ') == len(prefix)-1 {
544				// We just completed a command.
545				newCommand := i.commands.Next()
546				newLine := "/" + string(newCommand) + " " + line[pos:]
547				return newLine, len(newCommand) + 2, true
548			} else if prefix[len(prefix)-1] == ' ' {
549				// We just completed a uid in a command.
550				newUser := i.uidComplete.Next()
551				spacePos := strings.LastIndex(prefix[:len(prefix)-1], " ")
552
553				newLine := prefix[:spacePos] + " " + string(newUser) + " " + line[pos:]
554				return newLine, spacePos + 1 + len(newUser) + 1, true
555			}
556		} else if len(prefix) > 0 && prefix[0] != '/' && strings.HasSuffix(prefix, nameTerminator) {
557			// We just completed a uid at the start of a
558			// conversation line.
559			newUser := i.uidComplete.Next()
560			newLine := string(newUser) + nameTerminator + line[pos:]
561			return newLine, len(newUser) + 2, true
562		}
563	} else {
564		if len(prefix) > 0 && prefix[0] == '/' {
565			a, b, isCommand, ok := parseCommandForCompletion(uiCommands, prefix)
566			if !ok {
567				return "", -1, false
568			}
569			var newValue string
570			if isCommand {
571				newValue, ok = i.commands.Find(b)
572			} else {
573				newValue, ok = i.uidComplete.Find(b)
574			}
575			if !ok {
576				return "", -1, false
577			}
578
579			newLine := string(a) + newValue + " " + line[pos:]
580			i.lastKeyWasCompletion = true
581			return newLine, len(a) + len(newValue) + 1, true
582		} else if len(prefix) > 0 && strings.IndexAny(prefix, ": \t") == -1 {
583			// We're completing a uid at the start of a
584			// conversation line.
585			newUser, ok := i.uidComplete.Find(prefix)
586			if !ok {
587				return "", -1, false
588			}
589
590			newLine := newUser + nameTerminator + line[pos:]
591			i.lastKeyWasCompletion = true
592			return newLine, len(newUser) + len(nameTerminator), true
593		}
594	}
595
596	i.lastKeyWasCompletion = false
597	return "", -1, false
598}
599
600type priorityListEntry struct {
601	value string
602	next  *priorityListEntry
603}
604
605type priorityList struct {
606	head       *priorityListEntry
607	lastPrefix string
608	lastResult string
609	n          int
610}
611
612func (pl *priorityList) Insert(value string) {
613	ent := new(priorityListEntry)
614	ent.next = pl.head
615	ent.value = value
616	pl.head = ent
617}
618
619func (pl *priorityList) findNth(prefix string, nth int) (string, bool) {
620	var cur, last *priorityListEntry
621	cur = pl.head
622	for n := 0; cur != nil; cur = cur.next {
623		if strings.HasPrefix(cur.value, prefix) {
624			if n == nth {
625				// move this entry to the top
626				if last != nil {
627					last.next = cur.next
628				} else {
629					pl.head = cur.next
630				}
631				cur.next = pl.head
632				pl.head = cur
633				pl.lastResult = cur.value
634				return cur.value, true
635			}
636			n++
637		}
638		last = cur
639	}
640
641	return "", false
642}
643
644func (pl *priorityList) Find(prefix string) (string, bool) {
645	pl.lastPrefix = prefix
646	pl.n = 0
647
648	return pl.findNth(prefix, 0)
649}
650
651func (pl *priorityList) Next() string {
652	pl.n++
653	result, ok := pl.findNth(pl.lastPrefix, pl.n)
654	if !ok {
655		pl.n = 1
656		result, ok = pl.findNth(pl.lastPrefix, pl.n)
657	}
658	// In this case, there's only one matching entry in the list.
659	if !ok {
660		pl.n = 0
661		result, _ = pl.findNth(pl.lastPrefix, pl.n)
662	}
663	return result
664}
665