Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 18 additions & 19 deletions internal/cli/msg/send/send.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 5 additions & 4 deletions internal/cli/reply/reply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions internal/daemon/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
24 changes: 24 additions & 0 deletions internal/daemon/client_features_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
21 changes: 18 additions & 3 deletions internal/daemon/protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/daemon/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions internal/daemon/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down