From 8c0b8ad931045c70845f662238230edb81f8cd94 Mon Sep 17 00:00:00 2001 From: Alexander Neonxp Kiryukhin Date: Wed, 31 Dec 2025 14:15:42 +0300 Subject: =?UTF-8?q?=D0=A1=D0=B4=D0=B5=D0=BB=D0=B0=D0=BB=20=D0=BA=D0=BE?= =?UTF-8?q?=D1=80=D1=80=D0=B5=D0=BA=D1=82=D0=BD=D1=83=D1=8E=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D1=83=20=D1=81=20=D1=82=D0=B5=D1=80=D0=BC?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0=D0=BB=D0=BE=D0=BC=20=D0=B8=20=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 3 +- internal/chat/chan.go | 38 +++++++++++++ internal/chat/chat.go | 17 ++++-- internal/chat/logs.go | 44 +++++++++++++++ internal/chat/presence.go | 4 ++ internal/server/client.go | 72 ++++++++++++++++++------ internal/server/conn.go | 26 +-------- internal/server/display.go | 83 ---------------------------- internal/server/event.go | 133 +++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 287 insertions(+), 133 deletions(-) create mode 100644 internal/chat/logs.go delete mode 100644 internal/server/display.go create mode 100644 internal/server/event.go diff --git a/Dockerfile b/Dockerfile index 77d54d6..102353b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder ARG TARGETPLATFORM ARG BUILDPLATFORM -RUN apk add --no-cache git +RUN apk add --no-cache tzdata git WORKDIR /app @@ -23,6 +23,7 @@ LABEL maintainer="neonxp" \ description="qChat - quick ssh chat" \ version="1.0" +COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo COPY --from=builder /app /app ENV TZ=Europe/Moscow diff --git a/internal/chat/chan.go b/internal/chat/chan.go index bc2b70f..be9ae71 100644 --- a/internal/chat/chan.go +++ b/internal/chat/chan.go @@ -11,6 +11,7 @@ type Channel struct { Name string Users map[*User]struct{} Events chan any + Log Logs mu sync.RWMutex } @@ -21,6 +22,7 @@ func (c *Channel) Listen(ctx context.Context) { return case ev := <-c.Events: c.processEvent(ev) + c.processLog(ev) } } } @@ -42,6 +44,37 @@ func (c *Channel) processEvent(ev any) { } } +func (c *Channel) processLog(ev any) { + switch ev := ev.(type) { + case UserJoined: + c.Log.Append(LogEntry{ + Username: ev.User.NUsername(), + Time: ev.Time, + Type: TypeJoined, + }) + case UserLeft: + c.Log.Append(LogEntry{ + Username: ev.User.NUsername(), + Time: ev.Time, + Type: TypeLeft, + }) + case Message: + c.Log.Append(LogEntry{ + Username: ev.User.NUsername(), + Time: ev.Time, + Message: ev.Message, + Type: TypeMessage, + }) + case SelfMessage: + c.Log.Append(LogEntry{ + Username: ev.User.NUsername(), + Time: time.Now(), + Message: "/me " + ev.Message, + Type: TypeMessage, + }) + } +} + func (c *Channel) Join(u *User) { c.mu.Lock() defer c.mu.Unlock() @@ -56,6 +89,11 @@ func (c *Channel) Join(u *User) { } c.Users[u] = struct{}{} + + // Отправляем при подключении последние 20 сообщений + u.Events <- UserLogs{ + Logs: c.Log.Get(), + } } func (c *Channel) Leave(u *User) { diff --git a/internal/chat/chat.go b/internal/chat/chat.go index 4a639ba..1639d6b 100644 --- a/internal/chat/chat.go +++ b/internal/chat/chat.go @@ -15,6 +15,12 @@ var ( "/join [chan] - change current channel to [chan]\n" + "/chans - list all chans\n" + "/users - list all online users\n" + + "/me [message] - display message like from third person\n" + + "Formatting:\n" + + "- *Bold*\n" + + "- +Italic+\n" + + "- -Striked-\n" + + "- _Underline_\n" + "ctrl+c - leave chat" ) @@ -55,18 +61,18 @@ func (c *Chat) NewUser(username, identify string) *User { Events: make(chan any, 32), mu: sync.RWMutex{}, } + u.Events <- SystemMessage{ + Message: fmt.Sprintf("Connected to %s chat server...\nType /help command for list available commands.", c.cfg.Server.Name), + } ch := c.GetChannel("main") + if ch != nil { u.JoinChan(ch) } c.users[u] = struct{}{} - u.Events <- SystemMessage{ - Message: fmt.Sprintf("Connected to %s chat server...", c.cfg.Server.Name), - } - return u } @@ -170,8 +176,7 @@ func (c *Chat) Input(ctx context.Context, user *User, input string) { newChan = c.NewChannel(ctx, newChanName) } user.CurrentChan.Leave(user) - user.CurrentChan = newChan - newChan.Join(user) + user.JoinChan(newChan) default: c.Message(user, input) } diff --git a/internal/chat/logs.go b/internal/chat/logs.go new file mode 100644 index 0000000..09d60fe --- /dev/null +++ b/internal/chat/logs.go @@ -0,0 +1,44 @@ +package chat + +import "time" + +type Logs struct { + entries [20]LogEntry + start int +} + +func (l *Logs) Append(le LogEntry) { + l.entries[l.start] = le + l.start++ + if l.start == len(l.entries) { + l.start = 0 + } +} + +func (l *Logs) Get() []LogEntry { + result := make([]LogEntry, 0, len(l.entries)) + for i := l.start; i < len(l.entries); i++ { + result = append(result, l.entries[i]) + } + for i := 0; i < l.start; i++ { + result = append(result, l.entries[i]) + } + + return result +} + +type LogEntry struct { + Username string + Time time.Time + Message string + Type LogEntryType +} + +type LogEntryType int + +const ( + TypeNone LogEntryType = iota + TypeJoined + TypeLeft + TypeMessage +) diff --git a/internal/chat/presence.go b/internal/chat/presence.go index 995f214..1057a11 100644 --- a/internal/chat/presence.go +++ b/internal/chat/presence.go @@ -15,3 +15,7 @@ type UserLeft struct { Chan *Channel Time time.Time } + +type UserLogs struct { + Logs []LogEntry +} diff --git a/internal/server/client.go b/internal/server/client.go index 698f7fa..8a23c8b 100644 --- a/internal/server/client.go +++ b/internal/server/client.go @@ -2,18 +2,30 @@ package server import ( "context" + "encoding/binary" "io" "log/slog" + "sync" - "go.neonxp.ru/qchat/internal/chat" + "golang.org/x/crypto/ssh" "golang.org/x/term" ) -func (s *Server) serveClient(ctx context.Context, rw io.ReadWriteCloser, user *chat.User) { - t := term.NewTerminal(rw, "[] ") - // TODO resize terminal to user size +func (s *Server) serveClient( + ctx context.Context, + conn *ssh.ServerConn, + channel ssh.Channel, + requests <-chan *ssh.Request, +) { + wg := sync.WaitGroup{} - go func() { + identify := conn.Permissions.ExtraData["identify"].(string) + user := s.chat.NewUser(conn.User(), identify) + + t := term.NewTerminal(channel, "[] ") + + // Обработка ввода пользователя + wg.Go(func() { for { select { case <-ctx.Done(): @@ -31,6 +43,8 @@ func (s *Server) serveClient(ctx context.Context, rw io.ReadWriteCloser, user *c slog.Error("failed read line", slog.Any("err", err)) } + conn.Close() + return } if len(line) == 0 { @@ -39,20 +53,42 @@ func (s *Server) serveClient(ctx context.Context, rw io.ReadWriteCloser, user *c s.chat.Input(ctx, user, line) } - }() + }) - for message := range user.Events { - switch message := message.(type) { - case chat.Message: - displayMessage(t, message, user) - case chat.SelfMessage: - displaySelfMessage(t, message) - case chat.SystemMessage: - displaySystemMessage(t, message) - case chat.UserJoined: - displayUserJoined(t, message) - case chat.UserLeft: - displayUserLeft(t, message) + wg.Go(func() { + for req := range requests { + switch req.Type { + case "pty-req": + termLen := req.Payload[3] + w, h := parseDims(req.Payload[termLen+4:]) + t.SetSize(w, h) + req.Reply(true, nil) + case "window-change": + w, h := parseDims(req.Payload) + t.SetSize(w, h) + req.Reply(true, nil) + case "shell": + req.Reply(len(req.Payload) == 0, nil) + default: + req.Reply(false, nil) + } + slog.Debug( + "req", + slog.String("type", req.Type), + slog.Bool("want-reply", req.WantReply), + slog.String("payload", string(req.Payload)), + ) } + }) + + for message := range user.Events { + processUserEvent(message, t, user) } } + +func parseDims(b []byte) (int, int) { + w := binary.BigEndian.Uint32(b) + h := binary.BigEndian.Uint32(b[4:]) + + return int(w), int(h) +} diff --git a/internal/server/conn.go b/internal/server/conn.go index 1069b0b..347b4eb 100644 --- a/internal/server/conn.go +++ b/internal/server/conn.go @@ -35,31 +35,7 @@ func (s *Server) serveConn(ctx context.Context, nConn net.Conn, config *ssh.Serv } wg.Go(func() { - for req := range requests { - switch req.Type { - case "pty-req": - req.Reply(true, nil) - case "shell": - req.Reply(true, nil) - default: - req.Reply(false, nil) - } - slog.Debug( - "req", - slog.String("type", req.Type), - slog.Bool("want-reply", req.WantReply), - slog.String("payload", string(req.Payload)), - ) - } - }) - - wg.Go(func() { - identify := conn.Permissions.ExtraData["identify"].(string) - user := s.chat.NewUser(conn.User(), identify) - slog.Info("joined", slog.String("user", user.NUsername())) - s.serveClient(ctx, channel, user) - slog.Info("disconnected", slog.String("user", user.NUsername())) - conn.Close() + s.serveClient(ctx, conn, channel, requests) }) } diff --git a/internal/server/display.go b/internal/server/display.go deleted file mode 100644 index c3450ad..0000000 --- a/internal/server/display.go +++ /dev/null @@ -1,83 +0,0 @@ -package server - -import ( - "fmt" - "io" - "regexp" - "strings" - - "go.neonxp.ru/qchat/internal/chat" -) - -var replacements = []replacement{ - { - From: *regexp.MustCompile(`\*(.+?)\*`), - To: fmt.Sprintf("%s$1%s", escapeCodes[Bold], escapeCodes[Reset]), - }, - { - From: *regexp.MustCompile(`_(.+?)_`), - To: fmt.Sprintf("%s$1%s", escapeCodes[Italic], escapeCodes[Reset]), - }, -} - -func displayMessage(t io.Writer, message chat.Message, user *chat.User) { - msg := message.Message - mentionFrom := "@" + user.Username - mentionTo := string(escapeCodes[Blink]) + string(escapeCodes[Underline]) + "@" + user.Username + string(escapeCodes[Reset]) - if strings.Contains(msg, mentionFrom) { - msg = strings.ReplaceAll(msg, mentionFrom, mentionTo) - msg = "\x07\a" + msg - } - - for _, r := range replacements { - msg = r.From.ReplaceAllString(msg, r.To) - } - - fmt.Fprintf(t, "%s%15s%s [%s]%s: %s\n", - escapeCodes[Blue], - message.User.NUsername(), - escapeCodes[Green], - message.Time.Format("15:04:05"), - escapeCodes[Reset], - msg, - ) -} - -func displaySystemMessage(t io.Writer, message chat.SystemMessage) { - fmt.Fprintf(t, "%s* %s %s\n", - escapeCodes[Green], - message.Message, - escapeCodes[Reset], - ) -} -func displaySelfMessage(t io.Writer, message chat.SelfMessage) { - fmt.Fprintf(t, "%s* %s %s%s\n", - escapeCodes[Blue], - message.User.NUsername(), - message.Message, - escapeCodes[Reset], - ) -} - -func displayUserJoined(t io.Writer, presence chat.UserJoined) { - fmt.Fprintf(t, "%s* %s joined to %s chan%s\n", - escapeCodes[Green], - presence.User.NUsername(), - presence.Chan.Name, - escapeCodes[Reset], - ) -} - -func displayUserLeft(t io.Writer, presence chat.UserLeft) { - fmt.Fprintf(t, "%s* %s left %s chan%s\n", - escapeCodes[Red], - presence.User.NUsername(), - presence.Chan.Name, - escapeCodes[Reset], - ) -} - -type replacement struct { - From regexp.Regexp - To string -} diff --git a/internal/server/event.go b/internal/server/event.go new file mode 100644 index 0000000..d006d22 --- /dev/null +++ b/internal/server/event.go @@ -0,0 +1,133 @@ +package server + +import ( + "fmt" + "io" + "regexp" + "strings" + + "go.neonxp.ru/qchat/internal/chat" +) + +var replacements = []replacement{ + { + From: *regexp.MustCompile(`\*(.+?)\*`), + To: fmt.Sprintf("%s$1%s", escapeCodes[Bold], escapeCodes[Reset]), + }, + { + From: *regexp.MustCompile(`\+(.+?)\+`), + To: fmt.Sprintf("%s$1%s", escapeCodes[Italic], escapeCodes[Reset]), + }, + { + From: *regexp.MustCompile(`\-(.+?)\-`), + To: fmt.Sprintf("%s$1%s", escapeCodes[Strike], escapeCodes[Reset]), + }, + { + From: *regexp.MustCompile(`_(.+?)_`), + To: fmt.Sprintf("%s$1%s", escapeCodes[Underline], escapeCodes[Reset]), + }, +} + +func processUserEvent(message any, w io.Writer, user *chat.User) { + switch message := message.(type) { + case chat.Message: + displayMessage(w, message, user) + case chat.SelfMessage: + displaySelfMessage(w, message) + case chat.SystemMessage: + displaySystemMessage(w, message) + case chat.UserJoined: + displayUserJoined(w, message) + case chat.UserLeft: + displayUserLeft(w, message) + case chat.UserLogs: + displayUserLogs(w, message) + } +} + +func displayMessage(w io.Writer, message chat.Message, user *chat.User) { + msg := message.Message + mentionFrom := "@" + user.Username + mentionTo := string(escapeCodes[Yellow]) + string(escapeCodes[Underline]) + "@" + user.Username + string(escapeCodes[Reset]) + if strings.Contains(msg, mentionFrom) { + msg = strings.ReplaceAll(msg, mentionFrom, mentionTo) + msg = "\x07\a" + msg + } + + for _, r := range replacements { + msg = r.From.ReplaceAllString(msg, r.To) + } + + fmt.Fprintf(w, "%s%18s%s [%s]%s: %s\n", + escapeCodes[Blue], + message.User.NUsername(), + escapeCodes[Green], + message.Time.Format("15:04:05"), + escapeCodes[Reset], + msg, + ) +} + +func displaySystemMessage(w io.Writer, message chat.SystemMessage) { + fmt.Fprintf(w, "%s* %s %s\n", + escapeCodes[Green], + message.Message, + escapeCodes[Reset], + ) +} +func displaySelfMessage(w io.Writer, message chat.SelfMessage) { + fmt.Fprintf(w, "%s* %s %s%s\n", + escapeCodes[Blue], + message.User.NUsername(), + message.Message, + escapeCodes[Reset], + ) +} + +func displayUserJoined(w io.Writer, presence chat.UserJoined) { + fmt.Fprintf(w, "%s* %s joined to %s chan%s\n", + escapeCodes[Green], + presence.User.NUsername(), + presence.Chan.Name, + escapeCodes[Reset], + ) +} + +func displayUserLeft(w io.Writer, presence chat.UserLeft) { + fmt.Fprintf(w, "%s* %s left %s chan%s\n", + escapeCodes[Red], + presence.User.NUsername(), + presence.Chan.Name, + escapeCodes[Reset], + ) +} +func displayUserLogs(w io.Writer, message chat.UserLogs) { + messages := make([]string, 0, len(message.Logs)) + for _, m := range message.Logs { + switch m.Type { + case chat.TypeNone: + continue + case chat.TypeJoined: + messages = append(messages, fmt.Sprintf("* %18s [%s]: joined", m.Username, m.Time.Format("15:04:05"))) + case chat.TypeLeft: + messages = append(messages, fmt.Sprintf("* %18s [%s]: left", m.Username, m.Time.Format("15:04:05"))) + case chat.TypeMessage: + messages = append(messages, fmt.Sprintf("%18s [%s]: %s", m.Username, m.Time.Format("15:04:05"), m.Message)) + } + } + + if len(messages) == 0 { + return + } + + fmt.Fprintf(w, "%s* Last channel messages:\n%s%s\n", + escapeCodes[Blue], + strings.Join(messages, "\n"), + escapeCodes[Reset], + ) +} + +type replacement struct { + From regexp.Regexp + To string +} -- cgit v1.2.3