From 15a740da0b85eda6ff1143905ce0a78ca96c2e8c Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 31 Jul 2026 14:47:50 +0530 Subject: [PATCH 1/8] feat: add per-conversation file lock package Exclusive, non-blocking advisory locks in ~/.chatwoot/locks/, one file per conversation ID. flock on unix, LockFileEx on Windows (promotes x/sys to a direct dependency). The OS releases the lock if the process dies, so there is no stale-lock cleanup. --- go.mod | 2 +- internal/lock/lock.go | 63 +++++++++++++++++++++++++++++++++++ internal/lock/lock_test.go | 45 +++++++++++++++++++++++++ internal/lock/lock_unix.go | 21 ++++++++++++ internal/lock/lock_windows.go | 25 ++++++++++++++ 5 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 internal/lock/lock.go create mode 100644 internal/lock/lock_test.go create mode 100644 internal/lock/lock_unix.go create mode 100644 internal/lock/lock_windows.go diff --git a/go.mod b/go.mod index 44402b1..f4dfefd 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/getkin/kin-openapi v0.140.0 github.com/jotaen/kong-completion v0.0.14 github.com/zalando/go-keyring v0.2.8 + golang.org/x/sys v0.46.0 golang.org/x/term v0.44.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -25,6 +26,5 @@ require ( github.com/posener/complete v1.2.3 // indirect github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect - golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.14.0 // indirect ) diff --git a/internal/lock/lock.go b/internal/lock/lock.go new file mode 100644 index 0000000..16d03b7 --- /dev/null +++ b/internal/lock/lock.go @@ -0,0 +1,63 @@ +// Package lock provides per-conversation advisory file locks so that +// concurrent chatwoot processes on the same machine don't run mutating +// commands (e.g. reply) against the same conversation at once. +package lock + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + + "github.com/chatwoot/cli/internal/config" +) + +// ErrLocked is returned when another process already holds the lock. +var ErrLocked = errors.New("conversation is locked by another chatwoot process") + +// Lock is a held per-conversation lock. Release it with Release. +// The OS releases it automatically if the process exits or crashes. +type Lock struct { + f *os.File +} + +// AcquireConversation takes an exclusive non-blocking lock for the given +// conversation ID. It returns ErrLocked (wrapped) if another process holds it. +func AcquireConversation(id int) (*Lock, error) { + cfgDir, err := config.ConfigDir() + if err != nil { + return nil, err + } + return acquireAt(filepath.Join(cfgDir, "locks"), id) +} + +func acquireAt(dir string, id int) (*Lock, error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, err + } + path := filepath.Join(dir, fmt.Sprintf("conv-%d.lock", id)) + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, err + } + if err := tryLock(f); err != nil { + f.Close() + return nil, fmt.Errorf("conversation %d: %w", id, err) + } + // Best-effort PID marker for debugging; the flock is the real lock. + _ = f.Truncate(0) + _, _ = f.WriteString(strconv.Itoa(os.Getpid()) + "\n") + return &Lock{f: f}, nil +} + +// Release unlocks and closes the lock file. The file itself is left in place: +// unlinking it would race with other processes locking the same path. +func (l *Lock) Release() { + if l == nil || l.f == nil { + return + } + _ = unlock(l.f) + _ = l.f.Close() + l.f = nil +} diff --git a/internal/lock/lock_test.go b/internal/lock/lock_test.go new file mode 100644 index 0000000..43911cd --- /dev/null +++ b/internal/lock/lock_test.go @@ -0,0 +1,45 @@ +package lock + +import ( + "errors" + "path/filepath" + "testing" +) + +func TestAcquireConflictAndRelease(t *testing.T) { + dir := filepath.Join(t.TempDir(), "locks") + + l1, err := acquireAt(dir, 42) + if err != nil { + t.Fatalf("first acquire: %v", err) + } + + if _, err := acquireAt(dir, 42); !errors.Is(err, ErrLocked) { + t.Fatalf("second acquire: want ErrLocked, got %v", err) + } + + // A different conversation is unaffected. + l2, err := acquireAt(dir, 43) + if err != nil { + t.Fatalf("acquire other conversation: %v", err) + } + l2.Release() + + l1.Release() + l3, err := acquireAt(dir, 42) + if err != nil { + t.Fatalf("reacquire after release: %v", err) + } + l3.Release() +} + +func TestReleaseIsIdempotent(t *testing.T) { + l, err := acquireAt(filepath.Join(t.TempDir(), "locks"), 1) + if err != nil { + t.Fatalf("acquire: %v", err) + } + l.Release() + l.Release() // must not panic + var nilLock *Lock + nilLock.Release() // nil receiver must be safe +} diff --git a/internal/lock/lock_unix.go b/internal/lock/lock_unix.go new file mode 100644 index 0000000..0a49ac8 --- /dev/null +++ b/internal/lock/lock_unix.go @@ -0,0 +1,21 @@ +//go:build !windows + +package lock + +import ( + "errors" + "os" + "syscall" +) + +func tryLock(f *os.File) error { + err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if errors.Is(err, syscall.EWOULDBLOCK) { + return ErrLocked + } + return err +} + +func unlock(f *os.File) error { + return syscall.Flock(int(f.Fd()), syscall.LOCK_UN) +} diff --git a/internal/lock/lock_windows.go b/internal/lock/lock_windows.go new file mode 100644 index 0000000..72cec85 --- /dev/null +++ b/internal/lock/lock_windows.go @@ -0,0 +1,25 @@ +//go:build windows + +package lock + +import ( + "errors" + "os" + + "golang.org/x/sys/windows" +) + +func tryLock(f *os.File) error { + ol := new(windows.Overlapped) + err := windows.LockFileEx(windows.Handle(f.Fd()), + windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, + 0, 1, 0, ol) + if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { + return ErrLocked + } + return err +} + +func unlock(f *os.File) error { + return windows.UnlockFileEx(windows.Handle(f.Fd()), 0, 1, 0, new(windows.Overlapped)) +} From e361d89f2feccc1229b1fe668fbfed821230dfda Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 31 Jul 2026 14:47:50 +0530 Subject: [PATCH 2/8] feat: fail fast when another process is mutating the same conversation conv reply, resolve, assign, and snooze now take the per-conversation lock around their API call, so running e.g. reply concurrently from two terminals sends once and errors once instead of double-sending. Fail-fast rather than wait: a queued duplicate would still fire after the holder finishes. --- CHANGELOG.md | 2 + internal/cmd/conversation.go | 62 +++++++++++++++++++------- internal/cmd/conversation_lock_test.go | 46 +++++++++++++++++++ 3 files changed, 94 insertions(+), 16 deletions(-) create mode 100644 internal/cmd/conversation_lock_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e63bfd..984b7a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Per-conversation lock for `conv reply`, `resolve`, `assign`, and `snooze`: concurrent mutations of the same conversation from separate terminals now fail fast instead of both running. Locks live in `~/.chatwoot/locks/` and are released automatically by the OS if the process dies. + ### Changed ### Fixed diff --git a/internal/cmd/conversation.go b/internal/cmd/conversation.go index a0eacb9..40d7888 100644 --- a/internal/cmd/conversation.go +++ b/internal/cmd/conversation.go @@ -1,12 +1,14 @@ package cmd import ( + "errors" "fmt" "strconv" "strings" "time" "github.com/chatwoot/cli/internal/config" + "github.com/chatwoot/cli/internal/lock" "github.com/chatwoot/cli/internal/output" "github.com/chatwoot/cli/internal/sdk" ) @@ -184,6 +186,25 @@ func (c *ConvMessagesCmd) Run(app *App) error { return nil } +// -- per-conversation lock ---------------------------------------------------- + +// withConvLock runs fn while holding the per-conversation lock, so concurrent +// chatwoot processes can't mutate the same conversation at once. If another +// process holds the lock, it fails fast instead of waiting: a queued duplicate +// would still fire after the holder finishes, which is exactly what the lock +// exists to prevent. +func withConvLock(id int, fn func() error) error { + lk, err := lock.AcquireConversation(id) + if err != nil { + if errors.Is(err, lock.ErrLocked) { + return fmt.Errorf("conversation %d: another chatwoot command is already running on this conversation; try again in a moment", id) + } + return err + } + defer lk.Release() + return fn() +} + // -- reply -------------------------------------------------------------------- type ConvReplyCmd struct { @@ -193,20 +214,22 @@ type ConvReplyCmd struct { } func (c *ConvReplyCmd) Run(app *App) error { - msg, err := app.Client.Messages(c.ID).Create(c.Text, c.Private) - if err != nil { - return err - } - if app.Printer.Quiet { - fmt.Println(msg.ID) + return withConvLock(c.ID, func() error { + msg, err := app.Client.Messages(c.ID).Create(c.Text, c.Private) + if err != nil { + return err + } + if app.Printer.Quiet { + fmt.Println(msg.ID) + return nil + } + kind := "reply" + if c.Private { + kind = "note" + } + fmt.Printf("Sent %s on conversation %d (message %d).\n", kind, c.ID, msg.ID) return nil - } - kind := "reply" - if c.Private { - kind = "note" - } - fmt.Printf("Sent %s on conversation %d (message %d).\n", kind, c.ID, msg.ID) - return nil + }) } // -- status verbs: resolve / open / pending / snooze -------------------------- @@ -216,7 +239,9 @@ type ConvResolveCmd struct { } func (c *ConvResolveCmd) Run(app *App) error { - return setStatus(app, c.ID, "resolved", nil) + return withConvLock(c.ID, func() error { + return setStatus(app, c.ID, "resolved", nil) + }) } type ConvOpenCmd struct { @@ -249,7 +274,9 @@ func (c *ConvSnoozeCmd) Run(app *App) error { } until = &ts } - return setStatus(app, c.ID, "snoozed", until) + return withConvLock(c.ID, func() error { + return setStatus(app, c.ID, "snoozed", until) + }) } func setStatus(app *App, id int, status string, snoozedUntil *int64) error { @@ -321,7 +348,10 @@ func (c *ConvAssignCmd) Run(app *App) error { } agentPtr = &id } - if _, err := app.Client.Conversations().Assign(c.ID, agentPtr, c.Team); err != nil { + if err := withConvLock(c.ID, func() error { + _, err := app.Client.Conversations().Assign(c.ID, agentPtr, c.Team) + return err + }); err != nil { return err } if app.Printer.Quiet { diff --git a/internal/cmd/conversation_lock_test.go b/internal/cmd/conversation_lock_test.go new file mode 100644 index 0000000..dd36c08 --- /dev/null +++ b/internal/cmd/conversation_lock_test.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/chatwoot/cli/internal/lock" +) + +// Mutating conversation commands must fail fast when another process holds the +// per-conversation lock. The App is empty on purpose: the commands must bail +// out before touching the API client. +func TestConvMutationsFailWhenConversationLocked(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) // unix + t.Setenv("USERPROFILE", home) // windows + + const convID = 555 + lk, err := lock.AcquireConversation(convID) + if err != nil { + t.Fatalf("AcquireConversation: %v", err) + } + defer lk.Release() + + app := &App{} + cases := []struct { + name string + run func() error + }{ + {"reply", func() error { return (&ConvReplyCmd{ID: convID, Text: "hi"}).Run(app) }}, + {"resolve", func() error { return (&ConvResolveCmd{ID: convID}).Run(app) }}, + {"assign", func() error { return (&ConvAssignCmd{ID: convID, Team: 1}).Run(app) }}, + {"snooze", func() error { return (&ConvSnoozeCmd{ID: convID}).Run(app) }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.run() + if err == nil { + t.Fatal("want error while conversation is locked, got nil") + } + if !strings.Contains(err.Error(), "already running on this conversation") { + t.Errorf("error = %q, want lock-held message", err) + } + }) + } +} From ce6e1c972e97cbf8628c78bb661fb8dd856a6d36 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 31 Jul 2026 14:49:16 +0530 Subject: [PATCH 3/8] feat: guard conv open, pending, and unassign with the per-conversation lock Completes lock coverage across all mutating conversation verbs, so any overlapping pair of them fails fast instead of double-running. --- CHANGELOG.md | 2 +- internal/cmd/conversation.go | 12 +++++++++--- internal/cmd/conversation_lock_test.go | 3 +++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 984b7a7..e33b116 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Per-conversation lock for `conv reply`, `resolve`, `assign`, and `snooze`: concurrent mutations of the same conversation from separate terminals now fail fast instead of both running. Locks live in `~/.chatwoot/locks/` and are released automatically by the OS if the process dies. +- Per-conversation lock for `conv reply`, `resolve`, `open`, `pending`, `snooze`, `assign`, and `unassign`: concurrent mutations of the same conversation from separate terminals now fail fast instead of both running. Locks live in `~/.chatwoot/locks/` and are released automatically by the OS if the process dies. ### Changed diff --git a/internal/cmd/conversation.go b/internal/cmd/conversation.go index 40d7888..353d460 100644 --- a/internal/cmd/conversation.go +++ b/internal/cmd/conversation.go @@ -249,7 +249,9 @@ type ConvOpenCmd struct { } func (c *ConvOpenCmd) Run(app *App) error { - return setStatus(app, c.ID, "open", nil) + return withConvLock(c.ID, func() error { + return setStatus(app, c.ID, "open", nil) + }) } type ConvPendingCmd struct { @@ -257,7 +259,9 @@ type ConvPendingCmd struct { } func (c *ConvPendingCmd) Run(app *App) error { - return setStatus(app, c.ID, "pending", nil) + return withConvLock(c.ID, func() error { + return setStatus(app, c.ID, "pending", nil) + }) } type ConvSnoozeCmd struct { @@ -374,7 +378,9 @@ type ConvUnassignCmd struct { } func (c *ConvUnassignCmd) Run(app *App) error { - if err := app.Client.Conversations().Unassign(c.ID); err != nil { + if err := withConvLock(c.ID, func() error { + return app.Client.Conversations().Unassign(c.ID) + }); err != nil { return err } if app.Printer.Quiet { diff --git a/internal/cmd/conversation_lock_test.go b/internal/cmd/conversation_lock_test.go index dd36c08..e486d74 100644 --- a/internal/cmd/conversation_lock_test.go +++ b/internal/cmd/conversation_lock_test.go @@ -29,7 +29,10 @@ func TestConvMutationsFailWhenConversationLocked(t *testing.T) { }{ {"reply", func() error { return (&ConvReplyCmd{ID: convID, Text: "hi"}).Run(app) }}, {"resolve", func() error { return (&ConvResolveCmd{ID: convID}).Run(app) }}, + {"open", func() error { return (&ConvOpenCmd{ID: convID}).Run(app) }}, + {"pending", func() error { return (&ConvPendingCmd{ID: convID}).Run(app) }}, {"assign", func() error { return (&ConvAssignCmd{ID: convID, Team: 1}).Run(app) }}, + {"unassign", func() error { return (&ConvUnassignCmd{ID: convID}).Run(app) }}, {"snooze", func() error { return (&ConvSnoozeCmd{ID: convID}).Run(app) }}, } for _, tc := range cases { From 6e22e02ea6120950a8275f8dd155f7f58138c18e Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 31 Jul 2026 14:50:12 +0530 Subject: [PATCH 4/8] feat: guard conv label with the per-conversation lock --- CHANGELOG.md | 2 +- internal/cmd/conversation.go | 5 ++++- internal/cmd/conversation_lock_test.go | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e33b116..29e9378 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Per-conversation lock for `conv reply`, `resolve`, `open`, `pending`, `snooze`, `assign`, and `unassign`: concurrent mutations of the same conversation from separate terminals now fail fast instead of both running. Locks live in `~/.chatwoot/locks/` and are released automatically by the OS if the process dies. +- Per-conversation lock for `conv reply`, `resolve`, `open`, `pending`, `snooze`, `assign`, `unassign`, and `label`: concurrent mutations of the same conversation from separate terminals now fail fast instead of both running. Locks live in `~/.chatwoot/locks/` and are released automatically by the OS if the process dies. ### Changed diff --git a/internal/cmd/conversation.go b/internal/cmd/conversation.go index 353d460..9c31355 100644 --- a/internal/cmd/conversation.go +++ b/internal/cmd/conversation.go @@ -407,7 +407,10 @@ func (c *ConvLabelCmd) Run(app *App) error { } } } - if _, err := app.Client.Labels(c.ID).Add(flat); err != nil { + if err := withConvLock(c.ID, func() error { + _, err := app.Client.Labels(c.ID).Add(flat) + return err + }); err != nil { return err } if app.Printer.Quiet { diff --git a/internal/cmd/conversation_lock_test.go b/internal/cmd/conversation_lock_test.go index e486d74..2ddf643 100644 --- a/internal/cmd/conversation_lock_test.go +++ b/internal/cmd/conversation_lock_test.go @@ -34,6 +34,7 @@ func TestConvMutationsFailWhenConversationLocked(t *testing.T) { {"assign", func() error { return (&ConvAssignCmd{ID: convID, Team: 1}).Run(app) }}, {"unassign", func() error { return (&ConvUnassignCmd{ID: convID}).Run(app) }}, {"snooze", func() error { return (&ConvSnoozeCmd{ID: convID}).Run(app) }}, + {"label", func() error { return (&ConvLabelCmd{ID: convID, Labels: []string{"vip"}}).Run(app) }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { From 9e08ea1f375287374917bc541027b8f208dfa765 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 31 Jul 2026 14:51:00 +0530 Subject: [PATCH 5/8] feat: guard conv priority with the per-conversation lock Completes lock coverage: every mutating conv verb now takes the lock. --- CHANGELOG.md | 2 +- internal/cmd/conversation.go | 4 +++- internal/cmd/conversation_lock_test.go | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29e9378..2c704ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Per-conversation lock for `conv reply`, `resolve`, `open`, `pending`, `snooze`, `assign`, `unassign`, and `label`: concurrent mutations of the same conversation from separate terminals now fail fast instead of both running. Locks live in `~/.chatwoot/locks/` and are released automatically by the OS if the process dies. +- Per-conversation lock for all mutating `conv` verbs (`reply`, `resolve`, `open`, `pending`, `snooze`, `assign`, `unassign`, `label`, `priority`): concurrent mutations of the same conversation from separate terminals now fail fast instead of both running. Locks live in `~/.chatwoot/locks/` and are released automatically by the OS if the process dies. ### Changed diff --git a/internal/cmd/conversation.go b/internal/cmd/conversation.go index 9c31355..5b2db39 100644 --- a/internal/cmd/conversation.go +++ b/internal/cmd/conversation.go @@ -433,7 +433,9 @@ func (c *ConvPriorityCmd) Run(app *App) error { if value == "none" { value = "" } - if err := app.Client.Conversations().UpdatePriority(c.ID, value); err != nil { + if err := withConvLock(c.ID, func() error { + return app.Client.Conversations().UpdatePriority(c.ID, value) + }); err != nil { return err } if app.Printer.Quiet { diff --git a/internal/cmd/conversation_lock_test.go b/internal/cmd/conversation_lock_test.go index 2ddf643..67b0f71 100644 --- a/internal/cmd/conversation_lock_test.go +++ b/internal/cmd/conversation_lock_test.go @@ -35,6 +35,7 @@ func TestConvMutationsFailWhenConversationLocked(t *testing.T) { {"unassign", func() error { return (&ConvUnassignCmd{ID: convID}).Run(app) }}, {"snooze", func() error { return (&ConvSnoozeCmd{ID: convID}).Run(app) }}, {"label", func() error { return (&ConvLabelCmd{ID: convID, Labels: []string{"vip"}}).Run(app) }}, + {"priority", func() error { return (&ConvPriorityCmd{ID: convID, Level: "high"}).Run(app) }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { From 61c210f6efe8cb82e877f02f53bae1af2e5fa7b0 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 31 Jul 2026 14:59:53 +0530 Subject: [PATCH 6/8] fix: acquire the conversation lock before resolving the agent on assign resolveAgent hits the agents/profile API when --agent is a name or an uncached me, so locking after it meant a lock conflict could surface as a masked lookup error instead of failing fast before any request. Resolution now happens while the lock is held. --- internal/cmd/conversation.go | 17 ++++++++++------- internal/cmd/conversation_lock_test.go | 2 ++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/internal/cmd/conversation.go b/internal/cmd/conversation.go index 5b2db39..72ee71c 100644 --- a/internal/cmd/conversation.go +++ b/internal/cmd/conversation.go @@ -344,15 +344,18 @@ func (c *ConvAssignCmd) Run(app *App) error { if c.Agent == "" && c.Team == 0 { return fmt.Errorf("--agent or --team required") } + // Lock before resolveAgent: its agents/profile lookup is an API call, and + // a lock conflict must surface before any request, not as a masked lookup + // error or a delayed failure. var agentPtr *int - if c.Agent != "" { - id, err := resolveAgent(app, c.Agent) - if err != nil { - return err - } - agentPtr = &id - } if err := withConvLock(c.ID, func() error { + if c.Agent != "" { + id, err := resolveAgent(app, c.Agent) + if err != nil { + return err + } + agentPtr = &id + } _, err := app.Client.Conversations().Assign(c.ID, agentPtr, c.Team) return err }); err != nil { diff --git a/internal/cmd/conversation_lock_test.go b/internal/cmd/conversation_lock_test.go index 67b0f71..c0c08d1 100644 --- a/internal/cmd/conversation_lock_test.go +++ b/internal/cmd/conversation_lock_test.go @@ -32,6 +32,8 @@ func TestConvMutationsFailWhenConversationLocked(t *testing.T) { {"open", func() error { return (&ConvOpenCmd{ID: convID}).Run(app) }}, {"pending", func() error { return (&ConvPendingCmd{ID: convID}).Run(app) }}, {"assign", func() error { return (&ConvAssignCmd{ID: convID, Team: 1}).Run(app) }}, + // By-name assign: the lock must win before resolveAgent's API lookup. + {"assign-by-name", func() error { return (&ConvAssignCmd{ID: convID, Agent: "jane"}).Run(app) }}, {"unassign", func() error { return (&ConvUnassignCmd{ID: convID}).Run(app) }}, {"snooze", func() error { return (&ConvSnoozeCmd{ID: convID}).Run(app) }}, {"label", func() error { return (&ConvLabelCmd{ID: convID, Labels: []string{"vip"}}).Run(app) }}, From 03c1f2d8fc3dc32aa02cdc72fe1fad262aae47da Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 31 Jul 2026 15:12:23 +0530 Subject: [PATCH 7/8] chore: bump Go to 1.26.5 for the GO-2026-5856 crypto/tls fix govulncheck flagged the Encrypted Client Hello privacy leak in the 1.26.4 standard library, reachable through the SDK's HTTP client. --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index f4dfefd..c78a723 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/chatwoot/cli -go 1.26.4 +go 1.26.5 require ( github.com/alecthomas/kong v1.15.0 From 999086e2df45acc17037883d62ad63f140fbefae Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 31 Jul 2026 15:12:23 +0530 Subject: [PATCH 8/8] chore: satisfy errcheck on the lock acquire failure path --- internal/lock/lock.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/lock/lock.go b/internal/lock/lock.go index 16d03b7..36a90a0 100644 --- a/internal/lock/lock.go +++ b/internal/lock/lock.go @@ -42,7 +42,7 @@ func acquireAt(dir string, id int) (*Lock, error) { return nil, err } if err := tryLock(f); err != nil { - f.Close() + _ = f.Close() return nil, fmt.Errorf("conversation %d: %w", id, err) } // Best-effort PID marker for debugging; the flock is the real lock.