/timestamp now prefixes each line with a timestamp

Freakin' Timestamps On Every Freakin' Line
This commit is contained in:
Andrey Petrov 2019-03-17 14:05:22 -04:00 committed by GitHub
commit c02b6390d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 141 additions and 56 deletions

View File

@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/shazow/ssh-chat/chat/message" "github.com/shazow/ssh-chat/chat/message"
"github.com/shazow/ssh-chat/internal/sanitize" "github.com/shazow/ssh-chat/internal/sanitize"
@ -283,16 +284,34 @@ func InitCommands(c *Commands) {
}) })
c.Add(Command{ c.Add(Command{
Prefix: "/timestamp", Prefix: "/timestamp",
Help: "Timestamps after 30min of inactivity.", PrefixHelp: "[TZINFO]",
Help: "Prefix messages with a timestamp. (Example: America/Toronto)",
Handler: func(room *Room, msg message.CommandMsg) error { Handler: func(room *Room, msg message.CommandMsg) error {
u := msg.From() u := msg.From()
cfg := u.Config() cfg := u.Config()
cfg.Timestamp = !cfg.Timestamp
args := msg.Args()
if len(args) >= 1 {
// FIXME: This is an annoying format to demand from users, but
// hopefully we can make it a non-primary flow if we add GeoIP
// someday.
timeLoc, err := time.LoadLocation(args[0])
if err != nil {
err = fmt.Errorf("%s: Use a location name such as \"America/Toronto\" or refer to the IANA Time Zone database for the full list of names: https://wikipedia.org/wiki/List_of_tz_database_time_zones", err)
return err
}
cfg.Timezone = timeLoc
cfg.Timestamp = true
} else {
cfg.Timestamp = !cfg.Timestamp
}
u.SetConfig(cfg) u.SetConfig(cfg)
var body string var body string
if cfg.Timestamp { if cfg.Timestamp && cfg.Timezone != nil {
body = fmt.Sprintf("Timestamp is toggled ON with timezone %q", cfg.Timezone)
} else if cfg.Timestamp {
body = "Timestamp is toggled ON" body = "Timestamp is toggled ON"
} else { } else {
body = "Timestamp is toggled OFF" body = "Timestamp is toggled OFF"

View File

@ -112,6 +112,7 @@ func (m PublicMsg) Render(t *Theme) string {
return fmt.Sprintf("%s: %s", t.ColorName(m.from), m.body) return fmt.Sprintf("%s: %s", t.ColorName(m.from), m.body)
} }
// RenderFor renders the message for other users to see.
func (m PublicMsg) RenderFor(cfg UserConfig) string { func (m PublicMsg) RenderFor(cfg UserConfig) string {
if cfg.Highlight == nil || cfg.Theme == nil { if cfg.Highlight == nil || cfg.Theme == nil {
return m.Render(cfg.Theme) return m.Render(cfg.Theme)
@ -128,6 +129,11 @@ func (m PublicMsg) RenderFor(cfg UserConfig) string {
return fmt.Sprintf("%s: %s", cfg.Theme.ColorName(m.from), body) return fmt.Sprintf("%s: %s", cfg.Theme.ColorName(m.from), body)
} }
// RenderSelf renders the message for when it's echoing your own message.
func (m PublicMsg) RenderSelf(cfg UserConfig) string {
return fmt.Sprintf("[%s] %s", cfg.Theme.ColorName(m.from), m.body)
}
func (m PublicMsg) String() string { func (m PublicMsg) String() string {
return fmt.Sprintf("%s: %s", m.from.Name(), m.body) return fmt.Sprintf("%s: %s", m.from.Name(), m.body)
} }

View File

@ -1,6 +1,11 @@
package message package message
import "fmt" import (
"fmt"
"time"
)
const timestampLayout = "2006-01-02 15:04:05 MST"
const ( const (
// Reset resets the color // Reset resets the color
@ -122,43 +127,49 @@ type Theme struct {
names *Palette names *Palette
} }
func (t Theme) ID() string { func (theme Theme) ID() string {
return t.id return theme.id
} }
// Colorize name string given some index // Colorize name string given some index
func (t Theme) ColorName(u *User) string { func (theme Theme) ColorName(u *User) string {
if t.names == nil { if theme.names == nil {
return u.Name() return u.Name()
} }
return t.names.Get(u.colorIdx).Format(u.Name()) return theme.names.Get(u.colorIdx).Format(u.Name())
} }
// Colorize the PM string // Colorize the PM string
func (t Theme) ColorPM(s string) string { func (theme Theme) ColorPM(s string) string {
if t.pm == nil { if theme.pm == nil {
return s return s
} }
return t.pm.Format(s) return theme.pm.Format(s)
} }
// Colorize the Sys message // Colorize the Sys message
func (t Theme) ColorSys(s string) string { func (theme Theme) ColorSys(s string) string {
if t.sys == nil { if theme.sys == nil {
return s return s
} }
return t.sys.Format(s) return theme.sys.Format(s)
} }
// Highlight a matched string, usually name // Highlight a matched string, usually name
func (t Theme) Highlight(s string) string { func (theme Theme) Highlight(s string) string {
if t.highlight == nil { if theme.highlight == nil {
return s return s
} }
return t.highlight.Format(s) return theme.highlight.Format(s)
}
// Timestamp formats and colorizes the timestamp.
func (theme Theme) Timestamp(t time.Time) string {
// TODO: Change this per-theme? Or config?
return theme.sys.Format(t.Format(timestampLayout))
} }
// List of initialzied themes // List of initialzied themes

View File

@ -16,7 +16,6 @@ const messageBuffer = 5
const messageTimeout = 5 * time.Second const messageTimeout = 5 * time.Second
const reHighlight = `\b(%s)\b` const reHighlight = `\b(%s)\b`
const timestampTimeout = 30 * time.Minute const timestampTimeout = 30 * time.Minute
const timestampLayout = "2006-01-02 15:04:05 UTC"
var ErrUserClosed = errors.New("user closed") var ErrUserClosed = errors.New("user closed")
@ -158,17 +157,34 @@ func (u *User) SetHighlight(s string) error {
func (u *User) render(m Message) string { func (u *User) render(m Message) string {
cfg := u.Config() cfg := u.Config()
var out string
switch m := m.(type) { switch m := m.(type) {
case PublicMsg: case PublicMsg:
return m.RenderFor(cfg) + Newline if u == m.From() {
case *PrivateMsg: out += m.RenderSelf(cfg)
if cfg.Bell { } else {
return m.Render(cfg.Theme) + Bel + Newline out += m.RenderFor(cfg)
} }
return m.Render(cfg.Theme) + Newline case *PrivateMsg:
out += m.Render(cfg.Theme)
if cfg.Bell {
out += Bel
}
case *CommandMsg:
out += m.RenderSelf(cfg)
default: default:
return m.Render(cfg.Theme) + Newline out += m.Render(cfg.Theme)
} }
if cfg.Timestamp {
ts := m.Timestamp()
if cfg.Timezone != nil {
ts = ts.In(cfg.Timezone)
} else {
ts = ts.UTC()
}
return cfg.Theme.Timestamp(ts) + " " + out + Newline
}
return out + Newline
} }
// writeMsg renders the message and attempts to write it, will Close the user // writeMsg renders the message and attempts to write it, will Close the user
@ -186,20 +202,8 @@ func (u *User) writeMsg(m Message) error {
// HandleMsg will render the message to the screen, blocking. // HandleMsg will render the message to the screen, blocking.
func (u *User) HandleMsg(m Message) error { func (u *User) HandleMsg(m Message) error {
u.mu.Lock() u.mu.Lock()
cfg := u.config
lastMsg := u.lastMsg
u.lastMsg = m.Timestamp() u.lastMsg = m.Timestamp()
injectTimestamp := !lastMsg.IsZero() && cfg.Timestamp && u.lastMsg.Sub(lastMsg) > timestampTimeout
u.mu.Unlock() u.mu.Unlock()
if injectTimestamp {
// Inject a timestamp at most once every timestampTimeout between message intervals
ts := NewSystemMsg(fmt.Sprintf("Timestamp: %s", m.Timestamp().UTC().Format(timestampLayout)), u)
if err := u.writeMsg(ts); err != nil {
return err
}
}
return u.writeMsg(m) return u.writeMsg(m)
} }
@ -223,6 +227,7 @@ type UserConfig struct {
Bell bool Bell bool
Quiet bool Quiet bool
Timestamp bool Timestamp bool
Timezone *time.Location
Theme *Theme Theme *Theme
} }

View File

@ -90,12 +90,7 @@ func (r *Room) HandleMsg(m message.Message) {
user := m.To() user := m.To()
user.Send(m) user.Send(m)
default: default:
fromMsg, skip := m.(message.MessageFrom) fromMsg, _ := m.(message.MessageFrom)
var skipUser *message.User
if skip {
skipUser = fromMsg.From()
}
r.history.Add(m) r.history.Add(m)
r.Members.Each(func(_ string, item set.Item) (err error) { r.Members.Each(func(_ string, item set.Item) (err error) {
user := item.Value().(*Member).User user := item.Value().(*Member).User
@ -105,10 +100,6 @@ func (r *Room) HandleMsg(m message.Message) {
return return
} }
if skip && skipUser == user {
// Skip self
return
}
if _, ok := m.(*message.AnnounceMsg); ok { if _, ok := m.(*message.AnnounceMsg); ok {
if user.Config().Quiet { if user.Config().Quiet {
// Skip announcements // Skip announcements

14
host.go
View File

@ -162,6 +162,20 @@ func (h *Host) Connect(term *sshd.Terminal) {
m := message.ParseInput(line, user) m := message.ParseInput(line, user)
// Gross hack to override line echo in golang.org/x/crypto/ssh/terminal
// It needs to live before we render the resulting message.
term.Write([]byte{
27, '[', 'A', // Up
27, '[', '2', 'K', // Clear line
})
// May the gods have mercy on our souls.
if m, ok := m.(*message.CommandMsg); ok {
// Other messages render themselves by the room, commands we'll
// have to re-echo ourselves manually.
user.HandleMsg(m)
}
// FIXME: Any reason to use h.room.Send(m) instead? // FIXME: Any reason to use h.room.Send(m) instead?
h.HandleMsg(m) h.HandleMsg(m)

View File

@ -6,6 +6,7 @@ import (
"crypto/rsa" "crypto/rsa"
"errors" "errors"
"io" "io"
mathRand "math/rand"
"strings" "strings"
"testing" "testing"
@ -15,22 +16,53 @@ import (
) )
func stripPrompt(s string) string { func stripPrompt(s string) string {
pos := strings.LastIndex(s, "\033[K") // FIXME: Is there a better way to do this?
if pos < 0 { if endPos := strings.Index(s, "\x1b[K "); endPos > 0 {
return s return s[endPos+3:]
}
if endPos := strings.Index(s, "\x1b[2K "); endPos > 0 {
return s[endPos+4:]
}
if endPos := strings.Index(s, "] "); endPos > 0 {
return s[endPos+2:]
}
return s
}
func TestStripPrompt(t *testing.T) {
tests := []struct {
Input string
Want string
}{
{
Input: "\x1b[A\x1b[2K[quux] hello",
Want: "hello",
},
{
Input: "[foo] \x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[K * Guest1 joined. (Connected: 2)\r",
Want: " * Guest1 joined. (Connected: 2)\r",
},
}
for i, tc := range tests {
if got, want := stripPrompt(tc.Input), tc.Want; got != want {
t.Errorf("case #%d:\n got: %q\nwant: %q", i, got, want)
}
} }
return s[pos+3:]
} }
func TestHostGetPrompt(t *testing.T) { func TestHostGetPrompt(t *testing.T) {
var expected, actual string var expected, actual string
// Make the random colors consistent across tests
mathRand.Seed(1)
u := message.NewUser(&Identity{id: "foo"}) u := message.NewUser(&Identity{id: "foo"})
actual = GetPrompt(u) actual = GetPrompt(u)
expected = "[foo] " expected = "[foo] "
if actual != expected { if actual != expected {
t.Errorf("Got: %q; Expected: %q", actual, expected) t.Errorf("Invalid host prompt:\n Got: %q;\nWant: %q", actual, expected)
} }
u.SetConfig(message.UserConfig{ u.SetConfig(message.UserConfig{
@ -39,7 +71,7 @@ func TestHostGetPrompt(t *testing.T) {
actual = GetPrompt(u) actual = GetPrompt(u)
expected = "[\033[38;05;88mfoo\033[0m] " expected = "[\033[38;05;88mfoo\033[0m] "
if actual != expected { if actual != expected {
t.Errorf("Got: %q; Expected: %q", actual, expected) t.Errorf("Invalid host prompt:\n Got: %q;\nWant: %q", actual, expected)
} }
} }
@ -205,18 +237,23 @@ func TestHostKick(t *testing.T) {
// Change nicks, make sure op sticks // Change nicks, make sure op sticks
w.Write([]byte("/nick quux\r\n")) w.Write([]byte("/nick quux\r\n"))
scanner.Scan() // Prompt scanner.Scan() // Prompt
scanner.Scan() // Prompt echo
scanner.Scan() // Nick change response scanner.Scan() // Nick change response
// Signal for the second client to connect
connected <- struct{}{}
// Block until second client is here // Block until second client is here
connected <- struct{}{} connected <- struct{}{}
scanner.Scan() // Connected message scanner.Scan() // Connected message
w.Write([]byte("/kick bar\r\n")) w.Write([]byte("/kick bar\r\n"))
scanner.Scan() // Prompt scanner.Scan() // Prompt
scanner.Scan() // Prompt echo
scanner.Scan() scanner.Scan() // Kick result
if actual, expected := stripPrompt(scanner.Text()), " * bar was kicked by quux.\r"; actual != expected { if actual, expected := stripPrompt(scanner.Text()), " * bar was kicked by quux.\r"; actual != expected {
t.Errorf("Got %q; expected %q", actual, expected) t.Errorf("Failed to detect kick:\n Got: %q;\nWant: %q", actual, expected)
} }
kicked <- struct{}{} kicked <- struct{}{}
@ -231,6 +268,8 @@ func TestHostKick(t *testing.T) {
}() }()
go func() { go func() {
<-connected
// Second client // Second client
err := sshd.ConnectShell(addr, "bar", func(r io.Reader, w io.WriteCloser) error { err := sshd.ConnectShell(addr, "bar", func(r io.Reader, w io.WriteCloser) error {
scanner := bufio.NewScanner(r) scanner := bufio.NewScanner(r)