Skip to content

Commit d956fb8

Browse files
author
cuiko tang
authored
Merge pull request #30 from cuiko/feat/msg-sticker-set
feat(msg): Add `msg sticker add` / `remove` to install sticker sets
2 parents cc506aa + eeb4e74 commit d956fb8

8 files changed

Lines changed: 252 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ client commands route through when present).
133133
| `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`). |
134134
| `tg msg sticker list` | List your stickers to get a sendable `ref`. `--recent` (default), `--faved`, `--installed` (sets), `--all` (recent+faved+all sets expanded; slow). |
135135
| `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>`). |
136+
| `tg msg sticker add <set>` / `tg msg sticker remove <set>` | Install or uninstall a sticker set. `<set>` is a set short name or an `https://t.me/addstickers/<name>` link. |
136137
| `tg msg gif list` | List your saved GIFs, each with a sendable `ref`. |
137138
| `tg msg poll <ref> <question> <option>...` | Send a poll (≥2 options). `--multiple`, `--public` (default anonymous); `--correct <n>` makes it a quiz with optional `--explanation`. |
138139
| `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. |
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package message
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/vika2603/telegram-cli/internal/command"
9+
"github.com/vika2603/telegram-cli/internal/output"
10+
)
11+
12+
// StickerSetRequest is the raw request for `tg msg sticker add` / `remove`.
13+
type StickerSetRequest struct {
14+
RawSet string
15+
Remove bool
16+
}
17+
18+
// StickerSetQuery is the normalized payload passed to the Telegram layer.
19+
type StickerSetQuery struct {
20+
ShortName string
21+
Remove bool
22+
}
23+
24+
// StickerSetFunc installs or uninstalls a sticker set.
25+
type StickerSetFunc func(context.Context, StickerSetQuery) (output.StickerSetResult, error)
26+
27+
// InstallStickerSet validates the request and dispatches the install/uninstall
28+
// (uninstall when req.Remove). Named for the positive verb, mirroring
29+
// FaveSticker, which likewise covers its inverse via a flag.
30+
func InstallStickerSet(ctx context.Context, req StickerSetRequest, do StickerSetFunc) (output.StickerSetResult, error) {
31+
name, err := parseStickerSetRef(req.RawSet)
32+
if err != nil {
33+
return output.StickerSetResult{}, err
34+
}
35+
if do == nil {
36+
return output.StickerSetResult{}, fmt.Errorf("%w: msg sticker set called without function", command.ErrPrecondition)
37+
}
38+
return do(ctx, StickerSetQuery{ShortName: name, Remove: req.Remove})
39+
}
40+
41+
// stickerSetURLPrefixes are the addstickers link forms a short name may be
42+
// wrapped in; we strip them so the user can paste a share link directly.
43+
var stickerSetURLPrefixes = []string{
44+
"https://t.me/addstickers/",
45+
"http://t.me/addstickers/",
46+
"https://telegram.me/addstickers/",
47+
"t.me/addstickers/",
48+
"telegram.me/addstickers/",
49+
"addstickers/",
50+
}
51+
52+
// parseStickerSetRef accepts a sticker set short name or an addstickers link
53+
// and returns the bare short name. Short-name character validation is left to
54+
// Telegram, which rejects unknown sets with STICKERSET_INVALID.
55+
func parseStickerSetRef(raw string) (string, error) {
56+
s := strings.TrimSpace(raw)
57+
for _, p := range stickerSetURLPrefixes {
58+
if rest, ok := strings.CutPrefix(s, p); ok {
59+
s = rest
60+
break
61+
}
62+
}
63+
// A link may carry a trailing query/fragment (e.g. ?foo); keep only the
64+
// short-name segment.
65+
if i := strings.IndexAny(s, "/?#"); i >= 0 {
66+
s = s[:i]
67+
}
68+
if s == "" {
69+
return "", fmt.Errorf("%w: empty sticker set reference", command.ErrUsage)
70+
}
71+
return s, nil
72+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package message_test
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
9+
actionmessage "github.com/vika2603/telegram-cli/internal/action/message"
10+
"github.com/vika2603/telegram-cli/internal/command"
11+
"github.com/vika2603/telegram-cli/internal/output"
12+
)
13+
14+
func TestStickerSet_ParsesShortNameAndLinks(t *testing.T) {
15+
for _, in := range []string{
16+
"Animals",
17+
"addstickers/Animals",
18+
"t.me/addstickers/Animals",
19+
"https://t.me/addstickers/Animals",
20+
"https://telegram.me/addstickers/Animals",
21+
"https://t.me/addstickers/Animals?utm=x",
22+
} {
23+
_, err := actionmessage.InstallStickerSet(context.Background(), actionmessage.StickerSetRequest{RawSet: in},
24+
func(_ context.Context, q actionmessage.StickerSetQuery) (output.StickerSetResult, error) {
25+
require.Equal(t, "Animals", q.ShortName, in)
26+
require.False(t, q.Remove)
27+
return output.StickerSetResult{Action: "add", Set: q.ShortName}, nil
28+
})
29+
require.NoError(t, err, in)
30+
}
31+
}
32+
33+
func TestStickerSet_RemovePassesThrough(t *testing.T) {
34+
_, err := actionmessage.InstallStickerSet(context.Background(), actionmessage.StickerSetRequest{RawSet: "Animals", Remove: true},
35+
func(_ context.Context, q actionmessage.StickerSetQuery) (output.StickerSetResult, error) {
36+
require.Equal(t, "Animals", q.ShortName)
37+
require.True(t, q.Remove)
38+
return output.StickerSetResult{Action: "remove", Set: q.ShortName}, nil
39+
})
40+
require.NoError(t, err)
41+
}
42+
43+
func TestStickerSet_RejectsEmptyRef(t *testing.T) {
44+
_, err := actionmessage.InstallStickerSet(context.Background(), actionmessage.StickerSetRequest{RawSet: " "},
45+
func(context.Context, actionmessage.StickerSetQuery) (output.StickerSetResult, error) {
46+
t.Fatal("must not dispatch")
47+
return output.StickerSetResult{}, nil
48+
})
49+
require.ErrorIs(t, err, command.ErrUsage)
50+
}

internal/cli/msg/sticker/sticker.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,70 @@ func New(f *runtime.Invocation) *cobra.Command {
2727
cmd.AddCommand(newList(f, nil))
2828
cmd.AddCommand(newFave(f, nil, false, "fave", "Add a sticker to favorites"))
2929
cmd.AddCommand(newFave(f, nil, true, "unfave", "Remove a sticker from favorites"))
30+
cmd.AddCommand(newSet(f, nil, false, "add", "Install a sticker set"))
31+
cmd.AddCommand(newSet(f, nil, true, "remove", "Uninstall a sticker set"))
3032
return cmd
3133
}
3234

35+
// SetOptions holds flags/deps for `sticker add` / `remove`.
36+
type SetOptions struct {
37+
RawSet string
38+
Remove bool
39+
Exporter output.Exporter
40+
IOStreams *ui.IOStreams
41+
Do actionmessage.StickerSetFunc
42+
}
43+
44+
func newSet(f *runtime.Invocation, runF func(*SetOptions) error, remove bool, use, short string) *cobra.Command {
45+
opts := &SetOptions{Remove: remove}
46+
cmd := &cobra.Command{
47+
Use: use + " <set>",
48+
Short: short,
49+
Long: short + ". <set> is a sticker set short name or an https://t.me/addstickers/<name> link.",
50+
Args: cobra.ExactArgs(1),
51+
RunE: func(cmd *cobra.Command, args []string) error {
52+
opts.RawSet = args[0]
53+
opts.IOStreams = f.IOStreams
54+
if runF != nil {
55+
return runF(opts)
56+
}
57+
opts.Do = newSetDo(f)
58+
return RunSet(cmd.Context(), opts)
59+
},
60+
}
61+
command.SetMeta(cmd, command.Meta{NeedsAccount: true, NeedsClient: true})
62+
output.AddJSONFlags(cmd, &opts.Exporter, []string{"action", "set", "title", "count", "archived"})
63+
return cmd
64+
}
65+
66+
// RunSet dispatches install/uninstall.
67+
func RunSet(ctx context.Context, opts *SetOptions) error {
68+
res, err := actionmessage.InstallStickerSet(ctx, actionmessage.StickerSetRequest{RawSet: opts.RawSet, Remove: opts.Remove}, opts.Do)
69+
if err != nil {
70+
return err
71+
}
72+
if opts.Exporter != nil {
73+
return opts.Exporter.Write(opts.IOStreams, res)
74+
}
75+
return output.RenderStickerSet(opts.IOStreams, res)
76+
}
77+
78+
func newSetDo(f *runtime.Invocation) actionmessage.StickerSetFunc {
79+
return func(ctx context.Context, q actionmessage.StickerSetQuery) (output.StickerSetResult, error) {
80+
acct, err := f.Account("")
81+
if err != nil {
82+
return output.StickerSetResult{}, err
83+
}
84+
var res output.StickerSetResult
85+
err = f.WithPeers(ctx, acct, runtime.ClientOptsFrom(f, acct),
86+
func(ctx context.Context, api *tg.Client, _ *peers.Manager, _ *peer.Resolver) error {
87+
res, err = telegram.InstallStickerSet(ctx, api, q)
88+
return err
89+
})
90+
return res, err
91+
}
92+
}
93+
3394
// FaveOptions holds flags/deps for `sticker fave` / `unfave`.
3495
type FaveOptions struct {
3596
RawRef string

internal/output/sticker.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,32 @@ func RenderFave(io *ui.IOStreams, r FaveResult) error {
3434
return tp.Render()
3535
}
3636

37+
// StickerSetResult is emitted by `tg msg sticker add` / `remove`.
38+
type StickerSetResult struct {
39+
Action string `json:"action"` // "add" | "remove"
40+
Set string `json:"set"` // set short name
41+
Title string `json:"title,omitempty"` // set title
42+
Count int `json:"count,omitempty"` // stickers in set
43+
Archived bool `json:"archived,omitempty"` // add archived the set instead of installing
44+
}
45+
46+
// RenderStickerSet prints an install/uninstall confirmation.
47+
func RenderStickerSet(io *ui.IOStreams, r StickerSetResult) error {
48+
tp := NewTablePrinter(io)
49+
tp.AddRow("ACTION", r.Action)
50+
tp.AddRow("SET", r.Set)
51+
if r.Title != "" {
52+
tp.AddRow("TITLE", r.Title)
53+
}
54+
if r.Count > 0 {
55+
tp.AddRow("COUNT", strconv.Itoa(r.Count))
56+
}
57+
if r.Archived {
58+
tp.AddRow("ARCHIVED", "true")
59+
}
60+
return tp.Render()
61+
}
62+
3763
// RenderStickers prints sticker rows (or set rows) as a table.
3864
func RenderStickers(io *ui.IOStreams, rows []StickerRow) error {
3965
tp := NewTablePrinter(io)

internal/program/status/rpc.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ var rpcErrors = map[string]rpcClass{
4646
tg.ErrUserCreator: {CodeUsage, 2, "this action isn't allowed on the group/channel creator"},
4747
tg.ErrFileReferenceExpired: {CodeUsage, 2, "sticker reference expired; run `tg msg sticker list` again for a fresh ref"},
4848
tg.ErrFileReferenceInvalid: {CodeUsage, 2, "sticker reference is no longer valid; run `tg msg sticker list` again for a fresh ref"},
49+
tg.ErrStickersetInvalid: {CodeUsage, 2, "no such sticker set (check the short name or addstickers link)"},
4950
}
5051

5152
// matchRPC returns the classification for a known raw Telegram RPC error

internal/program/status/rpc_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ func TestRPC_ClassifiesForbiddenFamily(t *testing.T) {
3232
}
3333

3434
func TestRPC_ClassifiesUsageFamily(t *testing.T) {
35-
for _, typ := range []string{"HIDE_REQUESTER_MISSING", "PARTICIPANT_ID_INVALID", "USER_CREATOR"} {
35+
for _, typ := range []string{"HIDE_REQUESTER_MISSING", "PARTICIPANT_ID_INVALID", "USER_CREATOR", "STICKERSET_INVALID"} {
3636
err := rpcErr(typ, 400)
3737
require.Equal(t, CodeUsage, Code(err), typ)
3838
require.Equal(t, 2, MapExitCode(err), typ)

internal/telegram/sticker.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,46 @@ func faveStickerCall(ctx context.Context, api *tg.Client, doc *tg.InputDocument,
201201
return err
202202
}
203203

204+
// InstallStickerSet installs or (when q.Remove) uninstalls a sticker set by
205+
// short name. The set is fetched first to validate it exists and to surface
206+
// its title/count in the result.
207+
func InstallStickerSet(ctx context.Context, api *tg.Client, q actionmessage.StickerSetQuery) (output.StickerSetResult, error) {
208+
input := &tg.InputStickerSetShortName{ShortName: q.ShortName}
209+
got, err := api.MessagesGetStickerSet(ctx, &tg.MessagesGetStickerSetRequest{Stickerset: input})
210+
if err != nil {
211+
return output.StickerSetResult{}, err
212+
}
213+
full, ok := got.(*tg.MessagesStickerSet)
214+
if !ok {
215+
return output.StickerSetResult{}, fmt.Errorf("%w: sticker set %q is unavailable", command.ErrUnsupported, q.ShortName)
216+
}
217+
res := output.StickerSetResult{
218+
Set: full.Set.ShortName,
219+
Title: full.Set.Title,
220+
Count: full.Set.Count,
221+
}
222+
223+
if q.Remove {
224+
if _, err := api.MessagesUninstallStickerSet(ctx, input); err != nil {
225+
return output.StickerSetResult{}, err
226+
}
227+
res.Action = "remove"
228+
return res, nil
229+
}
230+
231+
installed, err := api.MessagesInstallStickerSet(ctx, &tg.MessagesInstallStickerSetRequest{Stickerset: input})
232+
if err != nil {
233+
return output.StickerSetResult{}, err
234+
}
235+
res.Action = "add"
236+
// Telegram may archive the set instead of installing it (e.g. when the
237+
// installed-set limit is hit); surface that rather than implying success.
238+
if _, ok := installed.(*tg.MessagesStickerSetInstallResultArchive); ok {
239+
res.Archived = true
240+
}
241+
return res, nil
242+
}
243+
204244
// refreshStickerRef re-fetches the sticker's owning set and returns the doc
205245
// with a fresh file reference. Used to recover from FILE_REFERENCE_EXPIRED.
206246
func refreshStickerRef(ctx context.Context, api *tg.Client, doc *actionmessage.StickerDoc) (*actionmessage.StickerDoc, error) {

0 commit comments

Comments
 (0)