From 8dd8dc1eec4a87cb34511bbdd0a661daaf58c377 Mon Sep 17 00:00:00 2001 From: cuiko Date: Thu, 4 Jun 2026 02:57:11 +0800 Subject: [PATCH 1/2] feat(daemon): Negotiate media-send capability to skip stopping the daemon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tg msg send --sticker/--gif` unconditionally took the local MTProto path, so a running daemon's account lock forced users to `tg daemon stop` before sending media. That local-only gate existed only to dodge a stale daemon silently dropping the unknown Sticker/Gif SendQuery fields and posting an empty message. Have the daemon advertise a media-send capability in its Hello frame and route sticker/GIF sends over IPC when the connected daemon supports it. This is dev-build safe (a capability, not a version string) and additive on the wire: an older daemon omits the feature and the client falls back. When a media send hits a daemon too old to relay it, return a clear busy error pointing at `daemon restart` / `daemon stop` instead of a bare lock failure. Text sends are unchanged — version-agnostic and always routed through a reachable daemon. --- internal/cli/msg/send/send.go | 37 ++++++++++++------------- internal/daemon/client.go | 12 ++++++++ internal/daemon/client_features_test.go | 24 ++++++++++++++++ internal/daemon/protocol.go | 21 ++++++++++++-- internal/daemon/server.go | 1 + internal/daemon/server_test.go | 2 ++ 6 files changed, 75 insertions(+), 22 deletions(-) create mode 100644 internal/daemon/client_features_test.go diff --git a/internal/cli/msg/send/send.go b/internal/cli/msg/send/send.go index 0df54d9..1589424 100644 --- a/internal/cli/msg/send/send.go +++ b/internal/cli/msg/send/send.go @@ -125,23 +125,29 @@ func Run(ctx context.Context, opts *Options) error { // newSend returns the production Send closure that calls the Telegram API. // -// Daemon fast-path applies only when the payload is pure text — file -// attachments and stdin require byte streams the IPC socket does not -// carry yet, so those fall through to the local WithPeers path. +// Daemon fast-path: text/metadata sends always route through a reachable +// daemon; sticker/GIF sends route through it only when the daemon advertised +// the media-send capability (older daemons would drop the field and post an +// empty message). File attachments carry bytes the IPC socket cannot relay, so +// they always take the local WithPeers path. func newSend(f *runtime.Invocation) actionmessage.SendFunc { return func(ctx context.Context, q actionmessage.SendQuery) ([]output.SendResultRow, error) { acct, err := f.Account("") if err != nil { return nil, err } - if canDaemonSend(q) { + if len(q.Attachments) == 0 { if cl, _ := runtime.MaybeDialDaemon(ctx, f, acct); cl != nil { + if isMediaSend(q) && !cl.SupportsMediaSend() { + _ = cl.Close() + return nil, fmt.Errorf("%w: the running daemon is too old to relay stickers/GIFs; "+ + "run `tg daemon restart` to enable daemon media sends, or `tg daemon stop` to send locally", account.ErrBusy) + } defer func() { _ = cl.Close() }() // SendQuery.Stdin is io.Reader, which the JSON encoder - // renders as `{}` and the decoder cannot rehydrate. - // Strip it before sending — telegram.SendMessage only - // consumes Stdin via Attachment.Path == "-", which the - // canDaemonSend gate already excluded. + // renders as `{}` and the decoder cannot rehydrate. Strip + // it before sending — telegram.SendMessage only consumes + // Stdin via Attachment.Path == "-", excluded above. wire := q wire.Stdin = nil raw, err := cl.Call(ctx, "msg.send", wire) @@ -172,17 +178,10 @@ func newSend(f *runtime.Invocation) actionmessage.SendFunc { } } -// canDaemonSend reports whether a SendQuery is daemon-routable. -// Attachments require bytes the daemon cannot read from the client's -// filesystem, so file uploads fall back to local mode. Stdin alone -// is NOT a gate — the CLI plumbs IOStreams.In into every SendQuery -// for use during stdin-text or stdin-file paths, but telegram.SendMessage -// only consumes Stdin when an Attachment with Path == "-" is present, -// and Normalize already resolves stdin-as-text into Text before this -// point. Schedule + Silent + Parse + ReplyTo are pure metadata and -// stay supported. -func canDaemonSend(q actionmessage.SendQuery) bool { - return len(q.Attachments) == 0 && q.Sticker == nil && q.Gif == nil +// isMediaSend reports whether the query carries a sticker or GIF, which a +// daemon can only relay when it advertised FeatureMediaSend. +func isMediaSend(q actionmessage.SendQuery) bool { + return q.Sticker != nil || q.Gif != nil } func recordSentMessages(store *account.PeerStore, peerRef, text string, rows []output.SendResultRow) { diff --git a/internal/daemon/client.go b/internal/daemon/client.go index a302ae8..0d8f212 100644 --- a/internal/daemon/client.go +++ b/internal/daemon/client.go @@ -143,6 +143,18 @@ func AttachClient(conn net.Conn) (*Client, error) { // Hello returns the welcome payload received at connect time. func (c *Client) Hello() HelloPayload { return c.hello } +// SupportsMediaSend reports whether the connected daemon advertised the +// media-send capability, i.e. it can relay sticker/GIF sends over IPC. When +// false the caller must use the local path for media sends. +func (c *Client) SupportsMediaSend() bool { + for _, f := range c.hello.Features { + if f == FeatureMediaSend { + return true + } + } + return false +} + // Stats fetches the daemon's live MetricsSnapshot via the built-in // daemon.stats RPC. Used by tg daemon status to surface real-time // observability alongside the host service manager's installed/ diff --git a/internal/daemon/client_features_test.go b/internal/daemon/client_features_test.go new file mode 100644 index 0000000..762484a --- /dev/null +++ b/internal/daemon/client_features_test.go @@ -0,0 +1,24 @@ +package daemon + +import "testing" + +func TestSupportsMediaSend(t *testing.T) { + cases := []struct { + name string + features []string + want bool + }{ + {"advertised", []string{FeatureMediaSend}, true}, + {"advertised among others", []string{"other", FeatureMediaSend}, true}, + {"old daemon omits it", []string{"other"}, false}, + {"old daemon no features", nil, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c := &Client{hello: HelloPayload{Features: tc.features}} + if got := c.SupportsMediaSend(); got != tc.want { + t.Fatalf("SupportsMediaSend() = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/internal/daemon/protocol.go b/internal/daemon/protocol.go index e62569a..55bb4db 100644 --- a/internal/daemon/protocol.go +++ b/internal/daemon/protocol.go @@ -12,6 +12,20 @@ import ( // because both ends ignore unknown JSON fields. const ProtocolSchema = 1 +// FeatureMediaSend is advertised in the Hello frame by daemons that can relay +// sticker/GIF sends over IPC (i.e. their SendQuery decoder understands the +// Sticker/Gif fields). A client that does not see this feature must fall back +// to the local path for media sends, because an older daemon would silently +// drop the unknown field and post an empty message. Additive on the wire: +// older daemons simply omit it, older clients ignore it. +const FeatureMediaSend = "msg.send.media" + +// DaemonFeatures lists the optional capabilities this build advertises to +// clients in the Hello frame. +func DaemonFeatures() []string { + return []string{FeatureMediaSend} +} + // Frame is the union sent in both directions over the Unix socket. // Exactly one of Method / Result / Error / Event is populated. The // daemon and client both decode into Frame first, then dispatch on @@ -73,9 +87,10 @@ type FrameError struct { // connection. The client uses it to confirm the server is the right // account and that the schema version matches. type HelloPayload struct { - DaemonVersion string `json:"daemon_version"` - Account string `json:"account"` - Schema int `json:"schema"` + DaemonVersion string `json:"daemon_version"` + Account string `json:"account"` + Schema int `json:"schema"` + Features []string `json:"features,omitempty"` } // SubscribeParams names the kinds/peers a subscriber wants. Empty diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 4b39285..8aefb54 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -233,6 +233,7 @@ func (s *Server) handleConn(parentCtx context.Context, conn net.Conn) { DaemonVersion: version.Version, Account: s.account, Schema: ProtocolSchema, + Features: DaemonFeatures(), }) if err := writeFrame(hello); err != nil { return diff --git a/internal/daemon/server_test.go b/internal/daemon/server_test.go index 340eac1..c095678 100644 --- a/internal/daemon/server_test.go +++ b/internal/daemon/server_test.go @@ -74,6 +74,8 @@ func TestServer_HelloFrameOnConnect(t *testing.T) { hello := cl.Hello() require.Equal(t, "alice", hello.Account) require.Equal(t, daemon.ProtocolSchema, hello.Schema) + require.Contains(t, hello.Features, daemon.FeatureMediaSend) + require.True(t, cl.SupportsMediaSend()) } func TestServer_PingPong(t *testing.T) { From c4ea93c35a8a14d7e84b2d87ec9e23dd173d78a9 Mon Sep 17 00:00:00 2001 From: cuiko Date: Thu, 4 Jun 2026 18:16:04 +0800 Subject: [PATCH 2/2] docs(reply): Drop stale canDaemonSend reference after media negotiation The send-path gate is no longer the canDaemonSend helper (removed in favor of capability negotiation). Update reply's comments to point at newSend instead. --- internal/cli/reply/reply.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/cli/reply/reply.go b/internal/cli/reply/reply.go index 8ed6c70..589c20c 100644 --- a/internal/cli/reply/reply.go +++ b/internal/cli/reply/reply.go @@ -106,13 +106,14 @@ func newSend(f *runtime.Invocation) actionmessage.SendFunc { // Daemon fast-path: text-only / no attachments. Stdin alone is // not a gate (CLI plumbs IOStreams.In into every Query, but // telegram.SendMessage only consumes it via Attachment.Path=="-"). - // Mirrors canDaemonSend in internal/cli/msg/send. + // reply has no sticker/GIF flags, so the attachment check is the + // whole gate; see newSend in internal/cli/msg/send for the + // media-capability negotiation that path adds. if len(q.Attachments) == 0 { if cl, _ := runtime.MaybeDialDaemon(ctx, f, acct); cl != nil { defer func() { _ = cl.Close() }() - // Strip Stdin io.Reader before wire encode — JSON - // cannot rehydrate the interface; see canDaemonSend - // comment for full rationale. + // Strip Stdin io.Reader before wire encode — JSON cannot + // rehydrate the interface. wire := q wire.Stdin = nil raw, err := cl.Call(ctx, "msg.send", wire)