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