6
1
mirror of https://git.mills.io/saltyim/saltyim.git synced 2024-06-16 03:48:24 +00:00

Refactor the code (#38)

Co-authored-by: James Mills <prologic@shortcircuit.net.au>
Reviewed-on: https://git.mills.io/prologic/saltyim/pulls/38
This commit is contained in:
James Mills 2022-03-21 14:46:16 +00:00
parent 950f54708f
commit 8993981e81
21 changed files with 598 additions and 519 deletions

298
client.go

@ -4,191 +4,167 @@ import (
"bytes"
"context"
"fmt"
"strings"
"sync"
"net/http"
"os"
"time"
"github.com/dim13/crc24"
"github.com/gdamore/tcell/v2"
"github.com/gdamore/tcell/v2/encoding"
"git.mills.io/prologic/msgbus"
msgbus_client "git.mills.io/prologic/msgbus/client"
"github.com/keys-pub/keys"
"github.com/posener/formatter"
"github.com/rivo/tview"
log "github.com/sirupsen/logrus"
"go.mills.io/salty"
"go.yarn.social/lextwt"
"go.mills.io/saltyim/internal/exec"
)
const (
dateTimeFormat = "2006-01-02 15:04:05"
)
type configCache map[string]Config
func getUserColor(s string) tcell.Color {
c := crc24.Sum([]byte(s))
return tcell.NewRGBColor(
int32(c>>0x10),
int32(c>>0x08),
int32(c),
)
// PackMessage formts an outoing message in the Message Format
// <timestamp>\t(<sender>) <message>
func PackMessage(me Addr, msg string) []byte {
return []byte(fmt.Sprint(time.Now().UTC().Format(time.RFC3339), "\t", me.Formatted(), "\t", msg))
}
type chatClient struct {
mu sync.RWMutex
key *keys.EdX25519Key
me Addr
endpoint string
user string
config Config
// Configurations.
palette map[string]string
}
// NewChatClient initializes a new chatClient.
// Sets up connection with broker, and initializes UI.
func NewChatClient(key *keys.EdX25519Key, me Addr, endpoint, user string) (*chatClient, error) {
config, err := Lookup(user)
// Send sends the encrypted message `msg` to the Endpoint `endpoint` using a
// `POST` request and returns nil on success or an error on failure.
func Send(endpoint, msg string) error {
res, err := Request(http.MethodPost, endpoint, nil, bytes.NewBufferString(msg))
if err != nil {
return nil, fmt.Errorf("error: failed to lookup user %q: %w", user, err)
return fmt.Errorf("error publishing message to %s: %w", endpoint, err)
}
defer res.Body.Close()
return nil
}
// Client is a Salty IM client that handles talking to a Salty IM Broker
// and Sedngina and Receiving messages to/from Salty IM Users.
type Client struct {
key *keys.EdX25519Key
endpoint string
me Addr
cache configCache
}
// NewClient reeturns a new Salty IM client for sending and receiving
// encrypted messages to other Salty IM users as well as decrypting
// and displaying messages of the user's own inbox.
func NewClient(me Addr, identity, endpoint string) (*Client, error) {
key, m, err := GetIdentity(identity)
if err != nil {
return nil, fmt.Errorf("error opening identity %s: %w", identity, err)
}
if me.IsZero() {
me = m
}
return &chatClient{
key: key,
me: me,
endpoint: endpoint,
user: user,
config: config,
if me.IsZero() {
return nil, fmt.Errorf("unable to find your user addressn in %s", identity)
}
palette: map[string]string{
"background": "000000",
"border": "4C5C68",
"title": "46494C",
"date": "E76F51",
"text": "577399",
},
log.Infof("Using identity %s with public key %s", identity, key)
log.Infof("Salty Addr is %s", me)
log.Infof("Endpoint is %s", endpoint)
return &Client{
key: key,
endpoint: endpoint,
me: me,
cache: make(configCache),
}, nil
}
// newMessageHandler returns an InputHandler that handles outgoing messages.
func (cc *chatClient) newMessageHandler(outCh chan<- string) InputHandler {
handler := func(message string) {
cc.mu.RLock()
outCh <- message
cc.mu.RUnlock()
func (cli *Client) getConfig(user string) (Config, error) {
config, ok := cli.cache[user]
if ok {
return config, nil
}
return handler
}
// updateChatBox updates chatBox component with incoming messages.
func (cc *chatClient) updateChatBox(inCh <-chan string, app *tview.Application,
chatBox *tview.TextView) {
for in := range inCh {
s, err := lextwt.ParseSalty(in)
if err != nil {
continue
}
switch s := s.(type) {
case *lextwt.SaltyEvent:
// Ignored for now
case *lextwt.SaltyText:
if s.User.String() != cc.me.String() && s.User.String() != cc.user {
continue
}
buf := &bytes.Buffer{}
_, _, width, _ := chatBox.GetInnerRect()
f := formatter.Formatter{
Writer: buf,
Indent: []byte("> "),
Width: width - 1,
}
if _, err := f.Write([]byte(s.LiteralText())); err != nil {
log.WithError(err).Error("error formatting message")
continue
}
cc.mu.RLock()
app.QueueUpdateDraw(func() {
fmt.Fprintf(chatBox,
"[#%s]%s [#%x]%s\n[#%s]%s\n\n",
cc.palette["date"],
s.Timestamp.DateTime().Local().Format(dateTimeFormat),
getUserColor(s.User.String()).Hex(),
s.User.String(),
cc.palette["text"],
buf.String(),
)
})
cc.mu.RUnlock()
}
}
}
// setScreen initializes the layout and UI components.
func (cc *chatClient) SetScreen(inCh <-chan string, outCh chan<- string) {
encoding.Register()
app := tview.NewApplication()
title := fmt.Sprintf(
"Chatting as %s via %s with %s via %s",
cc.me, cc.endpoint, cc.user, cc.config.Endpoint,
)
// Generate UI components.
cc.mu.RLock()
chatBox := NewChatBox(cc.palette, title)
inputField := NewChatInput(cc.palette, cc.newMessageHandler(outCh))
cc.mu.RUnlock()
// Layout the widgets in flex view.
flex := tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(chatBox, 0, 1, false).
AddItem(inputField, 3, 0, true)
go cc.updateChatBox(inCh, app, chatBox)
err := app.SetRoot(flex, true).SetFocus(inputField).EnableMouse(true).Run()
config, err := Lookup(user)
if err != nil {
log.Fatal(err)
return Config{}, fmt.Errorf("error: failed to lookup user %s: %w", user, err)
}
cli.cache[user] = config
return config, nil
}
func (cli *Client) handleMessage(prehook, posthook string, msgs chan string) msgbus.HandlerFunc {
return func(msg *msgbus.Message) error {
if prehook != "" {
out, err := exec.RunCmd(exec.DefaultRunCmdTimeout, prehook, bytes.NewBuffer(msg.Payload))
if err != nil {
log.WithError(err).Debugf("error running pre-hook %s", prehook)
}
log.Debugf("pre-hook: %q", out)
}
data, _, err := salty.Decrypt(cli.key, msg.Payload)
if err != nil {
fmt.Fprintf(os.Stderr, "error decrypting message")
return err
}
msgs <- string(data)
if posthook != "" {
out, err := exec.RunCmd(exec.DefaultRunCmdTimeout, posthook, bytes.NewBuffer(data))
if err != nil {
log.WithError(err).Debugf("error running post-hook %s", posthook)
}
log.Debugf("post-hook: %q", out)
}
return nil
}
}
// Open bi-directional stream between client and server.
func (cc *chatClient) RunChat(inCh chan<- string, outCh <-chan string) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
func (cli *Client) Me() Addr { return cli.me }
func (cli *Client) Endpoint() string { return cli.endpoint }
// Read subscribers to this user's inbox for new messages
func (cli *Client) Read(ctx context.Context, endpoint, prehook, posthook string) chan string {
if endpoint == "" {
endpoint = cli.endpoint
}
uri, inbox := SplitInbox(endpoint)
bus := msgbus_client.NewClient(uri, nil)
msgs := make(chan string)
s := bus.Subscribe(inbox, cli.handleMessage(prehook, posthook, msgs))
s.Start()
log.Infof("Connected to %s/%s", uri, inbox)
// Receives incoming messages on a separate goroutine to be non-blocking.
go func() {
for msg := range Read(ctx, cc.key, cc.endpoint, "", "") {
inCh <- msg
}
<-ctx.Done()
s.Stop()
close(msgs)
}()
// Forward/send outgoing messages.
for msg := range outCh {
if strings.TrimSpace(msg) == "" {
continue
}
packedMsg := PackMessage(cc.me, msg)
b, err := salty.Encrypt(cc.key, packedMsg, []string{cc.config.Key})
if err != nil {
log.Fatalf("error encrypting message: %v", err)
}
err = Send(cc.config.Endpoint, string(b))
if err != nil {
log.Fatalf("error sending message: %v", err)
}
inCh <- string(packedMsg)
}
return msgs
}
// Send sends an encrypted message to the specified user
func (cli *Client) Send(user, msg string) error {
cfg, err := cli.getConfig(user)
if err != nil {
return fmt.Errorf("error looking up user %s: %w", user, err)
}
b, err := salty.Encrypt(cli.key, PackMessage(cli.me, msg), []string{cfg.Key})
if err != nil {
return fmt.Errorf("error encrypting message to %s: %w", user, err)
}
if err := Send(cfg.Endpoint, string(b)); err != nil {
return fmt.Errorf("error sending message to %s: %w", user, err)
}
return nil
}

@ -9,6 +9,7 @@ import (
"github.com/spf13/viper"
"go.mills.io/saltyim"
"go.mills.io/saltyim/internal/tui"
)
var chatCmd = &cobra.Command{
@ -47,33 +48,27 @@ func init() {
}
func chat(me saltyim.Addr, identity, endpoint, user string) {
key, m, err := saltyim.GetIdentity(identity)
cli, err := saltyim.NewClient(me, identity, endpoint)
if err != nil {
fmt.Fprintf(os.Stderr, "error opening identity: %q\n", identity)
os.Exit(2)
}
if me.IsZero() {
me = m
}
if me.IsZero() {
fmt.Fprintf(os.Stderr, "unable to find your user addressn in %q\n", identity)
fmt.Fprintln(os.Stderr, "tip: try adding # user: nick@domain to your identity")
fmt.Fprintf(os.Stderr, "error initializing client: %s\n", err)
os.Exit(2)
}
// Set terminal title
tui.SetTerminalTitle("Salty IM with %s", user)
// Initialize necessary channels.
inCh := make(chan string)
outCh := make(chan string)
// Initialize client.
cc, err := saltyim.NewChatClient(key, me, endpoint, user)
// Initialize ui.
c, err := tui.NewChatTUI(cli, user)
if err != nil {
fmt.Fprintf(os.Stderr, "error creating chat: %s", err)
fmt.Fprintf(os.Stderr, "error creating chat: %s\n", err)
os.Exit(2)
}
saltyim.SetTerminalTitle("Salty IM with %s", user)
go cc.RunChat(inCh, outCh)
cc.SetScreen(inCh, outCh)
// Run the ui loop
go c.RunChat(inCh, outCh)
c.SetScreen(inCh, outCh)
}

@ -40,16 +40,21 @@ func init() {
func lookup(user string) {
user = strings.TrimSpace(user)
if user == "" {
fmt.Fprintln(os.Stderr, "error: no user supplied")
fmt.Fprintln(os.Stderr, "error no user supplied")
os.Exit(2)
}
config, err := saltyim.Lookup(user)
if err != nil {
fmt.Fprintf(os.Stderr, "error: failed to lookup user %q: %s", user, err)
fmt.Fprintf(os.Stderr, "error looking up user %s: %s\n", user, err)
os.Exit(2)
}
data, err := json.Marshal(config)
if err != nil {
fmt.Fprintf(os.Stderr, "error parsing lookup response: %s\n", err)
os.Exit(2)
}
data, _ := json.Marshal(config)
fmt.Fprintln(os.Stdout, string(data))
}

@ -10,6 +10,7 @@ import (
"github.com/spf13/viper"
"go.mills.io/saltyim"
"go.mills.io/saltyim/internal"
)
const (
@ -87,7 +88,7 @@ func makeuser(identity, endpoint, user string) {
os.Exit(2)
}
if saltyim.FileExists(identity) {
if internal.FileExists(identity) {
fmt.Fprintf(os.Stderr, "error identity %q already exists!\n", identity)
}
@ -110,5 +111,5 @@ func makeuser(identity, endpoint, user string) {
Addr: me,
}
fmt.Println(saltyim.MustRenderString(postSetupInstructions, ctx))
fmt.Println(internal.MustRenderString(postSetupInstructions, ctx))
}

@ -5,6 +5,7 @@ import (
"log"
"os"
"os/signal"
"strings"
"syscall"
"github.com/spf13/cobra"
@ -37,6 +38,12 @@ not specified defaults to the local user ($USER)`,
}
}
var me saltyim.Addr
if sp := strings.Split(user, "@"); len(sp) > 1 {
me.User = sp[0]
me.Domain = sp[1]
}
prehook, err := cmd.Flags().GetString("pre-hook")
if err != nil {
log.Fatal("error getting --pre-hook flag")
@ -47,7 +54,7 @@ not specified defaults to the local user ($USER)`,
log.Fatal("error getting --post-hook flag")
}
read(endpoint, identity, prehook, posthook, args...)
read(me, identity, endpoint, prehook, posthook, args...)
},
}
@ -71,10 +78,10 @@ func init() {
)
}
func read(endpoint, identity, prehook, posthook string, args ...string) {
key, _, err := saltyim.GetIdentity(identity)
func read(me saltyim.Addr, identity, endpoint string, prehook, posthook string, args ...string) {
cli, err := saltyim.NewClient(me, identity, endpoint)
if err != nil {
fmt.Fprintf(os.Stderr, "error reading identity %q: %s", identity, err)
fmt.Fprintf(os.Stderr, "error initializing client: %s\n", err)
os.Exit(2)
}
@ -89,7 +96,7 @@ func read(endpoint, identity, prehook, posthook string, args ...string) {
cancel()
}()
for msg := range saltyim.Read(ctx, key, endpoint, prehook, posthook) {
for msg := range cli.Read(ctx, "", "", "") {
fmt.Println(saltyim.FormatMessage(msg))
}
}

@ -29,20 +29,6 @@ Getting Started:
$ salty-chat make-user nick@domain
# follow the instructions
Reading Messages:
$ salty-chat read
$ salty-chat read -i inbox # if your nick != $USER
Sending Messages:
$ salty-chat send prologic@mills.io Hey
$ echo "Hello World!" | salty-chat send prologic@mills.io # reads from stdin
Chatting:
$ salty-chat chat prologic@mills.io Hey
See https://salty.im for more details.`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
// set logging level

@ -4,14 +4,12 @@ import (
"fmt"
"io"
"io/ioutil"
"log"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"go.mills.io/salty"
"go.mills.io/saltyim"
)
@ -37,12 +35,14 @@ https://mills.io/.well-known/salty/prologic.json`,
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
user := viper.GetString("user")
endpoint := viper.GetString("endpoint")
identity := viper.GetString("identity")
var profiles []profile
viper.UnmarshalKey("profiles", &profiles)
for _, p := range profiles {
if user == p.User {
endpoint = p.Endpoint
identity = p.Identity
}
}
@ -53,7 +53,7 @@ https://mills.io/.well-known/salty/prologic.json`,
me.Domain = sp[1]
}
send(me, identity, args[0], args[1:]...)
send(me, identity, endpoint, args[0], args[1:]...)
},
}
@ -61,26 +61,16 @@ func init() {
rootCmd.AddCommand(sendCmd)
}
func send(me saltyim.Addr, identity, user string, args ...string) {
func send(me saltyim.Addr, identity, endpoint, user string, args ...string) {
user = strings.TrimSpace(user)
if user == "" {
fmt.Fprintf(os.Stderr, "error: no user supplied")
os.Exit(2)
}
config, err := saltyim.Lookup(user)
cli, err := saltyim.NewClient(me, identity, endpoint)
if err != nil {
fmt.Fprintf(os.Stderr, "error: failed to lookup user %q: %s", user, err)
os.Exit(2)
}
key, me, err := saltyim.GetIdentity(identity)
if err != nil {
fmt.Fprintf(os.Stderr, "error opening identity: %q\n", identity)
if me.IsZero() {
fmt.Fprintf(os.Stderr, "unable to find your user addressn in %q\n", identity)
fmt.Fprintln(os.Stderr, "tip: try adding # user: nick@domain to your identity")
}
fmt.Fprintf(os.Stderr, "error initializing client: %s\n", err)
os.Exit(2)
}
@ -97,13 +87,8 @@ func send(me saltyim.Addr, identity, user string, args ...string) {
msg = strings.Join(args, " ")
}
b, err := salty.Encrypt(key, saltyim.PackMessage(me, msg), []string{config.Key})
if err != nil {
log.Fatalf("error encrypting message: %v", err)
}
err = saltyim.Send(config.Endpoint, string(b))
if err != nil {
log.Fatalf("error sending message: %v", err)
if err := cli.Send(user, msg); err != nil {
fmt.Fprintf(os.Stderr, "error sending message to %s: %s", user, err)
os.Exit(2)
}
}

@ -1,94 +0,0 @@
package saltyim
import (
"fmt"
"syscall"
)
type ErrCommandKilled struct {
Err error
Signal syscall.Signal
}
func (e *ErrCommandKilled) Is(target error) bool {
if _, ok := target.(*ErrCommandKilled); ok {
return true
}
return false
}
func (e *ErrCommandKilled) Error() string {
return fmt.Sprintf("error: command killed: %s", e.Err)
}
func (e *ErrCommandKilled) Unwrap() error {
return e.Err
}
type ErrCommandFailed struct {
Err error
Status int
}
func (e *ErrCommandFailed) Is(target error) bool {
if _, ok := target.(*ErrCommandFailed); ok {
return true
}
return false
}
func (e *ErrCommandFailed) Error() string {
return fmt.Sprintf("error: command failed: %s", e.Err)
}
func (e *ErrCommandFailed) Unwrap() error {
return e.Err
}
type ErrTranscodeTimeout struct {
Err error
}
func (e *ErrTranscodeTimeout) Error() string {
return fmt.Sprintf("error: transcode timed out: %s", e.Err)
}
func (e *ErrTranscodeTimeout) Unwrap() error {
return e.Err
}
type ErrTranscodeFailed struct {
Err error
}
func (e *ErrTranscodeFailed) Error() string {
return fmt.Sprintf("error: transcode failed: %s", e.Err)
}
func (e *ErrTranscodeFailed) Unwrap() error {
return e.Err
}
type ErrAudioUploadFailed struct {
Err error
}
func (e *ErrAudioUploadFailed) Error() string {
return fmt.Sprintf("error: audio upload failed: %s", e.Err)
}
func (e *ErrAudioUploadFailed) Unwrap() error {
return e.Err
}
type ErrVideoUploadFailed struct {
Err error
}
func (e *ErrVideoUploadFailed) Error() string {
return fmt.Sprintf("error: video upload failed: %s", e.Err)
}
func (e *ErrVideoUploadFailed) Unwrap() error {
return e.Err
}

@ -11,6 +11,10 @@ import (
"go.yarn.social/lextwt"
)
const (
DateTimeFormat = "2006-01-02 15:04:05"
)
func boundedInt(value, low, high uint8) uint8 {
diff := high - low
return (((value - low) % diff) + low)
@ -53,7 +57,7 @@ func FormatMessage(msg string) string {
return fmt.Sprintf(
"%s\t%s\n%s\n",
aurora.Sprintf(aurora.Blue(st.Timestamp.DateTime().Local().Format(dateTimeFormat))),
aurora.Sprintf(aurora.Blue(st.Timestamp.DateTime().Local().Format(DateTimeFormat))),
aurora.Sprintf(aurora.Index(userColor, st.User.String())),
buf.String(),
)

2
go.mod

@ -11,7 +11,7 @@ require (
)
require (
git.mills.io/prologic/msgbus v0.1.6
git.mills.io/prologic/msgbus v0.1.7
github.com/dim13/crc24 v0.0.0-20190831075008-15d874f06514
github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1
github.com/google/go-cmp v0.5.7 // indirect

4
go.sum

@ -48,8 +48,8 @@ cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RX
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
git.mills.io/prologic/msgbus v0.1.6 h1:ayEmGsuvzfatQ2j5HwYRtH78BjaFScRjXhlQ7SmYQ30=
git.mills.io/prologic/msgbus v0.1.6/go.mod h1:1fzdbZsp32pQfMD2ULeozCwNRZ1qx8XTjFbN/M+QGSs=
git.mills.io/prologic/msgbus v0.1.7 h1:UfqB9J3NMrlhjW0gHHqCMwisOP1hAz/rBVKADYF2Wzg=
git.mills.io/prologic/msgbus v0.1.7/go.mod h1:1fzdbZsp32pQfMD2ULeozCwNRZ1qx8XTjFbN/M+QGSs=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=

3
internal/doc.go Normal file

@ -0,0 +1,3 @@
package internal
/* internal */

47
internal/exec/cmd.go Normal file

@ -0,0 +1,47 @@
package exec
import (
"context"
"io"
"os/exec"
"syscall"
"time"
)
const (
DefaultRunCmdTimeout = time.Second * 3
)
// RunCmd executes the given command and arguments and stdin and ensures the
// command takes no longer than the timeout before the command is terminated.
func RunCmd(timeout time.Duration, command string, stdin io.Reader, args ...string) (string, error) {
var (
ctx context.Context
cancel context.CancelFunc
)
if timeout > 0 {
ctx, cancel = context.WithTimeout(context.Background(), timeout)
} else {
ctx, cancel = context.WithCancel(context.Background())
}
defer cancel()
cmd := exec.CommandContext(ctx, command, args...)
cmd.Stdin = stdin
out, err := cmd.CombinedOutput()
if err != nil {
if exitError, ok := err.(*exec.ExitError); ok {
if ws, ok := exitError.Sys().(syscall.WaitStatus); ok && ws.Signal() == syscall.SIGKILL {
err = &ErrCommandKilled{Err: err, Signal: ws.Signal()}
} else {
err = &ErrCommandFailed{Err: err, Status: exitError.ExitCode()}
}
}
return "", err
}
return string(out), nil
}

46
internal/exec/errors.go Normal file

@ -0,0 +1,46 @@
package exec
import (
"fmt"
"syscall"
)
type ErrCommandKilled struct {
Err error
Signal syscall.Signal
}
func (e *ErrCommandKilled) Is(target error) bool {
if _, ok := target.(*ErrCommandKilled); ok {
return true
}
return false
}
func (e *ErrCommandKilled) Error() string {
return fmt.Sprintf("error: command killed: %s", e.Err)
}
func (e *ErrCommandKilled) Unwrap() error {
return e.Err
}
type ErrCommandFailed struct {
Err error
Status int
}
func (e *ErrCommandFailed) Is(target error) bool {
if _, ok := target.(*ErrCommandFailed); ok {
return true
}
return false
}
func (e *ErrCommandFailed) Error() string {
return fmt.Sprintf("error: command failed: %s", e.Err)
}
func (e *ErrCommandFailed) Unwrap() error {
return e.Err
}

180
internal/tui/tui.go Normal file

@ -0,0 +1,180 @@
package tui
import (
"bytes"
"context"
"fmt"
"strings"
"sync"
"github.com/dim13/crc24"
"github.com/gdamore/tcell/v2"
"github.com/gdamore/tcell/v2/encoding"
"github.com/posener/formatter"
"github.com/rivo/tview"
log "github.com/sirupsen/logrus"
"go.yarn.social/lextwt"
"go.mills.io/saltyim"
)
func getUserColor(s string) tcell.Color {
c := crc24.Sum([]byte(s))
return tcell.NewRGBColor(
int32(c>>0x10),
int32(c>>0x08),
int32(c),
)
}
type ChatTUI struct {
mu sync.RWMutex
cli *saltyim.Client
me saltyim.Addr
user string
config saltyim.Config
// Configurations.
palette map[string]string
}
// NewChatTUI initializes a new chatApp.
// Sets up connection with broker, and initializes UI.
func NewChatTUI(cli *saltyim.Client, user string) (*ChatTUI, error) {
config, err := saltyim.Lookup(user)
if err != nil {
return nil, fmt.Errorf("error looking up user %s: %s", user, err)
}
return &ChatTUI{
cli: cli,
user: user,
config: config,
palette: map[string]string{
"background": "000000",
"border": "4C5C68",
"title": "46494C",
"date": "E76F51",
"text": "577399",
},
}, nil
}
// newMessageHandler returns an InputHandler that handles outgoing messages.
func (c *ChatTUI) newMessageHandler(outCh chan<- string) InputHandler {
handler := func(message string) {
c.mu.RLock()
outCh <- message
c.mu.RUnlock()
}
return handler
}
// updateChatBox updates chatBox component with incoming messages.
func (c *ChatTUI) updateChatBox(inCh <-chan string, app *tview.Application,
chatBox *tview.TextView) {
for in := range inCh {
s, err := lextwt.ParseSalty(in)
if err != nil {
continue
}
switch s := s.(type) {
case *lextwt.SaltyEvent:
// Ignored for now
case *lextwt.SaltyText:
if s.User.String() != c.cli.Me().String() && s.User.String() != c.user {
continue
}
buf := &bytes.Buffer{}
_, _, width, _ := chatBox.GetInnerRect()
f := formatter.Formatter{
Writer: buf,
Indent: []byte("> "),
Width: width - 1,
}
if _, err := f.Write([]byte(s.LiteralText())); err != nil {
log.WithError(err).Error("error formatting message")
continue
}
c.mu.RLock()
app.QueueUpdateDraw(func() {
fmt.Fprintf(chatBox,
"[#%s]%s [#%x]%s\n[#%s]%s\n\n",
c.palette["date"],
s.Timestamp.DateTime().Local().Format(saltyim.DateTimeFormat),
getUserColor(s.User.String()).Hex(),
s.User.String(),
c.palette["text"],
buf.String(),
)
})
c.mu.RUnlock()
}
}
}
// setScreen initializes the layout and UI components.
func (c *ChatTUI) SetScreen(inCh <-chan string, outCh chan<- string) {
encoding.Register()
app := tview.NewApplication()
title := fmt.Sprintf(
"Chatting as %s via %s with %s via %s",
c.cli.Me(), c.cli.Endpoint(), c.user, c.config.Endpoint,
)
// Generate UI components.
c.mu.RLock()
chatBox := NewChatBox(c.palette, title)
inputField := NewChatInput(c.palette, c.newMessageHandler(outCh))
c.mu.RUnlock()
// Layout the widgets in flex view.
flex := tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(chatBox, 0, 1, false).
AddItem(inputField, 3, 0, true)
go c.updateChatBox(inCh, app, chatBox)
err := app.SetRoot(flex, true).SetFocus(inputField).EnableMouse(true).Run()
if err != nil {
log.Fatal(err)
}
}
// Open bi-directional stream between client and server.
func (c *ChatTUI) RunChat(inCh chan<- string, outCh <-chan string) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Receives incoming messages on a separate goroutine to be non-blocking.
go func() {
for msg := range c.cli.Read(ctx, "", "", "") {
inCh <- msg
}
}()
// Forward/send outgoing messages.
for msg := range outCh {
if strings.TrimSpace(msg) == "" {
continue
}
if err := c.cli.Send(c.user, msg); err != nil {
log.WithError(err).Fatal("error sending message")
}
inCh <- string(saltyim.PackMessage(c.cli.Me(), msg))
}
}

@ -1,4 +1,4 @@
package saltyim
package tui
import (
"regexp"

10
internal/tui/utils.go Normal file

@ -0,0 +1,10 @@
package tui
import (
"fmt"
)
// SetTerminalTitle sets the Terminal/Console's title
func SetTerminalTitle(format string, args ...interface{}) {
fmt.Printf("\033]0;%s\007", fmt.Sprintf(format, args...))
}

120
internal/utils.go Normal file

@ -0,0 +1,120 @@
package internal
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"strings"
"text/template"
"time"
log "github.com/sirupsen/logrus"
"go.mills.io/saltyim"
)
const (
defaultRequestTimeout = time.Second * 5
)
func MustRenderString(s string, ctx interface{}) string {
out, err := RenderString(s, ctx)
if err != nil {
log.WithError(err).Fatal("error rendering string template")
}
return out
}
// DirExists returns true if the given directory exists
func DirExists(name string) bool {
stat, err := os.Stat(name)
if err != nil {
if os.IsNotExist(err) {
return false
}
return false
}
return stat.IsDir()
}
// FileExists returns true if the given file exists
func FileExists(name string) bool {
if _, err := os.Stat(name); err != nil {
if os.IsNotExist(err) {
return false
}
}
return true
}
// RenderString renders the string `s` as a `text/template` using the provided
// context `ctx` as input into the template.
// Typically used to render the results into a Markdown document.
func RenderString(s string, ctx interface{}) (string, error) {
t := template.Must(template.New("s").Parse(s))
buf := bytes.NewBuffer([]byte{})
err := t.Execute(buf, ctx)
if err != nil {
return "", err
}
return buf.String(), nil
}
// Request is a generic request handling function for making artbitrary HTPT
// requests to Salty endpoints for looking up Salty Addresses, Configs and
// publishing encrypted messages.
func Request(method, uri string, headers http.Header, body io.Reader) (*http.Response, error) {
req, err := http.NewRequest(method, uri, body)
if err != nil {
return nil, fmt.Errorf("%s: http.NewRequest fail: %s", uri, err)
}
if headers == nil {
headers = make(http.Header)
}
// Set a default User-Agent (if none set)
if headers.Get("User-Agent") == "" {
headers.Set("User-Agent", fmt.Sprintf("saltyim/%s", saltyim.FullVersion()))
}
req.Header = headers
client := http.Client{
Timeout: defaultRequestTimeout,
}
res, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("%s: client.Do fail: %s", uri, err)
}
if res.StatusCode/100 != 2 {
return nil, fmt.Errorf("non-2xx response received: %s", res.Status)
}
return res, nil
}
// CmdExists ...
func CmdExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
// SetTerminalTitle sets the Terminal/Console's title
func SetTerminalTitle(format string, args ...interface{}) {
fmt.Printf("\033]0;%s\007", fmt.Sprintf(format, args...))
}
func SplitInbox(endpoint string) (string, string) {
idx := strings.LastIndex(endpoint, "/")
if idx == -1 {
return "", ""
}
return endpoint[:idx], endpoint[idx+1:]
}

@ -1,70 +0,0 @@
package saltyim
import (
"bytes"
"context"
"fmt"
"os"
"time"
"git.mills.io/prologic/msgbus"
"git.mills.io/prologic/msgbus/client"
"github.com/keys-pub/keys"
log "github.com/sirupsen/logrus"
"go.mills.io/salty"
)
const (
defaultRunCmdTimeout = time.Second * 3
)
func handleMessage(key *keys.EdX25519Key, prehook, posthook string, msgs chan string) msgbus.HandlerFunc {
return func(msg *msgbus.Message) error {
if prehook != "" {
out, err := RunCmd(defaultRunCmdTimeout, prehook, bytes.NewBuffer(msg.Payload))
if err != nil {
log.WithError(err).Debugf("error running pre-hook %s", prehook)
}
log.Debugf("pre-hook: %q", out)
}
data, _, err := salty.Decrypt(key, msg.Payload)
if err != nil {
fmt.Fprintf(os.Stderr, "error decrypting message")
return err
}
msgs <- string(data)
if posthook != "" {
out, err := RunCmd(defaultRunCmdTimeout, posthook, bytes.NewBuffer(data))
if err != nil {
log.WithError(err).Debugf("error running post-hook %s", posthook)
}
log.Debugf("post-hook: %q", out)
}
return nil
}
}
// Read ...
func Read(ctx context.Context, key *keys.EdX25519Key, endpoint, prehook, posthook string) chan string {
uri, inbox := SplitInbox(endpoint)
client := client.NewClient(uri, nil)
msgs := make(chan string)
s := client.Subscribe(inbox, handleMessage(key, prehook, posthook, msgs))
s.Start()
log.Infof("Connected to %s/%s", uri, inbox)
go func() {
<-ctx.Done()
s.Stop()
close(msgs)
}()
return msgs
}

@ -1,28 +0,0 @@
package saltyim
import (
"bytes"
"fmt"
"net/http"
"time"
log "github.com/sirupsen/logrus"
)
// Packmessage formts an outoing message in the Message Format
// <timestamp>\t(<sender>) <message>
func PackMessage(me Addr, msg string) []byte {
return []byte(fmt.Sprint(time.Now().UTC().Format(time.RFC3339), "\t", me.Formatted(), "\t", msg))
}
// Send sends the encrypted message `msg` to the Endpoint `endpoint` using a
// `POST` request and returns nil on success or an error on failure.
func Send(endpoint, msg string) error {
res, err := Request(http.MethodPost, endpoint, nil, bytes.NewBufferString(msg))
if err != nil {
log.WithError(err).Errorf("error publishing message to %s", endpoint)
return err
}
defer res.Body.Close()
return nil
}

@ -1,69 +1,17 @@
package saltyim
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"strings"
"syscall"
"text/template"
"time"
log "github.com/sirupsen/logrus"
)
const (
defaultRequestTimeout = time.Second * 5
)
func MustRenderString(s string, ctx interface{}) string {
out, err := RenderString(s, ctx)
if err != nil {
log.WithError(err).Fatal("error rendering string template")
}
return out
}
// DirExists returns true if the given directory exists
func DirExists(name string) bool {
stat, err := os.Stat(name)
if err != nil {
if os.IsNotExist(err) {
return false
}
return false
}
return stat.IsDir()
}
// FileExists returns true if the given file exists
func FileExists(name string) bool {
if _, err := os.Stat(name); err != nil {
if os.IsNotExist(err) {
return false
}
}
return true
}
// RenderString renders the string `s` as a `text/template` using the provided
// context `ctx` as input into the template.
// Typically used to render the results into a Markdown document.
func RenderString(s string, ctx interface{}) (string, error) {
t := template.Must(template.New("s").Parse(s))
buf := bytes.NewBuffer([]byte{})
err := t.Execute(buf, ctx)
if err != nil {
return "", err
}
return buf.String(), nil
}
// Request is a generic request handling function for making artbitrary HTPT
// requests to Salty endpoints for looking up Salty Addresses, Configs and
// publishing encrypted messages.
@ -100,50 +48,8 @@ func Request(method, uri string, headers http.Header, body io.Reader) (*http.Res
return res, nil
}
// CmdExists ...
func CmdExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
// RunCmd ...
func RunCmd(timeout time.Duration, command string, stdin io.Reader, args ...string) (string, error) {
var (
ctx context.Context
cancel context.CancelFunc
)
if timeout > 0 {
ctx, cancel = context.WithTimeout(context.Background(), timeout)
} else {
ctx, cancel = context.WithCancel(context.Background())
}
defer cancel()
cmd := exec.CommandContext(ctx, command, args...)
cmd.Stdin = stdin
out, err := cmd.CombinedOutput()
if err != nil {
if exitError, ok := err.(*exec.ExitError); ok {
if ws, ok := exitError.Sys().(syscall.WaitStatus); ok && ws.Signal() == syscall.SIGKILL {
err = &ErrCommandKilled{Err: err, Signal: ws.Signal()}
} else {
err = &ErrCommandFailed{Err: err, Status: exitError.ExitCode()}
}
}
return "", err
}
return string(out), nil
}
// SetTerminalTitle sets the Terminal/Console's title
func SetTerminalTitle(format string, args ...interface{}) {
fmt.Printf("\033]0;%s\007", fmt.Sprintf(format, args...))
}
// SplitInbox splits and endpoint into it's components (inbox, uri)
// where inbox is a topic queue on the Salty broker uri
func SplitInbox(endpoint string) (string, string) {
idx := strings.LastIndex(endpoint, "/")
if idx == -1 {