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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,10 @@ client commands route through when present).
| `tg chat perms <ref>` | Set the group's default member permissions with `--deny`/`--allow` (same keywords). |
| `tg msg list <ref>` | List message history. Album members (same `grouped_id`) merge into one row with an `album` array (`--limit` counts an album as one). |
| `tg msg info <msg-ref>` | Show one message's details: media info (file name/size/mime, dimensions, duration, sticker emoji, web-page title/url), album members (each with media detail), and poll content (question, numbered options, tallies). |
| `tg msg send <ref> [text...]` | Send text. Repeat `--file <path>` to attach one or more files; text becomes the first media caption. Use `--name` to override upload filenames. `--sticker <ref>` sends a sticker — either a `msg sticker list` ref or a `<msg-ref>` of an existing sticker (no text/`--file`). |
| `tg msg send <ref> [text...]` | Send text. Repeat `--file <path>` to attach one or more files; text becomes the first media caption. Use `--name` to override upload filenames. `--sticker <ref>` / `--gif <ref>` send a sticker/gif — either a `msg sticker list`/`msg gif list` ref or a `<msg-ref>` of an existing one (no text/`--file`). |
| `tg msg sticker list` | List your stickers to get a sendable `ref`. `--recent` (default), `--faved`, `--installed` (sets), `--all` (recent+faved+all sets expanded; slow). |
| `tg msg sticker fave <ref>` / `tg msg sticker unfave <ref>` | Add or remove a sticker from favorites (`<ref>` is a list ref or `<msg-ref>`). |
| `tg msg gif list` | List your saved GIFs, each with a sendable `ref`. |
| `tg msg poll <ref> <question> <option>...` | Send a poll (≥2 options). `--multiple`, `--public` (default anonymous); `--correct <n>` makes it a quiz with optional `--explanation`. |
| `tg msg vote <msg-ref> <option-number>...` | Vote on a poll by 1-based option number (multiple numbers for multiple-choice polls); `--retract` to take back your vote. |
| `tg msg download <msg-ref>` | Download photo, video, document, or other message media. Defaults to the media filename; use `-o/--output` for a file path or existing directory. |
Expand Down
77 changes: 77 additions & 0 deletions internal/action/message/gif.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package message

import (
"context"
"fmt"

"github.com/vika2603/telegram-cli/internal/command"
"github.com/vika2603/telegram-cli/internal/output"
"github.com/vika2603/telegram-cli/internal/ref"
)

// gifTokenPrefix marks a `msg gif list` ref handle, distinct from a sticker ref
// and from a message ref passed to `--gif`.
const gifTokenPrefix = "gif_"

// EncodeGifToken / DecodeGifToken mirror the sticker token but with a gif
// prefix. GIFs belong to no set, so the set fields stay zero.
func EncodeGifToken(doc StickerDoc) string { return encodeDocToken(gifTokenPrefix, doc) }

// DecodeGifToken reverses EncodeGifToken; ok is false when s isn't a gif token.
func DecodeGifToken(s string) (*StickerDoc, bool, error) { return decodeDocToken(gifTokenPrefix, s) }

// parseGifSource interprets a gif argument as a `msg gif list` ref token or a
// message ref.
func parseGifSource(raw string) (*StickerSource, error) {
src := &StickerSource{}
if doc, ok, err := DecodeGifToken(raw); err != nil {
return nil, err
} else if ok {
src.Doc = doc
return src, nil
}
mref, err := parseMessageRef(raw)
if err != nil {
return nil, err
}
src.Peer, src.MessageID = mref.Peer, mref.MessageID
return src, nil
}

// normalizeGifSend handles `tg msg send <ref> --gif <ref>`. Like a sticker, a
// gif is sent on its own.
func normalizeGifSend(req SendRequest) (SendQuery, error) {
if req.Text != "" {
return SendQuery{}, fmt.Errorf("%w: --gif cannot be combined with message text", command.ErrUsage)
}
if len(compactStrings(req.Files)) > 0 {
return SendQuery{}, fmt.Errorf("%w: --gif cannot be combined with --file", command.ErrUsage)
}
target, err := ref.ParseRef(req.RawRef)
if err != nil {
return SendQuery{}, fmt.Errorf("%w: %s", command.ErrUsage, err.Error())
}
source, err := parseGifSource(req.Gif)
if err != nil {
return SendQuery{}, err
}
return SendQuery{
Ref: target,
Gif: source,
ReplyTo: req.ReplyTo,
Silent: req.Silent,
Schedule: req.Schedule,
RandomID: req.RandomID,
}, nil
}

// GifListFunc lists the account's saved GIFs.
type GifListFunc func(context.Context) ([]output.StickerRow, error)

