diff options
| author | 2025-12-30 19:33:39 +0300 | |
|---|---|---|
| committer | 2025-12-30 19:33:39 +0300 | |
| commit | d317e8f6df0e0e16445db606da1d683a6b35f531 (patch) | |
| tree | 4b80de04e17a137cff2dc309508b5f841f48c994 /internal | |
| download | qchat-d317e8f6df0e0e16445db606da1d683a6b35f531.tar.gz qchat-d317e8f6df0e0e16445db606da1d683a6b35f531.tar.bz2 qchat-d317e8f6df0e0e16445db606da1d683a6b35f531.tar.xz qchat-d317e8f6df0e0e16445db606da1d683a6b35f531.zip | |
начальный коммит
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/chat/chan.go | 69 | ||||
| -rw-r--r-- | internal/chat/chat.go | 178 | ||||
| -rw-r--r-- | internal/chat/message.go | 20 | ||||
| -rw-r--r-- | internal/chat/presence.go | 17 | ||||
| -rw-r--r-- | internal/chat/user.go | 35 | ||||
| -rw-r--r-- | internal/config/config.go | 46 | ||||
| -rw-r--r-- | internal/server/client.go | 58 | ||||
| -rw-r--r-- | internal/server/conn.go | 67 | ||||
| -rw-r--r-- | internal/server/display.go | 83 | ||||
| -rw-r--r-- | internal/server/escapes.go | 49 | ||||
| -rw-r--r-- | internal/server/server.go | 70 |
11 files changed, 692 insertions, 0 deletions
diff --git a/internal/chat/chan.go b/internal/chat/chan.go new file mode 100644 index 0000000..ae78bb6 --- /dev/null +++ b/internal/chat/chan.go @@ -0,0 +1,69 @@ +package chat + +import ( + "context" + "log/slog" + "sync" + "time" +) + +type Channel struct { + Name string + Users map[*User]struct{} + Events chan any + mu sync.RWMutex +} + +func (c *Channel) Listen(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case ev := <-c.Events: + c.processEvent(ev) + } + } +} + +func (c *Channel) processEvent(ev any) { + slog.Info("channel event", slog.String("channel", c.Name), slog.Any("event", ev)) + + for u := range c.Users { + select { + case u.Events <- ev: + default: + } + } +} + +func (c *Channel) Join(u *User) { + c.mu.Lock() + defer c.mu.Unlock() + + if _, ok := c.Users[u]; ok { + return + } + c.Events <- UserJoined{ + User: u, + Time: time.Now(), + Chan: c, + } + + c.Users[u] = struct{}{} +} + +func (c *Channel) Leave(u *User) { + c.mu.Lock() + defer c.mu.Unlock() + + if _, ok := c.Users[u]; !ok { + return + } + c.Events <- UserLeft{ + User: u, + Time: time.Now(), + Chan: c, + } + + delete(c.Users, u) +} diff --git a/internal/chat/chat.go b/internal/chat/chat.go new file mode 100644 index 0000000..4a639ba --- /dev/null +++ b/internal/chat/chat.go @@ -0,0 +1,178 @@ +package chat + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "go.neonxp.ru/qchat/internal/config" +) + +var ( + helpMessage = "Available commands:\n" + + "/join [chan] - change current channel to [chan]\n" + + "/chans - list all chans\n" + + "/users - list all online users\n" + + "ctrl+c - leave chat" +) + +type Chat struct { + cfg *config.Config + users map[*User]struct{} + channels map[*Channel]struct{} + wg sync.WaitGroup + mu sync.RWMutex +} + +func New(cfg *config.Config) *Chat { + return &Chat{ + cfg: cfg, + users: make(map[*User]struct{}, 32), + channels: make(map[*Channel]struct{}, 32), + wg: sync.WaitGroup{}, + mu: sync.RWMutex{}, + } +} + +func (c *Chat) Run(ctx context.Context) error { + for _, channel := range c.cfg.Channels { + c.NewChannel(ctx, channel.Name) + } + + c.wg.Wait() + + return nil +} + +func (c *Chat) NewUser(username, identify string) *User { + u := &User{ + Username: username, + Identify: identify, + Chans: map[string]*Channel{}, + CurrentChan: nil, + Events: make(chan any, 32), + mu: sync.RWMutex{}, + } + + 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 +} + +func (c *Chat) RemoveUser(u *User) { + c.mu.Lock() + defer c.mu.Unlock() + + for ch := range c.channels { + ch.Leave(u) + } + + close(u.Events) + + delete(c.users, u) +} + +func (c *Chat) NewChannel(ctx context.Context, name string) *Channel { + ch := &Channel{ + Name: name, + Users: make(map[*User]struct{}, 32), + Events: make(chan any), + mu: sync.RWMutex{}, + } + c.mu.Lock() + defer c.mu.Unlock() + c.channels[ch] = struct{}{} + c.wg.Go(func() { + ch.Listen(ctx) + }) + + return ch +} + +func (c *Chat) GetChannel(name string) *Channel { + for ch := range c.channels { + if ch.Name == name { + return ch + } + } + + return nil +} + +func (c *Chat) Message(user *User, message string) { + if user.CurrentChan == nil { + return + } + + user.CurrentChan.Events <- Message{ + User: user, + Message: message, + Time: time.Now(), + } +} + +func (c *Chat) SelfMessage(user *User, message string) { + if user.CurrentChan == nil { + return + } + + user.CurrentChan.Events <- SelfMessage{ + User: user, + Message: message, + } +} +func (c *Chat) SystemMessage(user *User, message string) { + user.Events <- SystemMessage{ + Message: message, + } +} + +func (c *Chat) Input(ctx context.Context, user *User, input string) { + cmd := strings.ToLower(input) + switch { + case strings.HasPrefix(cmd, "/me "): + c.SelfMessage(user, strings.TrimPrefix(input, "/me ")) + case cmd == "/help": + c.SystemMessage(user, helpMessage) + case cmd == "/list": + list := make([]string, 0, len(c.channels)) + for ch := range c.channels { + list = append(list, ch.Name) + } + c.SystemMessage(user, "Chans:\n"+strings.Join(list, "\n")) + case cmd == "/users": + list := make([]string, 0, len(c.users)) + for u := range c.users { + list = append(list, u.NUsername()) + } + c.SystemMessage(user, "Users:\n"+strings.Join(list, "\n")) + case strings.HasPrefix(cmd, "/join "): + newChanName := strings.TrimPrefix(input, "/join ") + var newChan *Channel + for ch := range c.channels { + if ch.Name == newChanName { + newChan = ch + break + } + } + if newChan == nil { + newChan = c.NewChannel(ctx, newChanName) + } + user.CurrentChan.Leave(user) + user.CurrentChan = newChan + newChan.Join(user) + default: + c.Message(user, input) + } +} diff --git a/internal/chat/message.go b/internal/chat/message.go new file mode 100644 index 0000000..081d640 --- /dev/null +++ b/internal/chat/message.go @@ -0,0 +1,20 @@ +package chat + +import ( + "time" +) + +type SystemMessage struct { + Message string +} + +type Message struct { + User *User + Time time.Time + Message string +} + +type SelfMessage struct { + User *User + Message string +} diff --git a/internal/chat/presence.go b/internal/chat/presence.go new file mode 100644 index 0000000..995f214 --- /dev/null +++ b/internal/chat/presence.go @@ -0,0 +1,17 @@ +package chat + +import ( + "time" +) + +type UserJoined struct { + User *User + Chan *Channel + Time time.Time +} + +type UserLeft struct { + User *User + Chan *Channel + Time time.Time +} diff --git a/internal/chat/user.go b/internal/chat/user.go new file mode 100644 index 0000000..e897f32 --- /dev/null +++ b/internal/chat/user.go @@ -0,0 +1,35 @@ +package chat + +import ( + "sync" +) + +type User struct { + Username string + Identify string + Chans map[string]*Channel + CurrentChan *Channel + Events chan any + mu sync.RWMutex +} + +func (u *User) JoinChan(channel *Channel) { + u.mu.Lock() + defer u.mu.Unlock() + + if _, ok := u.Chans[channel.Name]; ok { + return + } + + u.Chans[channel.Name] = channel + u.CurrentChan = channel + channel.Join(u) +} + +func (u *User) NUsername() string { + username := u.Username + identify := u.Identify + shortIdentify := identify[max(0, len(identify)-5):] + + return username[:min(len(username), 10)] + " (" + shortIdentify + ")" +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..94ff011 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,46 @@ +package config + +import ( + "encoding/json" + "os" +) + +type Config struct { + Server Server `json:"server"` + Channels []Channel `json:"channels"` +} + +type Server struct { + PrivateKey string `json:"private_key"` + Addr string `json:"addr"` + Name string `json:"name"` +} + +type Channel struct { + Name string `json:"name"` +} + +func Load(file string) (*Config, error) { + fp, err := os.Open(file) + if err != nil { + return nil, err + } + defer fp.Close() + + cfg := new(Config) + + return cfg, json.NewDecoder(fp).Decode(cfg) +} + +func Save(file string, cfg *Config) error { + fp, err := os.Create(file) + if err != nil { + return err + } + + enc := json.NewEncoder(fp) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + + return enc.Encode(cfg) +} diff --git a/internal/server/client.go b/internal/server/client.go new file mode 100644 index 0000000..dbea5a9 --- /dev/null +++ b/internal/server/client.go @@ -0,0 +1,58 @@ +package server + +import ( + "context" + "io" + "log/slog" + + "go.neonxp.ru/qchat/internal/chat" + "golang.org/x/term" +) + +func (s *Server) serveClient(ctx context.Context, rw io.ReadWriteCloser, user *chat.User) { + t := term.NewTerminal(rw, "[] ") + + go func() { + // defer s.bus.Unsubscribe(evCh) + for { + select { + case <-ctx.Done(): + return + default: + } + if user.CurrentChan != nil { + t.SetPrompt("[" + user.CurrentChan.Name + "] ") + } + line, err := t.ReadLine() + if err != nil { + s.chat.RemoveUser(user) + + if err != io.EOF { + slog.Error("failed read line", slog.Any("err", err)) + } + + return + } + if len(line) == 0 { + continue + } + + 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) + } + } +} diff --git a/internal/server/conn.go b/internal/server/conn.go new file mode 100644 index 0000000..1069b0b --- /dev/null +++ b/internal/server/conn.go @@ -0,0 +1,67 @@ +package server + +import ( + "context" + "fmt" + "log/slog" + "net" + "sync" + + "golang.org/x/crypto/ssh" +) + +func (s *Server) serveConn(ctx context.Context, nConn net.Conn, config *ssh.ServerConfig) error { + conn, chans, reqs, err := ssh.NewServerConn(nConn, config) + if err != nil { + return fmt.Errorf("failed to handshake: %w", err) + } + slog.Info("user connected", slog.Any("user", conn.User()), slog.String("ip", conn.RemoteAddr().String())) + + var wg sync.WaitGroup + defer wg.Wait() + + wg.Go(func() { + ssh.DiscardRequests(reqs) + }) + + for newChannel := range chans { + if newChannel.ChannelType() != "session" { + newChannel.Reject(ssh.UnknownChannelType, "unknown channel type") + continue + } + channel, requests, err := newChannel.Accept() + if err != nil { + return fmt.Errorf("could not accept channel: %w", err) + } + + 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() + }) + } + + return nil +} diff --git a/internal/server/display.go b/internal/server/display.go new file mode 100644 index 0000000..90d481a --- /dev/null +++ b/internal/server/display.go @@ -0,0 +1,83 @@ +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%s%s\t[%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/escapes.go b/internal/server/escapes.go new file mode 100644 index 0000000..f495345 --- /dev/null +++ b/internal/server/escapes.go @@ -0,0 +1,49 @@ +package server + +const ( + keyEscape = 27 +) + +var escapeCodes = map[color][]byte{ + Black: {keyEscape, '[', '3', '0', 'm'}, + Red: {keyEscape, '[', '3', '1', 'm'}, + Green: {keyEscape, '[', '3', '2', 'm'}, + Yellow: {keyEscape, '[', '3', '3', 'm'}, + Blue: {keyEscape, '[', '3', '4', 'm'}, + Magenta: {keyEscape, '[', '3', '5', 'm'}, + Cyan: {keyEscape, '[', '3', '6', 'm'}, + White: {keyEscape, '[', '3', '7', 'm'}, + Reset: {keyEscape, '[', '0', 'm'}, + Bold: {keyEscape, '[', '1', 'm'}, + Faint: {keyEscape, '[', '2', 'm'}, + Italic: {keyEscape, '[', '3', 'm'}, + Underline: {keyEscape, '[', '4', 'm'}, + Blink: {keyEscape, '[', '5', 'm'}, + Rapid: {keyEscape, '[', '6', 'm'}, + Reverse: {keyEscape, '[', '7', 'm'}, + Conceal: {keyEscape, '[', '8', 'm'}, + Strike: {keyEscape, '[', '9', 'm'}, +} + +type color int + +const ( + Black color = iota + Red + Green + Yellow + Blue + Magenta + Cyan + White + Reset + Bold + Faint + Italic + Underline + Blink + Rapid + Reverse + Conceal + Strike +) diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..1c28cd5 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,70 @@ +package server + +import ( + "context" + "encoding/hex" + "fmt" + "log/slog" + "net" + "sync" + + "go.neonxp.ru/qchat/internal/chat" + "go.neonxp.ru/qchat/internal/config" + "golang.org/x/crypto/ssh" +) + +type Server struct { + cfg *config.Config + chat *chat.Chat +} + +func New(cfg *config.Config, ch *chat.Chat) *Server { + return &Server{ + cfg: cfg, + chat: ch, + } +} + +func (s *Server) Run(ctx context.Context) error { + config := &ssh.ServerConfig{ + PublicKeyCallback: func(c ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) { + return &ssh.Permissions{ + ExtraData: map[any]any{ + "identify": hex.EncodeToString(pubKey.Marshal()), + }, + }, nil + }, + } + + private, err := ssh.ParsePrivateKey([]byte(s.cfg.Server.PrivateKey)) + if err != nil { + return fmt.Errorf("failed to parse private key: %w", err) + } + config.AddHostKey(private) + listener, err := net.Listen("tcp", s.cfg.Server.Addr) + if err != nil { + return fmt.Errorf("failed to parse private key: %w", err) + } + go func() { + <-ctx.Done() + listener.Close() + }() + + wg := sync.WaitGroup{} + defer wg.Wait() + + slog.InfoContext(ctx, "started server at", slog.String("addr", s.cfg.Server.Addr)) + + for { + nConn, err := listener.Accept() + if err != nil { + slog.ErrorContext(ctx, "failed to accept incoming connection", slog.Any("err", err)) + continue + } + wg.Go(func() { + if err := s.serveConn(ctx, nConn, config); err != nil { + slog.ErrorContext(ctx, "connection error", slog.Any("err", err)) + } + }) + } +} |