// ListGifs dispatches `tg msg gif list`.
func ListGifs(ctx context.Context, do GifListFunc) ([]output.StickerRow, error) {
if do == nil {
return nil, fmt.Errorf("%w: msg gif list called without list function", command.ErrPrecondition)
}
return do(ctx)
}
76 changes: 76 additions & 0 deletions internal/action/message/gif_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package message_test

import (
"context"
"testing"

"github.com/stretchr/testify/require"

actionmessage "github.com/vika2603/telegram-cli/internal/action/message"
"github.com/vika2603/telegram-cli/internal/command"
"github.com/vika2603/telegram-cli/internal/output"
)

func TestGifToken_RoundTrip(t *testing.T) {
in := actionmessage.StickerDoc{ID: 99, AccessHash: -7, FileReference: []byte{0x09, 0x08}}
tok := actionmessage.EncodeGifToken(in)
out, ok, err := actionmessage.DecodeGifToken(tok)
require.NoError(t, err)
require.True(t, ok)
require.Equal(t, in.ID, out.ID)
require.Equal(t, in.FileReference, out.FileReference)
}

func TestGifToken_NotConfusedWithSticker(t *testing.T) {
gifTok := actionmessage.EncodeGifToken(actionmessage.StickerDoc{ID: 1, FileReference: []byte{1}})
// A gif token must not decode as a sticker token, and vice versa.
_, ok, err := actionmessage.DecodeStickerToken(gifTok)
require.NoError(t, err)
require.False(t, ok)

stkTok := actionmessage.EncodeStickerToken(actionmessage.StickerDoc{ID: 1, FileReference: []byte{1}})
_, ok, err = actionmessage.DecodeGifToken(stkTok)
require.NoError(t, err)
require.False(t, ok)
}

func TestSend_GifTokenResolvesToDoc(t *testing.T) {
tok := actionmessage.EncodeGifToken(actionmessage.StickerDoc{ID: 5, AccessHash: 6, FileReference: []byte{0xbb}})
_, err := actionmessage.Send(context.Background(), actionmessage.SendRequest{
RawRef: "@bob", Gif: tok,
}, func(_ context.Context, q actionmessage.SendQuery) ([]output.SendResultRow, error) {
require.NotNil(t, q.Gif)
require.NotNil(t, q.Gif.Doc)
require.Equal(t, int64(5), q.Gif.Doc.ID)
require.Nil(t, q.Sticker)
return []output.SendResultRow{{Action: "send", MessageID: 1}}, nil
})
require.NoError(t, err)
}

func TestSend_GifMsgRef(t *testing.T) {
_, err := actionmessage.Send(context.Background(), actionmessage.SendRequest{
RawRef: "@bob", Gif: "@alice:42",
}, func(_ context.Context, q actionmessage.SendQuery) ([]output.SendResultRow, error) {
require.NotNil(t, q.Gif)
require.Equal(t, "alice", q.Gif.Peer.Value)
require.Equal(t, 42, q.Gif.MessageID)
return []output.SendResultRow{{Action: "send"}}, nil
})
require.NoError(t, err)
}

func TestSend_GifRejectsTextAndFile(t *testing.T) {
for _, req := range []actionmessage.SendRequest{
{RawRef: "@bob", Gif: "@a:1", Text: "hi"},
{RawRef: "@bob", Gif: "@a:1", Files: []string{"/tmp/x"}},
{RawRef: "@bob", Gif: "@a:1", Sticker: "@a:2"},
} {
_, err := actionmessage.Send(context.Background(), req,
func(context.Context, actionmessage.SendQuery) ([]output.SendResultRow, error) {
t.Fatal("must not dispatch")
return nil, nil
})
require.ErrorIs(t, err, command.ErrUsage)
}
}
14 changes: 11 additions & 3 deletions internal/action/message/ops.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ type SendRequest struct {
Text string
Files []string
Names []string
Sticker string // message ref of an existing sticker to resend
Sticker string // ref of a sticker to send (list ref or message ref)
Gif string // ref of a gif to send (list ref or message ref)
ReplyTo int
Silent bool
Schedule time.Time
Expand All @@ -35,9 +36,10 @@ type SendQuery struct {
Ref ref.Ref
Text string
Attachments []Attachment
// Sticker, when set, resends an existing sticker (referenced by message)
// instead of sending text/attachments. Mutually exclusive with both.
// Sticker / Gif, when set, send that media instead of text/attachments.
// Mutually exclusive with text, --file, and each other.
Sticker *StickerSource
Gif *StickerSource
ReplyTo int
Silent bool
Schedule time.Time
Expand Down Expand Up @@ -112,9 +114,15 @@ func Send(ctx context.Context, req SendRequest, do SendFunc) ([]output.SendResul

// NormalizeSend resolves stdin-backed text and parses the peer ref.
func NormalizeSend(req SendRequest) (SendQuery, error) {
if req.Sticker != "" && req.Gif != "" {
return SendQuery{}, fmt.Errorf("%w: --sticker and --gif are mutually exclusive", command.ErrUsage)
}
if req.Sticker != "" {
return normalizeStickerSend(req)
}
if req.Gif != "" {
return normalizeGifSend(req)
}
switch req.Parse {
case "", "html":
case "markdown":
Expand Down
27 changes: 18 additions & 9 deletions internal/action/message/sticker.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,26 +17,35 @@ const stickerTokenPrefix = "stk_"
// EncodeStickerToken packs a sticker's input-document triple into a
// self-contained, copy-pasteable handle. The file reference is short-lived, so
// a token only stays valid for a while after listing.
func EncodeStickerToken(doc StickerDoc) string {
func EncodeStickerToken(doc StickerDoc) string { return encodeDocToken(stickerTokenPrefix, doc) }

// DecodeStickerToken reverses EncodeStickerToken. ok is false when s is not a
// sticker token (so the caller can fall back to message-ref parsing); err is
// returned only when s looks like a token but is malformed.
func DecodeStickerToken(s string) (*StickerDoc, bool, error) {
return decodeDocToken(stickerTokenPrefix, s)
}

// encodeDocToken packs a document ref (id + access hash + file reference, plus
// an optional owning set) into a prefixed, base64 handle. Shared by sticker and
// gif refs via their distinct prefixes.
func encodeDocToken(prefix string, doc StickerDoc) string {
buf := make([]byte, 32+len(doc.FileReference))
binary.BigEndian.PutUint64(buf[0:8], uint64(doc.ID))
binary.BigEndian.PutUint64(buf[8:16], uint64(doc.AccessHash))
binary.BigEndian.PutUint64(buf[16:24], uint64(doc.SetID))
binary.BigEndian.PutUint64(buf[24:32], uint64(doc.SetAccessHash))
copy(buf[32:], doc.FileReference)
return stickerTokenPrefix + base64.RawURLEncoding.EncodeToString(buf)
return prefix + base64.RawURLEncoding.EncodeToString(buf)
}

// DecodeStickerToken reverses EncodeStickerToken. ok is false when s is not a
// sticker token (so the caller can fall back to message-ref parsing); err is
// returned only when s looks like a token but is malformed.
func DecodeStickerToken(s string) (*StickerDoc, bool, error) {
if len(s) <= len(stickerTokenPrefix) || s[:len(stickerTokenPrefix)] != stickerTokenPrefix {
func decodeDocToken(prefix, s string) (*StickerDoc, bool, error) {
if len(s) <= len(prefix) || s[:len(prefix)] != prefix {
return nil, false, nil
}
raw, err := base64.RawURLEncoding.DecodeString(s[len(stickerTokenPrefix):])
raw, err := base64.RawURLEncoding.DecodeString(s[len(prefix):])
if err != nil || len(raw) < 32 {
return nil, false, fmt.Errorf("%w: invalid sticker ref %q", command.ErrUsage, s)
return nil, false, fmt.Errorf("%w: invalid media ref %q", command.ErrUsage, s)
}
return &StickerDoc{
ID: int64(binary.BigEndian.Uint64(raw[0:8])),
Expand Down
83 changes: 83 additions & 0 deletions internal/cli/msg/gif/gif.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Package gif implements "tg msg gif list".
package gif

import (
"context"

"github.com/gotd/td/telegram/peers"
"github.com/gotd/td/tg"
"github.com/spf13/cobra"

actionmessage "github.com/vika2603/telegram-cli/internal/action/message"
"github.com/vika2603/telegram-cli/internal/command"
"github.com/vika2603/telegram-cli/internal/output"
"github.com/vika2603/telegram-cli/internal/runtime"
"github.com/vika2603/telegram-cli/internal/telegram"
"github.com/vika2603/telegram-cli/internal/telegram/peer"
"github.com/vika2603/telegram-cli/internal/ui"
)

// New builds the "tg msg gif" command group.
func New(f *runtime.Invocation) *cobra.Command {
cmd := &cobra.Command{
Use: "gif",
Short: "Your saved GIFs (for sending with `msg send --gif`)",
}
cmd.AddCommand(newList(f, nil))
return cmd
}

// Options holds flags/deps for `gif list`.
type Options struct {
Exporter output.Exporter
IOStreams *ui.IOStreams
Do actionmessage.GifListFunc
}

func newList(f *runtime.Invocation, runF func(*Options) error) *cobra.Command {
opts := &Options{}
cmd := &cobra.Command{
Use: "list",
Short: "List your saved GIFs",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
opts.IOStreams = f.IOStreams
if runF != nil {
return runF(opts)
}
opts.Do = newDo(f)
return Run(cmd.Context(), opts)
},
}
command.SetMeta(cmd, command.Meta{NeedsAccount: true, NeedsClient: true})
output.AddJSONFlags(cmd, &opts.Exporter, []string{"kind", "ref", "id", "type"})
return cmd
}

// Run dispatches and renders.
func Run(ctx context.Context, opts *Options) error {
rows, err := actionmessage.ListGifs(ctx, opts.Do)
if err != nil {
return err
}
if opts.Exporter != nil {
return opts.Exporter.Write(opts.IOStreams, rows)
}
return output.RenderStickers(opts.IOStreams, rows)
}

func newDo(f *runtime.Invocation) actionmessage.GifListFunc {
return func(ctx context.Context) ([]output.StickerRow, error) {
acct, err := f.Account("")
if err != nil {
return nil, err
}
var rows []output.StickerRow
err = f.WithPeers(ctx, acct, runtime.ClientOptsFrom(f, acct),
func(ctx context.Context, api *tg.Client, _ *peers.Manager, _ *peer.Resolver) error {
rows, err = telegram.ListGifs(ctx, api)
return err
})
return rows, err
}
}
2 changes: 2 additions & 0 deletions internal/cli/msg/msg.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/vika2603/telegram-cli/internal/cli/msg/download"
"github.com/vika2603/telegram-cli/internal/cli/msg/edit"
"github.com/vika2603/telegram-cli/internal/cli/msg/forward"
"github.com/vika2603/telegram-cli/internal/cli/msg/gif"
"github.com/vika2603/telegram-cli/internal/cli/msg/info"
"github.com/vika2603/telegram-cli/internal/cli/msg/link"
"github.com/vika2603/telegram-cli/internal/cli/msg/list"
Expand Down Expand Up @@ -36,6 +37,7 @@ func New(f *runtime.Invocation) *cobra.Command {
cmd.AddCommand(download.New(f, nil))
cmd.AddCommand(send.New(f, nil))
cmd.AddCommand(sticker.New(f))
cmd.AddCommand(gif.New(f))
cmd.AddCommand(poll.New(f, nil))
cmd.AddCommand(vote.New(f, nil))
cmd.AddCommand(edit.New(f, nil))
Expand Down
7 changes: 5 additions & 2 deletions internal/cli/msg/send/send.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type Options struct {
Files []string
Names []string
Sticker string
Gif string
ReplyTo int
Silent bool
Schedule time.Time
Expand Down Expand Up @@ -82,7 +83,8 @@ func New(f *runtime.Invocation, runF func(*Options) error) *cobra.Command {

cmd.Flags().StringArrayVar(&opts.Files, "file", nil, `File attachment; repeat for multiple files; "-" reads stdin bytes`)
cmd.Flags().StringArrayVar(&opts.Names, "name", nil, "Upload filename override; repeat to match --file")
cmd.Flags().StringVar(&opts.Sticker, "sticker", "", "Resend an existing sticker by message ref (no text/--file)")
cmd.Flags().StringVar(&opts.Sticker, "sticker", "", "Send a sticker by `msg sticker list` ref or message ref (no text/--file)")
cmd.Flags().StringVar(&opts.Gif, "gif", "", "Send a gif by `msg gif list` ref or message ref (no text/--file)")
cmd.Flags().IntVar(&opts.ReplyTo, "reply-to", 0, "Reply to message ID")
cmd.Flags().BoolVar(&opts.Silent, "silent", false, "Send without notification")
cmd.Flags().StringVar(&scheduleRaw, "schedule", "", "Schedule delivery (RFC3339)")
Expand All @@ -103,6 +105,7 @@ func Run(ctx context.Context, opts *Options) error {
Files: opts.Files,
Names: opts.Names,
Sticker: opts.Sticker,
Gif: opts.Gif,
ReplyTo: opts.ReplyTo,
Silent: opts.Silent,
Schedule: opts.Schedule,
Expand Down Expand Up @@ -179,7 +182,7 @@ func newSend(f *runtime.Invocation) actionmessage.SendFunc {
// 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
return len(q.Attachments) == 0 && q.Sticker == nil && q.Gif == nil
}

func recordSentMessages(store *account.PeerStore, peerRef, text string, rows []output.SendResultRow) {
Expand Down
Loading