From 6945ba743661b9495022b7676e7841a9a029675d Mon Sep 17 00:00:00 2001 From: Logan Pratt Date: Sun, 18 Oct 2026 00:53:08 -0400 Subject: [PATCH 01/19] fix(storage): replace session, peer cache and config files atomically Several tg processes routinely share one session directory (parallel agent commands, or two agents driving the same account). gotd's FileStorage and the peer cache truncated files in place, so a concurrent reader could load an empty or torn session or cache and fail. - internal/atomicfile: write to a temp file in the same directory, then rename over the target (fsync for sessions/config; no fsync for the cache). - File session store keeps gotd's on-disk format and path. - Peer cache merges entries other processes saved since it was loaded, so concurrent commands no longer drop each other's access hashes, and treats a corrupt cache as empty instead of failing every command. - writeConfig refuses to overwrite with O_EXCL instead of stat-then-write. Co-Authored-By: Claude Opus 5.5 --- cmd/tg/config.go | 43 ++++++++---- cmd/tg/session_store.go | 45 +++++++++++-- internal/atomicfile/atomicfile.go | 70 ++++++++++++++++++++ internal/atomicfile/atomicfile_test.go | 92 ++++++++++++++++++++++++++ internal/peercache/peercache.go | 72 ++++++++++++++------ internal/peercache/peercache_test.go | 58 ++++++++++++++++ 6 files changed, 341 insertions(+), 39 deletions(-) create mode 100644 internal/atomicfile/atomicfile.go create mode 100644 internal/atomicfile/atomicfile_test.go diff --git a/cmd/tg/config.go b/cmd/tg/config.go index 1e5f87c..2c388f2 100644 --- a/cmd/tg/config.go +++ b/cmd/tg/config.go @@ -10,6 +10,8 @@ import ( "github.com/go-faster/errors" "gopkg.in/yaml.v3" + + "github.com/gotd/cli/internal/atomicfile" ) // defaultAccount is the label of the top-level (legacy) account. @@ -108,39 +110,54 @@ func loadConfig(path string) (Config, error) { return cfg, nil } -// writeConfig writes cfg to path, refusing to overwrite an existing file. -func writeConfig(path string, cfg Config) error { +// encodeConfig renders cfg as YAML. +func encodeConfig(cfg Config) ([]byte, error) { buf := new(bytes.Buffer) e := yaml.NewEncoder(buf) e.SetIndent(2) if err := e.Encode(cfg); err != nil { - return errors.Wrap(err, "encode") + return nil, errors.Wrap(err, "encode") } + return buf.Bytes(), nil +} - if _, err := os.Stat(path); err == nil { - return errors.Errorf("file %s exists", path) +// writeConfig writes cfg to path, refusing to overwrite an existing file. +func writeConfig(path string, cfg Config) error { + data, err := encodeConfig(cfg) + if err != nil { + return err } if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } - if err := os.WriteFile(path, buf.Bytes(), 0o600); err != nil { + // O_EXCL makes "refuse to overwrite" race-free. + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) // #nosec G304 // path provided via flag + if errors.Is(err, os.ErrExist) { + return errors.Errorf("file %s exists", path) + } + if err != nil { + return errors.Wrap(err, "create") + } + if _, err := f.Write(data); err != nil { + _ = f.Close() + return errors.Wrap(err, "write") + } + if err := f.Close(); err != nil { return errors.Wrap(err, "write") } return nil } -// saveConfig writes cfg to path, overwriting any existing file. +// saveConfig atomically writes cfg to path, overwriting any existing file. func saveConfig(path string, cfg Config) error { - buf := new(bytes.Buffer) - e := yaml.NewEncoder(buf) - e.SetIndent(2) - if err := e.Encode(cfg); err != nil { - return errors.Wrap(err, "encode") + data, err := encodeConfig(cfg) + if err != nil { + return err } if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } - if err := os.WriteFile(path, buf.Bytes(), 0o600); err != nil { + if err := atomicfile.WriteFile(path, data, 0o600); err != nil { return errors.Wrap(err, "write") } return nil diff --git a/cmd/tg/session_store.go b/cmd/tg/session_store.go index 283a922..dc4af3e 100644 --- a/cmd/tg/session_store.go +++ b/cmd/tg/session_store.go @@ -4,8 +4,13 @@ import ( "context" "os" "path/filepath" + "sync" + + "github.com/go-faster/errors" "github.com/gotd/td/session" + + "github.com/gotd/cli/internal/atomicfile" ) // sessionService is the Keychain service name under which sessions are stored. @@ -22,14 +27,42 @@ type sessionStore interface { Delete(ctx context.Context) error } -// fileSessionStore is the cross-platform backend: gotd's JSON FileStorage plus -// existence/deletion over the same path. +// fileSessionStore is the cross-platform backend: the same JSON file format as +// gotd's session.FileStorage, but replaced atomically on write. Several tg +// processes often share one session (parallel agent commands); gotd's +// FileStorage truncates the file in place, so a concurrent reader could load an +// empty or torn session and fail. type fileSessionStore struct { - *session.FileStorage + path string + mu sync.Mutex +} + +func (s *fileSessionStore) LoadSession(context.Context) ([]byte, error) { + s.mu.Lock() + defer s.mu.Unlock() + + data, err := os.ReadFile(s.path) + if os.IsNotExist(err) { + return nil, session.ErrNotFound + } + if err != nil { + return nil, errors.Wrap(err, "read session") + } + return data, nil +} + +func (s *fileSessionStore) StoreSession(_ context.Context, data []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + + if err := atomicfile.WriteFile(s.path, data, 0o600); err != nil { + return errors.Wrap(err, "store session") + } + return nil } func (s *fileSessionStore) Exists(context.Context) (bool, error) { - switch _, err := os.Stat(s.Path); { + switch _, err := os.Stat(s.path); { case err == nil: return true, nil case os.IsNotExist(err): @@ -40,7 +73,7 @@ func (s *fileSessionStore) Exists(context.Context) (bool, error) { } func (s *fileSessionStore) Delete(context.Context) error { - if err := os.Remove(s.Path); err != nil && !os.IsNotExist(err) { + if err := os.Remove(s.path); err != nil && !os.IsNotExist(err) { return err } return nil @@ -65,7 +98,7 @@ func newSessionStore(dir, label string, acc Account, kind string, useKeychain bo return ks } } - return &fileSessionStore{FileStorage: &session.FileStorage{Path: path}} + return &fileSessionStore{path: path} } // useKeychain reports whether the Keychain backend is enabled. It defaults to diff --git a/internal/atomicfile/atomicfile.go b/internal/atomicfile/atomicfile.go new file mode 100644 index 0000000..87bbd91 --- /dev/null +++ b/internal/atomicfile/atomicfile.go @@ -0,0 +1,70 @@ +// Package atomicfile replaces files so that concurrent readers never observe a +// partially written file. +// +// Several tg processes commonly share one session directory (an agent running +// commands in parallel, or two agents driving the same account). A plain +// os.WriteFile truncates the file before writing it, so a reader that opens it +// mid-write sees an empty or torn file and fails to parse it. Writing to a +// temporary file in the same directory and renaming it over the target makes +// the replacement atomic: readers see either the old or the new contents. +package atomicfile + +import ( + "os" + "path/filepath" + + "github.com/go-faster/errors" +) + +// WriteFile atomically replaces path with data and syncs it to disk before the +// rename, so a crash leaves either the old or the new file, never a truncated +// one. Use it for state that must survive a crash, such as sessions and config. +func WriteFile(path string, data []byte, perm os.FileMode) error { + return write(path, data, perm, true) +} + +// Replace is WriteFile without the fsync: readers still never observe a +// partial file, but a crash may lose the most recent write. Use it for caches +// that are rewritten often and can be rebuilt. +func Replace(path string, data []byte, perm os.FileMode) error { + return write(path, data, perm, false) +} + +func write(path string, data []byte, perm os.FileMode, durable bool) (rerr error) { + // Replace the file a symlink points at rather than the symlink itself, so + // a config managed as a dotfile symlink stays a symlink. + if resolved, err := filepath.EvalSymlinks(path); err == nil { + path = resolved + } + + f, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-*") + if err != nil { + return errors.Wrap(err, "create temp file") + } + tmp := f.Name() + defer func() { + if rerr != nil { + _ = f.Close() + _ = os.Remove(tmp) + } + }() + + if err := f.Chmod(perm); err != nil { + return errors.Wrap(err, "chmod temp file") + } + if _, err := f.Write(data); err != nil { + return errors.Wrap(err, "write temp file") + } + if durable { + if err := f.Sync(); err != nil { + return errors.Wrap(err, "sync temp file") + } + } + if err := f.Close(); err != nil { + return errors.Wrap(err, "close temp file") + } + if err := os.Rename(tmp, path); err != nil { + return errors.Wrap(err, "rename temp file") + } + return nil +} diff --git a/internal/atomicfile/atomicfile_test.go b/internal/atomicfile/atomicfile_test.go new file mode 100644 index 0000000..06dae26 --- /dev/null +++ b/internal/atomicfile/atomicfile_test.go @@ -0,0 +1,92 @@ +package atomicfile + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestWriteFileReplaces(t *testing.T) { + for name, write := range map[string]func(string, []byte, os.FileMode) error{ + "WriteFile": WriteFile, + "Replace": Replace, + } { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "state.json") + if err := os.WriteFile(path, []byte("old contents"), 0o600); err != nil { + t.Fatal(err) + } + + if err := write(path, []byte("new"), 0o600); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != "new" { + t.Errorf("contents = %q, want %q", got, "new") + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Errorf("leftover files: %v", entries) + } + }) + } +} + +func TestWriteFilePerm(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permissions") + } + path := filepath.Join(t.TempDir(), "session.json") + if err := WriteFile(path, []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("perm = %o, want 600", perm) + } +} + +func TestWriteFileFollowsSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks need privileges on windows") + } + dir := t.TempDir() + target := filepath.Join(dir, "real.yaml") + link := filepath.Join(dir, "link.yaml") + if err := os.WriteFile(target, []byte("a"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + + if err := WriteFile(link, []byte("b"), 0o600); err != nil { + t.Fatal(err) + } + + if fi, err := os.Lstat(link); err != nil || fi.Mode()&os.ModeSymlink == 0 { + t.Fatalf("link is no longer a symlink (err=%v)", err) + } + if got, _ := os.ReadFile(target); string(got) != "b" { + t.Errorf("target = %q, want %q", got, "b") + } +} + +func TestWriteFileMissingDir(t *testing.T) { + path := filepath.Join(t.TempDir(), "missing", "file") + if err := WriteFile(path, []byte("x"), 0o600); err == nil { + t.Fatal("expected an error for a missing directory") + } +} diff --git a/internal/peercache/peercache.go b/internal/peercache/peercache.go index 98ac5f9..62cac9f 100644 --- a/internal/peercache/peercache.go +++ b/internal/peercache/peercache.go @@ -4,6 +4,11 @@ // access-hashes would be lost between CLI invocations. This storage persists // them as JSON in the session directory, keyed per account, so peer resolution // is cheap and survives restarts. +// +// Several tg processes may share one cache file. Writes replace the file +// atomically and merge in entries other processes added since it was loaded, +// so concurrent commands neither corrupt the file nor drop each other's +// access hashes (re-resolving a username costs a rate-limited RPC). package peercache import ( @@ -16,6 +21,8 @@ import ( "github.com/go-faster/errors" "github.com/gotd/td/telegram/peers" + + "github.com/gotd/cli/internal/atomicfile" ) // Storage is a JSON-file-backed peers.Storage. @@ -37,48 +44,73 @@ type data struct { var _ peers.Storage = (*Storage)(nil) -// Open loads the cache at path, creating an empty one if it does not exist. +// Open loads the cache at path, creating an empty one if it does not exist. An +// unreadable (corrupt) cache is treated as empty and rewritten on the next +// save: it only holds data that can be fetched again. func Open(path string) (*Storage, error) { - s := &Storage{ - path: path, - data: data{ - Peers: map[string]int64{}, - Phones: map[string]string{}, - }, + d, err := load(path) + if err != nil { + return nil, err } + return &Storage{path: path, data: d}, nil +} +// load reads the cache file at path. A missing or corrupt file yields an empty +// cache; only I/O errors are returned. +func load(path string) (data, error) { + d := data{ + Peers: map[string]int64{}, + Phones: map[string]string{}, + } raw, err := os.ReadFile(path) // #nosec G304 // path derived from config dir if err != nil { if os.IsNotExist(err) { - return s, nil + return d, nil } - return nil, errors.Wrap(err, "read peer cache") + return d, errors.Wrap(err, "read peer cache") } - if len(raw) > 0 { - if err := json.Unmarshal(raw, &s.data); err != nil { - return nil, errors.Wrap(err, "parse peer cache") - } + var onDisk data + if json.Unmarshal(raw, &onDisk) != nil { + return d, nil } - if s.data.Peers == nil { - s.data.Peers = map[string]int64{} + for k, v := range onDisk.Peers { + d.Peers[k] = v } - if s.data.Phones == nil { - s.data.Phones = map[string]string{} + for k, v := range onDisk.Phones { + d.Phones[k] = v } - return s, nil + d.ContactsHash = onDisk.ContactsHash + return d, nil } func keyString(k peers.Key) string { return k.Prefix + ":" + strconv.FormatInt(k.ID, 10) } -// flush persists the cache; caller must hold mu. +// flush persists the cache; caller must hold mu. Entries another process saved +// since this cache was loaded are merged in first (this process's values win +// for keys both know), so concurrent writers do not drop each other's entries. func (s *Storage) flush() error { + onDisk, err := load(s.path) + if err != nil { + return err + } + for k, v := range onDisk.Peers { + if _, ok := s.data.Peers[k]; !ok { + s.data.Peers[k] = v + } + } + for k, v := range onDisk.Phones { + if _, ok := s.data.Phones[k]; !ok { + s.data.Phones[k] = v + } + } + raw, err := json.Marshal(s.data) if err != nil { return errors.Wrap(err, "marshal peer cache") } - if err := os.WriteFile(s.path, raw, 0o600); err != nil { + if err := atomicfile.Replace(s.path, raw, 0o600); err != nil { return errors.Wrap(err, "write peer cache") } return nil diff --git a/internal/peercache/peercache_test.go b/internal/peercache/peercache_test.go index 01a8a93..c2e2fa2 100644 --- a/internal/peercache/peercache_test.go +++ b/internal/peercache/peercache_test.go @@ -2,6 +2,7 @@ package peercache import ( "context" + "os" "path/filepath" "testing" @@ -98,3 +99,60 @@ func TestFindMissing(t *testing.T) { t.Error("expected not found") } } + +func TestConcurrentWritersMerge(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "peers.json") + + // Two processes open the same (empty) cache, then each saves a different peer. + a, err := Open(path) + if err != nil { + t.Fatal(err) + } + b, err := Open(path) + if err != nil { + t.Fatal(err) + } + if err := a.Save(ctx, peers.Key{Prefix: "users_", ID: 1}, peers.Value{AccessHash: 11}); err != nil { + t.Fatal(err) + } + if err := b.Save(ctx, peers.Key{Prefix: "users_", ID: 2}, peers.Value{AccessHash: 22}); err != nil { + t.Fatal(err) + } + + c, err := Open(path) + if err != nil { + t.Fatal(err) + } + for id, want := range map[int64]int64{1: 11, 2: 22} { + v, found, err := c.Find(ctx, peers.Key{Prefix: "users_", ID: id}) + if err != nil || !found || v.AccessHash != want { + t.Errorf("peer %d: Find = %+v, %v, %v; want hash %d", id, v, found, err, want) + } + } +} + +func TestCorruptCacheIsRebuilt(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "peers.json") + // A torn write from an older version: truncated JSON. + if err := os.WriteFile(path, []byte(`{"peers":{"users_:1":`), 0o600); err != nil { + t.Fatal(err) + } + + s, err := Open(path) + if err != nil { + t.Fatalf("Open should tolerate a corrupt cache: %v", err) + } + key := peers.Key{Prefix: "users_", ID: 5} + if err := s.Save(ctx, key, peers.Value{AccessHash: 55}); err != nil { + t.Fatal(err) + } + s2, err := Open(path) + if err != nil { + t.Fatal(err) + } + if v, found, _ := s2.Find(ctx, key); !found || v.AccessHash != 55 { + t.Errorf("Find after rebuild = %+v, %v", v, found) + } +} From 90f5947580c910e571edc02bce2b67306d5993e5 Mon Sep 17 00:00:00 2001 From: Logan Pratt Date: Sun, 18 Oct 2026 01:12:36 -0400 Subject: [PATCH 02/19] feat: bot-testing primitives, stable exit codes and JSON errors Make tg a dependable harness for agents that test Telegram bots. Bot testing: - Messages now carry reply_markup (inline buttons and reply keyboards, with callback data), entities (with the covered text), file details (kind, name, MIME, size, duration, dimensions), edit_date, via_bot_id and grouped_id. All new fields are omitted when empty, so existing JSON consumers see the same shape. - tg click presses a button like a client: callback queries return the bot's answer (an unanswered callback is not an error), reply-keyboard buttons send their label, URL/copy buttons report their target. Buttons are chosen by label, callback data, index or row/col; errors list the available buttons. - --wait on send, reply, click and start-bot listens before acting and collects the bot's replies and edits until it settles (--settle, extended while the bot is typing), --replies arrive, or --wait elapses. - tg start-bot sends a deep-link /start with a parameter (messages.startBot), also accepting t.me/?start= links. - tg bot-info shows a bot's descriptions, command menu, menu button and inline/group capabilities. - --stdin on send, reply and edit keeps secrets out of the process list. - history --after returns only newer messages. Reliability: - Exit codes: 2 usage, 3 auth, 4 not found, 5 flood wait, 6 timeout, 7 other RPC error, 130 interrupted; 1 stays the generic failure. - With -o json a failure also writes {"schema":1,"error":{...}} to stdout (code, exit, message, retry_after, rpc); stderr is unchanged. - Global --timeout (TG_TIMEOUT) bounds any command; watch ends cleanly at it. - Flood waits are logged instead of looking like a hang. - tg version / --version report the build, commit and whether app credentials are embedded. - watch/wait register update handlers before connecting (the dispatcher map was modified while updates were being dispatched). - getMessage checks the message belongs to the requested chat: messages.getMessages is not scoped to one. Co-Authored-By: Claude Opus 5.5 --- cmd/tg/app.go | 35 +++- cmd/tg/bot.go | 256 +++++++++++++++++++++++++++ cmd/tg/click.go | 262 +++++++++++++++++++++++++++ cmd/tg/click_test.go | 239 +++++++++++++++++++++++++ cmd/tg/common.go | 60 +++++++ cmd/tg/context.go | 5 +- cmd/tg/conversation.go | 313 ++++++++++++++++++++++++++++++++ cmd/tg/conversation_test.go | 176 ++++++++++++++++++ cmd/tg/download.go | 19 +- cmd/tg/edit.go | 23 ++- cmd/tg/exitcode.go | 181 +++++++++++++++++++ cmd/tg/exitcode_test.go | 175 ++++++++++++++++++ cmd/tg/format.go | 132 ++++++++++++++ cmd/tg/history.go | 45 ++++- cmd/tg/history_test.go | 6 +- cmd/tg/init.go | 8 + cmd/tg/link.go | 5 +- cmd/tg/main.go | 58 ++++-- cmd/tg/markup.go | 315 +++++++++++++++++++++++++++++++++ cmd/tg/markup_test.go | 231 ++++++++++++++++++++++++ cmd/tg/pin.go | 5 +- cmd/tg/reactions.go | 8 +- cmd/tg/reply.go | 42 +++-- cmd/tg/root.go | 20 ++- cmd/tg/send.go | 51 ++++-- cmd/tg/version.go | 92 ++++++++++ cmd/tg/wait.go | 28 ++- cmd/tg/watch.go | 64 ++++--- internal/output/output.go | 22 +++ internal/output/output_test.go | 19 ++ 30 files changed, 2778 insertions(+), 117 deletions(-) create mode 100644 cmd/tg/bot.go create mode 100644 cmd/tg/click.go create mode 100644 cmd/tg/click_test.go create mode 100644 cmd/tg/conversation.go create mode 100644 cmd/tg/conversation_test.go create mode 100644 cmd/tg/exitcode.go create mode 100644 cmd/tg/exitcode_test.go create mode 100644 cmd/tg/markup.go create mode 100644 cmd/tg/markup_test.go create mode 100644 cmd/tg/version.go diff --git a/cmd/tg/app.go b/cmd/tg/app.go index 9a8529c..b78d727 100644 --- a/cmd/tg/app.go +++ b/cmd/tg/app.go @@ -2,6 +2,7 @@ package main import ( "context" + "time" "github.com/go-faster/errors" "github.com/spf13/cobra" @@ -58,6 +59,10 @@ type app struct { outputFormat string proxyURL string accountFlag string + timeout time.Duration + + // cancelTimeout releases the --timeout context; see before and close. + cancelTimeout context.CancelFunc cfg Config log *zap.Logger @@ -79,13 +84,26 @@ func newApp() *app { panic(err) } + // Surface rate limiting: without this a flood wait looks like a hang. + waiter := floodwait.NewWaiter().WithCallback(func(_ context.Context, w floodwait.FloodWait) { + defaultLog.Warn("Rate limited by Telegram (FLOOD_WAIT); retrying after the wait", + zap.Duration("wait", w.Duration)) + }) + return &app{ - waiter: floodwait.NewWaiter(), + waiter: waiter, log: defaultLog, printer: output.New(output.Text, nil), } } +// close releases resources held across the command run. +func (a *app) close() { + if a.cancelTimeout != nil { + a.cancelTimeout() + } +} + // before is wired as the root command's PersistentPreRunE; it runs before every // subcommand. It sets up the output printer and (for commands that need it) // loads the config. @@ -96,6 +114,12 @@ func (a *app) before(cmd *cobra.Command) error { } a.printer = output.New(format, cmd.OutOrStdout()) + if a.timeout > 0 { + ctx, cancel := context.WithTimeout(cmd.Context(), a.timeout) + cmd.SetContext(ctx) + a.cancelTimeout = cancel + } + if skipConfig(cmd) { return nil } @@ -161,7 +185,7 @@ func (a *app) ensureActive() error { func skipConfig(cmd *cobra.Command) bool { for c := cmd; c != nil; c = c.Parent() { switch c.Name() { - case "init", "docs", "completion", "help", + case "init", "docs", "completion", "help", "version", cobra.ShellCompRequestCmd, cobra.ShellCompNoDescRequestCmd: return true } @@ -173,6 +197,10 @@ func skipConfig(cmd *cobra.Command) bool { type runParams struct { auth authKind updates bool + // register attaches update handlers to the dispatcher (updates only). It + // runs before the client connects: the dispatcher's handler map is not + // safe to modify while updates are being dispatched. + register func(d tg.UpdateDispatcher) // authorize performs interactive authentication when the session is not yet // authorized. If nil, user sessions error with errNotAuthorized and bot // sessions authenticate with the configured token. @@ -217,6 +245,9 @@ func (a *app) connectWith( var d tg.UpdateDispatcher if rp.updates { d = tg.NewUpdateDispatcher() + if rp.register != nil { + rp.register(d) + } } appID, appHash, err := effectiveCreds(st.acc) diff --git a/cmd/tg/bot.go b/cmd/tg/bot.go new file mode 100644 index 0000000..15ab8b5 --- /dev/null +++ b/cmd/tg/bot.go @@ -0,0 +1,256 @@ +package main + +import ( + "context" + "fmt" + "io" + "net/url" + "strings" + + "github.com/go-faster/errors" + "github.com/spf13/cobra" + + "github.com/gotd/td/telegram/message/unpack" + "github.com/gotd/td/telegram/peers" + "github.com/gotd/td/tg" +) + +// botCommand is one command from a bot's command menu. +type botCommand struct { + Command string `json:"command"` + Description string `json:"description"` +} + +// menuButton is the button next to a bot chat's input field. +type menuButton struct { + // Type is "commands" (opens the command list), "web_app" or "default". + Type string `json:"type"` + Text string `json:"text,omitempty"` + URL string `json:"url,omitempty"` +} + +// botInfoResult is the result of `tg bot-info`. +type botInfoResult struct { + Peer peerRef `json:"peer"` + // About is the short description shown on the bot's profile. + About string `json:"about,omitempty"` + // Description is the "What can this bot do?" text of an empty chat. + Description string `json:"description,omitempty"` + Commands []botCommand `json:"commands"` + MenuButton *menuButton `json:"menu_button,omitempty"` + PrivacyPolicyURL string `json:"privacy_policy_url,omitempty"` + // InlinePlaceholder is set when the bot supports inline mode. + InlinePlaceholder string `json:"inline_placeholder,omitempty"` + CanJoinGroups bool `json:"can_join_groups"` + ReadsAllGroupMessages bool `json:"reads_all_group_messages"` + ActiveUsers int `json:"active_users,omitempty"` +} + +// MarshalText renders the bot's profile and command menu. +func (r botInfoResult) MarshalText(w io.Writer) error { + lines := []string{fmt.Sprintf("%s id=%d", r.Peer.label(), r.Peer.ID)} + if r.About != "" { + lines = append(lines, "about: "+r.About) + } + if r.Description != "" { + lines = append(lines, "description: "+strings.ReplaceAll(r.Description, "\n", " ")) + } + if r.MenuButton != nil { + lines = append(lines, "menu button: "+strings.TrimSpace(r.MenuButton.Type+" "+r.MenuButton.Text+" "+r.MenuButton.URL)) + } + if r.InlinePlaceholder != "" { + lines = append(lines, fmt.Sprintf("inline mode: %q", r.InlinePlaceholder)) + } + lines = append(lines, fmt.Sprintf("commands (%d):", len(r.Commands))) + for _, c := range r.Commands { + lines = append(lines, fmt.Sprintf(" /%s - %s", c.Command, c.Description)) + } + _, err := io.WriteString(w, strings.Join(lines, "\n")+"\n") + return err +} + +// newBotInfoResult builds the result from a bot user and its full profile. +func newBotInfoResult(ref peerRef, u *tg.User, full *tg.UserFull) botInfoResult { + res := botInfoResult{ + Peer: ref, + About: full.About, + Commands: []botCommand{}, + InlinePlaceholder: u.BotInlinePlaceholder, + CanJoinGroups: !u.BotNochats, + ReadsAllGroupMessages: u.BotChatHistory, + ActiveUsers: u.BotActiveUsers, + } + info, ok := full.GetBotInfo() + if !ok { + return res + } + res.Description = info.Description + res.PrivacyPolicyURL = info.PrivacyPolicyURL + for _, c := range info.Commands { + res.Commands = append(res.Commands, botCommand{Command: c.Command, Description: c.Description}) + } + switch b := info.MenuButton.(type) { + case *tg.BotMenuButtonCommands: + res.MenuButton = &menuButton{Type: "commands"} + case *tg.BotMenuButton: + res.MenuButton = &menuButton{Type: "web_app", Text: b.Text, URL: b.URL} + case *tg.BotMenuButtonDefault: + res.MenuButton = &menuButton{Type: "default"} + } + return res +} + +// resolveBot resolves arg and checks that it is a bot. +func resolveBot(ctx context.Context, m *peerManager, arg string) (peers.User, error) { + p, err := resolvePeerArg(ctx, m, arg) + if err != nil { + return peers.User{}, err + } + u, ok := p.(peers.User) + if !ok || !u.Raw().Bot { + return peers.User{}, usageError{errors.Errorf("%s is not a bot", arg)} + } + return u, nil +} + +func (a *app) newBotInfoCmd() *cobra.Command { + return &cobra.Command{ + Use: "bot-info ", + Short: "Show a bot's description, commands and capabilities", + GroupID: groupMessaging, + Long: `Show what a bot advertises: its profile and empty-chat descriptions, the +command menu (/commands with descriptions), the menu button, inline mode and +group settings. Handy as the first step of testing a bot: every listed command +can then be exercised with ` + "`tg send --peer / --wait 30s`.", + Example: ` tg bot-info @BotFather + tg bot-info @mybot -o json | jq -r '.data.commands[].command'`, + Args: cobra.ExactArgs(1), + ValidArgsFunction: peerArgCompletion, + RunE: func(cmd *cobra.Command, args []string) error { + return a.run(cmd.Context(), runParams{auth: authUser}, func(ctx context.Context, api *tg.Client) error { + m, err := a.manager(api) + if err != nil { + return err + } + u, err := resolveBot(ctx, m, args[0]) + if err != nil { + return err + } + full, err := u.FullRaw(ctx) + if err != nil { + return errors.Wrap(err, "users.getFullUser") + } + return a.printer.Emit(newBotInfoResult(describeManagedPeer(u), u.Raw(), full)) + }) + }, + } +} + +// parseBotLink parses a bot deep link such as https://t.me/mybot?start=abc or +// tg://resolve?domain=mybot&start=abc into "@mybot" and the start parameter. +func parseBotLink(s string) (bot, param string, ok bool) { + raw := s + if !strings.Contains(raw, "://") { + if !strings.HasPrefix(raw, "t.me/") && !strings.HasPrefix(raw, "telegram.me/") { + return "", "", false + } + raw = "https://" + raw + } + u, err := url.Parse(raw) + if err != nil { + return "", "", false + } + switch { + case u.Scheme == "tg" && u.Host == "resolve": + bot = u.Query().Get("domain") + case (u.Scheme == "https" || u.Scheme == "http") && (u.Host == "t.me" || u.Host == "telegram.me"): + bot = strings.Trim(u.Path, "/") + default: + return "", "", false + } + if bot == "" || strings.Contains(bot, "/") { + return "", "", false + } + return "@" + bot, u.Query().Get("start"), true +} + +// startArgs splits `start-bot` arguments into the bot and start parameter. +func startArgs(args []string) (bot, param string, err error) { + bot = args[0] + if b, p, ok := parseBotLink(bot); ok { + bot, param = b, p + } + if len(args) == 2 { + if param != "" { + return "", "", usageError{errors.New("start parameter given both in the link and as an argument")} + } + param = args[1] + } + return bot, param, nil +} + +func (a *app) newStartBotCmd() *cobra.Command { + var wait waitOptions + + cmd := &cobra.Command{ + Use: "start-bot [param]", + Short: "Start a bot, optionally with a deep-link parameter", + GroupID: groupMessaging, + Long: `Start a bot the way a t.me/?start= deep link does: the bot +receives "/start " as a deep-link start, so referral, login and linking +flows can be tested. Without a parameter a plain "/start" is sent. The bot may +be given as the deep link itself. + +With --wait the bot's replies (and message edits) are collected and printed.`, + Example: ` tg start-bot @mybot --wait 20s + tg start-bot @mybot ref_42 --wait 20s -o json + tg start-bot "https://t.me/mybot?start=ref_42" --wait 20s`, + Args: cobra.RangeArgs(1, 2), + ValidArgsFunction: peerArgCompletion, + RunE: func(cmd *cobra.Command, args []string) error { + botArg, param, err := startArgs(args) + if err != nil { + return err + } + collector := newReplyCollector() + return a.run(cmd.Context(), wait.runParams(collector), func(ctx context.Context, api *tg.Client) error { + sender, m, err := a.sender(api) + if err != nil { + return err + } + bot, err := resolveBot(ctx, m, botArg) + if err != nil { + return err + } + collector.arm(bot.ID()) + + var id int + if param == "" { + id, err = unpack.MessageID(sender.To(bot.InputPeer()).Text(ctx, "/start")) + } else { + var rid int64 + if rid, err = randomID(); err != nil { + return err + } + id, err = unpack.MessageID(api.MessagesStartBot(ctx, &tg.MessagesStartBotRequest{ + Bot: bot.InputUser(), + Peer: bot.InputPeer(), + RandomID: rid, + StartParam: param, + })) + } + if err != nil { + return errors.Wrap(err, "start bot") + } + + res := sentResult{Peer: botArg, MessageID: id} + if res.conversation, err = awaitResponse(ctx, collector, wait); err != nil { + return err + } + return a.printer.Emit(res) + }) + }, + } + wait.register(cmd.Flags()) + return cmd +} diff --git a/cmd/tg/click.go b/cmd/tg/click.go new file mode 100644 index 0000000..f96b359 --- /dev/null +++ b/cmd/tg/click.go @@ -0,0 +1,262 @@ +package main + +import ( + "context" + "fmt" + "io" + + "github.com/go-faster/errors" + "github.com/spf13/cobra" + + "github.com/gotd/td/telegram/message" + "github.com/gotd/td/telegram/message/unpack" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" +) + +// keyboardSearchDepth is how many recent messages `tg click` scans for a +// keyboard when no message id is given. +const keyboardSearchDepth = 20 + +// callbackAnswer is a bot's answer to a pressed callback button. +type callbackAnswer struct { + // Answered is false when the bot did not answer the callback query in + // time (BOT_RESPONSE_TIMEOUT); it may still have acted on the press. + Answered bool `json:"answered"` + // Message is the notification text; Alert means it is shown as a modal + // alert instead of a toast. + Message string `json:"message,omitempty"` + Alert bool `json:"alert,omitempty"` + URL string `json:"url,omitempty"` +} + +// clickResult is the result of `tg click`. +type clickResult struct { + Peer string `json:"peer,omitempty"` + MessageID int `json:"message_id"` // the message carrying the keyboard + Row int `json:"row"` + Col int `json:"col"` + Button buttonItem `json:"button"` + // Answer is the callback answer (callback and game buttons). + Answer *callbackAnswer `json:"answer,omitempty"` + // SentMessageID is the message sent by a reply-keyboard button. + SentMessageID int `json:"sent_message_id,omitempty"` + *conversation +} + +// MarshalText renders what was pressed and what came back. +func (r clickResult) MarshalText(w io.Writer) error { + line := fmt.Sprintf("pressed %q (%s, row %d, col %d) on message #%d", + r.Button.Text, r.Button.Type, r.Row, r.Col, r.MessageID) + switch { + case r.SentMessageID != 0: + line += fmt.Sprintf(": sent message #%d", r.SentMessageID) + case r.Button.URL != "": + line += ": " + r.Button.URL + case r.Button.CopyText != "": + line += fmt.Sprintf(": copy %q", r.Button.CopyText) + } + if _, err := fmt.Fprintln(w, line); err != nil { + return err + } + if a := r.Answer; a != nil { + var ans string + switch { + case !a.Answered: + ans = "answer: none (bot did not answer in time)" + case a.Alert: + ans = fmt.Sprintf("answer (alert): %q", a.Message) + case a.Message != "": + ans = fmt.Sprintf("answer: %q", a.Message) + default: + ans = "answer: (empty)" + } + if a.URL != "" { + ans += " url=" + a.URL + } + if _, err := fmt.Fprintln(w, ans); err != nil { + return err + } + } + return r.conversation.marshalText(w) +} + +// keyboardMessage returns the message whose keyboard to press: message id, or +// with id 0 the newest incoming message from peer that has buttons. +func keyboardMessage(ctx context.Context, api *tg.Client, peer tg.InputPeerClass, id int) (*tg.Message, error) { + if id != 0 { + msg, err := getMessage(ctx, api, peer, id) + if err != nil { + return nil, err + } + if len(keyboardButtons(msg.ReplyMarkup)) == 0 { + return nil, notFoundf("message #%d has no keyboard buttons", id) + } + return msg, nil + } + + res, err := api.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{Peer: peer, Limit: keyboardSearchDepth}) + if err != nil { + return nil, errors.Wrap(err, "messages.getHistory") + } + msgs, _, err := messagesFrom(res) + if err != nil { + return nil, err + } + for _, m := range msgs { // newest first + if msg, ok := m.(*tg.Message); ok && !msg.Out && len(keyboardButtons(msg.ReplyMarkup)) > 0 { + return msg, nil + } + } + return nil, notFoundf("none of the last %d messages from this peer has keyboard buttons", keyboardSearchDepth) +} + +// pressButton performs a button's action the way a Telegram client does. +// to sends messages to the chat (for reply-keyboard buttons). +func pressButton( + ctx context.Context, + api *tg.Client, + to *message.RequestBuilder, + peer tg.InputPeerClass, + msgID int, + b keyboardButton, +) (clickResult, error) { + res := clickResult{MessageID: msgID, Row: b.Row, Col: b.Col, Button: b.item} + switch v := b.raw.(type) { + case *tg.KeyboardButtonCallback: + if v.RequiresPassword { + return res, errors.New("this button requires the account's 2FA password; tg does not send it") + } + req := &tg.MessagesGetBotCallbackAnswerRequest{Peer: peer, MsgID: msgID} + req.SetData(v.Data) + ans, err := botCallbackAnswer(ctx, api, req) + res.Answer = ans + return res, err + case *tg.KeyboardButtonGame: + req := &tg.MessagesGetBotCallbackAnswerRequest{Peer: peer, MsgID: msgID} + req.SetGame(true) + ans, err := botCallbackAnswer(ctx, api, req) + res.Answer = ans + return res, err + case *tg.KeyboardButton: + // A reply-keyboard button sends its label. Outside private chats it is + // sent as a reply to the keyboard, so bots in privacy mode receive it. + mb := &to.Builder + if _, private := peer.(*tg.InputPeerUser); !private { + mb = mb.Reply(msgID) + } + id, err := unpack.MessageID(mb.Text(ctx, v.Text)) + if err != nil { + return res, errors.Wrap(err, "send button text") + } + res.SentMessageID = id + return res, nil + case *tg.KeyboardButtonURL, *tg.KeyboardButtonURLAuth, *tg.KeyboardButtonWebView, + *tg.KeyboardButtonSimpleWebView, *tg.KeyboardButtonCopy: + // These act only in the client: nothing reaches the bot. The result + // carries the URL or text the client would open or copy. + return res, nil + default: + return res, errors.Errorf("%s buttons cannot be pressed from tg", b.item.Type) + } +} + +// botCallbackAnswer sends a callback query and returns the bot's answer. A bot +// that does not answer in time is not an error: it may still have acted. +func botCallbackAnswer(ctx context.Context, api *tg.Client, req *tg.MessagesGetBotCallbackAnswerRequest) (*callbackAnswer, error) { + ans, err := api.MessagesGetBotCallbackAnswer(ctx, req) + if tgerr.Is(err, "BOT_RESPONSE_TIMEOUT") { + return &callbackAnswer{Answered: false}, nil + } + if err != nil { + return nil, errors.Wrap(err, "messages.getBotCallbackAnswer") + } + return &callbackAnswer{Answered: true, Message: ans.Message, Alert: ans.Alert, URL: ans.URL}, nil +} + +func (a *app) newClickCmd() *cobra.Command { + var ( + sel buttonSelector + wait waitOptions + ) + + cmd := &cobra.Command{ + Use: "click [message-id]", + Aliases: []string{"press"}, + Short: "Press a bot's inline or reply-keyboard button", + GroupID: groupMessaging, + Long: `Press a button of a bot's keyboard, as a Telegram client would: + + callback sends the callback query and prints the bot's answer (toast/alert) + text (reply keyboard) sends the button's label as a message + url, copy nothing is sent; the URL or text is printed + +Choose the button with --text (exact label, then case-insensitive, then a +unique substring), --data (callback data), --index (1-based, reading order) or +--row/--col. Without a message id, the newest message from the peer that has +buttons is used. ` + "`tg history -o json`" + ` shows each message's +"reply_markup". + +With --wait the bot's response is collected: new messages and edits (bots often +edit the message whose button was pressed).`, + Example: ` tg click @mybot --text "Settings" --wait 15s + tg click @mybot 4242 --data "page:2" --wait 15s -o json + tg click @mybot --row 2 --col 1`, + Args: cobra.RangeArgs(1, 2), + ValidArgsFunction: peerArgCompletion, + RunE: func(cmd *cobra.Command, args []string) error { + if err := sel.validate(); err != nil { + return err + } + var msgID int + if len(args) == 2 { + id, err := parseMessageID(args[1]) + if err != nil { + return err + } + msgID = id + } + + collector := newReplyCollector() + return a.run(cmd.Context(), wait.runParams(collector), func(ctx context.Context, api *tg.Client) error { + sender, m, err := a.sender(api) + if err != nil { + return err + } + peer, err := resolvePeer(ctx, m, args[0]) + if err != nil { + return err + } + msg, err := keyboardMessage(ctx, api, peer, msgID) + if err != nil { + return err + } + btn, err := selectButton(keyboardButtons(msg.ReplyMarkup), sel) + if err != nil { + return err + } + + collector.arm(inputPeerID(peer)) + res, err := pressButton(ctx, api, sender.To(peer), peer, msg.ID, btn) + if err != nil { + return err + } + res.Peer = args[0] + if res.conversation, err = awaitResponse(ctx, collector, wait); err != nil { + return err + } + return a.printer.Emit(res) + }) + }, + } + + fs := cmd.Flags() + fs.StringVar(&sel.text, "text", "", "press the button with this label") + fs.StringVar(&sel.data, "data", "", "press the callback button with this data") + fs.IntVar(&sel.index, "index", 0, "press the n-th button (1-based, reading order)") + fs.IntVar(&sel.row, "row", 0, "press the button in this row (1-based, with --col)") + fs.IntVar(&sel.col, "col", 0, "press the button in this column (1-based, with --row)") + wait.register(fs) + + return cmd +} diff --git a/cmd/tg/click_test.go b/cmd/tg/click_test.go new file mode 100644 index 0000000..6928879 --- /dev/null +++ b/cmd/tg/click_test.go @@ -0,0 +1,239 @@ +package main + +import ( + "context" + "strings" + "testing" + + "github.com/go-faster/errors" + + "github.com/gotd/td/bin" + "github.com/gotd/td/telegram/message" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" +) + +func botPeer() *tg.InputPeerUser { return &tg.InputPeerUser{UserID: testBotID, AccessHash: 1} } + +func TestPressCallbackButton(t *testing.T) { + var got *tg.MessagesGetBotCallbackAnswerRequest + api := newFuncAPI(t, func(req bin.Encoder) (bin.Encoder, error) { + r, ok := req.(*tg.MessagesGetBotCallbackAnswerRequest) + if !ok { + return nil, errors.Errorf("unexpected request %T", req) + } + got = r + return &tg.MessagesBotCallbackAnswer{Message: "Saved", Alert: true}, nil + }) + btn := keyboardButtons(testInlineMarkup())[0] // "📋 Menu", data "menu" + + res, err := pressButton(context.Background(), api, message.NewSender(api).To(botPeer()), botPeer(), 42, btn) + if err != nil { + t.Fatal(err) + } + if got == nil || got.MsgID != 42 || string(got.Data) != "menu" || got.Game { + t.Errorf("request = %+v", got) + } + if res.Answer == nil || !res.Answer.Answered || res.Answer.Message != "Saved" || !res.Answer.Alert { + t.Errorf("answer = %+v", res.Answer) + } + if res.Row != 1 || res.Col != 1 || res.MessageID != 42 || res.Button.Data != "menu" { + t.Errorf("result = %+v", res) + } +} + +func TestPressCallbackButtonUnanswered(t *testing.T) { + api := newFuncAPI(t, func(bin.Encoder) (bin.Encoder, error) { + return nil, tgerr.New(400, "BOT_RESPONSE_TIMEOUT") + }) + btn := keyboardButtons(testInlineMarkup())[0] + res, err := pressButton(context.Background(), api, message.NewSender(api).To(botPeer()), botPeer(), 42, btn) + if err != nil { + t.Fatalf("an unanswered callback is not an error: %v", err) + } + if res.Answer == nil || res.Answer.Answered { + t.Errorf("answer = %+v, want answered=false", res.Answer) + } +} + +func TestPressButtonWithoutRPC(t *testing.T) { + api := newFuncAPI(t, func(req bin.Encoder) (bin.Encoder, error) { + return nil, errors.Errorf("no RPC expected, got %T", req) + }) + docs := keyboardButtons(testInlineMarkup())[2] + res, err := pressButton(context.Background(), api, message.NewSender(api).To(botPeer()), botPeer(), 42, docs) + if err != nil || res.Button.URL != "https://example.com" { + t.Errorf("url button: %+v, %v", res, err) + } + + share := keyboardButtons(testInlineMarkup())[3] + if _, err := pressButton(context.Background(), api, message.NewSender(api).To(botPeer()), botPeer(), 42, share); err == nil { + t.Error("switch_inline buttons should be reported as unsupported") + } + + locked := keyboardButtons(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{ + &tg.KeyboardButtonCallback{Text: "Transfer", Data: []byte("x"), RequiresPassword: true}, + }}}})[0] + if _, err := pressButton(context.Background(), api, message.NewSender(api).To(botPeer()), botPeer(), 42, locked); err == nil { + t.Error("password-protected buttons should be refused") + } +} + +func TestPressReplyKeyboardButtonSendsText(t *testing.T) { + var sent *tg.MessagesSendMessageRequest + api := newFuncAPI(t, func(req bin.Encoder) (bin.Encoder, error) { + r, ok := req.(*tg.MessagesSendMessageRequest) + if !ok { + return nil, errors.Errorf("unexpected request %T", req) + } + sent = r + return &tg.UpdateShortSentMessage{ID: 77}, nil + }) + btn := keyboardButtons(&tg.ReplyKeyboardMarkup{Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{ + &tg.KeyboardButton{Text: "Yes"}, + }}}})[0] + + res, err := pressButton(context.Background(), api, message.NewSender(api).To(botPeer()), botPeer(), 42, btn) + if err != nil { + t.Fatal(err) + } + if sent == nil || sent.Message != "Yes" || sent.ReplyTo != nil { + t.Errorf("sent = %+v (private chats send without reply)", sent) + } + if res.SentMessageID != 77 { + t.Errorf("sent message id = %d", res.SentMessageID) + } +} + +func TestKeyboardMessageLatest(t *testing.T) { + api := newFuncAPI(t, func(req bin.Encoder) (bin.Encoder, error) { + if _, ok := req.(*tg.MessagesGetHistoryRequest); !ok { + return nil, errors.Errorf("unexpected request %T", req) + } + withButtons := botMessage(8, "pick") + withButtons.SetReplyMarkup(testInlineMarkup()) + olderButtons := botMessage(3, "old") + olderButtons.SetReplyMarkup(testInlineMarkup()) + ours := &tg.Message{ID: 9, PeerID: &tg.PeerUser{UserID: testBotID}, Out: true} + return &tg.MessagesMessages{Messages: []tg.MessageClass{ // newest first + ours, botMessage(9, "no buttons"), withButtons, olderButtons, + }}, nil + }) + msg, err := keyboardMessage(context.Background(), api, botPeer(), 0) + if err != nil || msg.ID != 8 { + t.Fatalf("keyboardMessage = %+v, %v; want #8", msg, err) + } +} + +func TestGetMessageScopedToChat(t *testing.T) { + // messages.getMessages is not scoped to a chat; a message from another + // private chat must not be returned. + api := newFuncAPI(t, func(bin.Encoder) (bin.Encoder, error) { + return &tg.MessagesMessages{Messages: []tg.MessageClass{ + &tg.Message{ID: 5, PeerID: &tg.PeerUser{UserID: 999}}, + }}, nil + }) + _, err := getMessage(context.Background(), api, botPeer(), 5) + var nf notFoundError + if !errors.As(err, &nf) { + t.Fatalf("err = %v, want not-found", err) + } + if _, err := getMessage(context.Background(), api, &tg.InputPeerUser{UserID: 999}, 5); err != nil { + t.Errorf("same chat: %v", err) + } +} + +func TestParseBotLink(t *testing.T) { + cases := []struct { + in, bot, param string + ok bool + }{ + {"https://t.me/mybot?start=ref_42", "@mybot", "ref_42", true}, + {"t.me/mybot", "@mybot", "", true}, + {"tg://resolve?domain=mybot&start=abc", "@mybot", "abc", true}, + {"@mybot", "", "", false}, + {"https://example.com/mybot?start=x", "", "", false}, + {"https://t.me/c/123/4", "", "", false}, + } + for _, c := range cases { + bot, param, ok := parseBotLink(c.in) + if bot != c.bot || param != c.param || ok != c.ok { + t.Errorf("parseBotLink(%q) = %q, %q, %v", c.in, bot, param, ok) + } + } + + if _, _, err := startArgs([]string{"https://t.me/mybot?start=a", "b"}); err == nil { + t.Error("expected an error for a parameter given twice") + } + if bot, param, err := startArgs([]string{"@mybot", "p"}); err != nil || bot != "@mybot" || param != "p" { + t.Errorf("startArgs = %q, %q, %v", bot, param, err) + } +} + +func TestNewBotInfoResult(t *testing.T) { + u := &tg.User{ID: testBotID, Bot: true, BotNochats: true, BotInlinePlaceholder: "Search…"} + full := &tg.UserFull{About: "Bot about"} + full.SetBotInfo(tg.BotInfo{ + Description: "What I do", + Commands: []tg.BotCommand{{Command: "start", Description: "Begin"}, {Command: "help", Description: "Help"}}, + MenuButton: &tg.BotMenuButtonCommands{}, + }) + res := newBotInfoResult(peerRef{ID: testBotID, Type: peerUser, Username: "mybot"}, u, full) + if res.About != "Bot about" || res.Description != "What I do" || len(res.Commands) != 2 || res.Commands[1].Command != "help" { + t.Errorf("result = %+v", res) + } + if res.CanJoinGroups || res.InlinePlaceholder != "Search…" || res.MenuButton == nil || res.MenuButton.Type != "commands" { + t.Errorf("flags = %+v", res) + } + var sb strings.Builder + if err := res.MarshalText(&sb); err != nil || !strings.Contains(sb.String(), "/help - Help") { + t.Errorf("text = %q, %v", sb.String(), err) + } + + // A bot without bot info still reports an empty (non-nil) command list. + if res := newBotInfoResult(peerRef{}, u, &tg.UserFull{}); res.Commands == nil { + t.Error("commands should be an empty list, not null") + } +} + +func TestMessageText(t *testing.T) { + if got, err := messageText([]string{"hi"}, false, nil); err != nil || got != "hi" { + t.Errorf("arg: %q, %v", got, err) + } + if got, err := messageText(nil, true, strings.NewReader("secret-code\n")); err != nil || got != "secret-code" { + t.Errorf("stdin: %q, %v", got, err) + } + if got, err := messageText(nil, true, strings.NewReader("line 1\nline 2\r\n")); err != nil || got != "line 1\nline 2" { + t.Errorf("multi-line stdin: %q, %v", got, err) + } + for name, run := range map[string]func() error{ + "missing": func() error { _, err := messageText(nil, false, nil); return err }, + "both": func() error { _, err := messageText([]string{"x"}, true, strings.NewReader("y")); return err }, + "empty": func() error { _, err := messageText(nil, true, strings.NewReader("\n")); return err }, + "oversized": func() error { _, err := messageText(nil, true, strings.NewReader(strings.Repeat("a", maxStdinText+1))); return err }, + "empty argv": func() error { _, err := messageText([]string{""}, false, nil); return err }, + } { + var u usageError + if err := run(); !errors.As(err, &u) { + t.Errorf("%s: err = %v, want usage error", name, err) + } + } +} + +func TestListHistoryAfter(t *testing.T) { + api := newFuncAPI(t, func(req bin.Encoder) (bin.Encoder, error) { + if _, ok := req.(*tg.MessagesGetHistoryRequest); !ok { + return nil, errors.Errorf("unexpected request %T", req) + } + return &tg.MessagesMessages{Messages: []tg.MessageClass{ + botMessage(12, "c"), botMessage(11, "b"), botMessage(10, "a"), botMessage(9, "old"), + }}, nil + }) + res, err := listHistory(context.Background(), api, botPeer(), 30, 10) + if err != nil { + t.Fatal(err) + } + if len(res.Messages) != 2 || res.Messages[0].ID != 11 || res.Messages[1].ID != 12 { + t.Errorf("messages = %+v, want #11 and #12", res.Messages) + } +} diff --git a/cmd/tg/common.go b/cmd/tg/common.go index 1fb067e..60a144d 100644 --- a/cmd/tg/common.go +++ b/cmd/tg/common.go @@ -1,8 +1,12 @@ package main import ( + "context" + "io" + "strings" "time" + "github.com/go-faster/errors" "github.com/spf13/pflag" "github.com/gotd/td/telegram/message" @@ -29,6 +33,62 @@ func (o *messageOptions) register(fs *pflag.FlagSet) { "schedule the message to be sent after this delay") } +// maxStdinText bounds message text read from stdin (Telegram's own limit is +// 4096 characters). +const maxStdinText = 64 << 10 + +// messageText returns the message text: the positional argument, or with +// --stdin the whole of standard input minus one trailing newline. Reading from +// stdin keeps secrets (login or link codes) out of the process list. +func messageText(args []string, fromStdin bool, stdin io.Reader) (string, error) { + if !fromStdin { + if len(args) == 0 || args[0] == "" { + return "", usageError{errors.New("missing message text: pass it as an argument or use --stdin")} + } + return args[0], nil + } + if len(args) > 0 { + return "", usageError{errors.New("pass the message text as an argument or with --stdin, not both")} + } + data, err := io.ReadAll(io.LimitReader(stdin, maxStdinText+1)) + if err != nil { + return "", errors.Wrap(err, "read stdin") + } + if len(data) > maxStdinText { + return "", usageError{errors.Errorf("message text on stdin exceeds %d bytes", maxStdinText)} + } + text := strings.TrimSuffix(strings.TrimSuffix(string(data), "\n"), "\r") + if text == "" { + return "", usageError{errors.New("empty message text on stdin")} + } + return text, nil +} + +// targetFor returns a request builder for peer, like builderFor. When replies +// will be collected it resolves the peer first and arms c on it, so nothing the +// peer sends after the message goes out is missed. +func targetFor( + ctx context.Context, + m *peerManager, + sender *message.Sender, + peer string, + c *replyCollector, + wait waitOptions, +) (*message.RequestBuilder, error) { + if !wait.enabled() { + return builderFor(ctx, m, sender, peer) + } + if isSelf(peer) { + return nil, usageError{errors.New("--wait needs another peer: Saved Messages never reply")} + } + input, err := resolvePeer(ctx, m, peer) + if err != nil { + return nil, err + } + c.arm(inputPeerID(input)) + return sender.To(input), nil +} + // apply mutates the request builder according to the flags and returns the // builder together with the styled text options for the message body. func (o *messageOptions) apply( diff --git a/cmd/tg/context.go b/cmd/tg/context.go index f045510..5a2f6b0 100644 --- a/cmd/tg/context.go +++ b/cmd/tg/context.go @@ -3,7 +3,6 @@ package main import ( "context" "sort" - "strconv" "github.com/go-faster/errors" "github.com/spf13/cobra" @@ -46,9 +45,9 @@ func (a *app) newContextCmd() *cobra.Command { Args: cobra.ExactArgs(2), ValidArgsFunction: peerArgCompletion, RunE: func(cmd *cobra.Command, args []string) error { - id, err := strconv.Atoi(args[1]) + id, err := parseMessageID(args[1]) if err != nil { - return errors.Wrap(err, "message-id must be an integer") + return err } return a.run(cmd.Context(), runParams{auth: authUser}, func(ctx context.Context, api *tg.Client) error { m, err := a.manager(api) diff --git a/cmd/tg/conversation.go b/cmd/tg/conversation.go new file mode 100644 index 0000000..02fe38b --- /dev/null +++ b/cmd/tg/conversation.go @@ -0,0 +1,313 @@ +package main + +import ( + "context" + "io" + "sort" + "sync" + "time" + + "github.com/go-faster/errors" + "github.com/spf13/pflag" + + "github.com/gotd/td/telegram/message/peer" + "github.com/gotd/td/tg" +) + +// typingGrace is how long a "typing…" action from the peer keeps a +// conversation open: Telegram shows the action for about five seconds and bots +// repeat it while they work. +const typingGrace = 6 * time.Second + +// deadlineMargin is kept free before a --timeout deadline so a waiting command +// can still print what it collected. +const deadlineMargin = time.Second + +// waitOptions configure collecting a peer's responses to an action (`--wait`). +type waitOptions struct { + wait time.Duration + settle time.Duration + replies int +} + +// register binds the --wait/--settle/--replies flags. +func (o *waitOptions) register(fs *pflag.FlagSet) { + fs.DurationVar(&o.wait, "wait", 0, + "then collect the peer's replies and edits for up to this long (e.g. 30s; 0 = don't wait)") + fs.DurationVar(&o.settle, "settle", 2*time.Second, + "with --wait: stop once the peer has been quiet this long after responding") + fs.IntVar(&o.replies, "replies", 0, + "with --wait: stop as soon as this many new messages arrived (ignores --settle)") +} + +func (o waitOptions) enabled() bool { return o.wait > 0 } + +// runParams returns the parameters for a user command that may wait: with +// --wait, updates are enabled and c listens on them. +func (o waitOptions) runParams(c *replyCollector) runParams { + rp := runParams{auth: authUser} + if o.enabled() { + rp.updates = true + rp.register = c.register + } + return rp +} + +// awaitResponse collects the peer's response when --wait is set; it returns +// nil otherwise. +func awaitResponse(ctx context.Context, c *replyCollector, o waitOptions) (*conversation, error) { + if !o.enabled() { + return nil, nil + } + conv, err := c.collect(ctx, o) + if err != nil { + return nil, errors.Wrap(err, "stopped waiting for a response") + } + return conv, nil +} + +// conversation is what a peer did in response to an action: messages it sent +// and messages it edited (in their latest version), oldest first. +type conversation struct { + Replies []messageItem `json:"replies"` + Edits []messageItem `json:"edits,omitempty"` + // TimedOut is set when --wait elapsed before the peer settled (or, with + // --replies, before enough replies arrived). + TimedOut bool `json:"timed_out,omitempty"` +} + +// marshalText renders the collected messages like `tg history`. +func (c *conversation) marshalText(w io.Writer) error { + if c == nil { + return nil + } + if err := (historyResult{Messages: c.Replies}).MarshalText(w); err != nil { + return err + } + if len(c.Edits) > 0 { + if _, err := io.WriteString(w, "edited:\n"); err != nil { + return err + } + if err := (historyResult{Messages: c.Edits}).MarshalText(w); err != nil { + return err + } + } + if c.TimedOut { + _, err := io.WriteString(w, "(timed out waiting for the peer)\n") + return err + } + return nil +} + +// replyCollector gathers a peer's new messages and edits. Its handlers are +// registered on the dispatcher before the client connects (the dispatcher is +// not safe for concurrent registration) and ignore everything until arm names +// the peer to listen to. +type replyCollector struct { + mu sync.Mutex + peerID int64 // 0 until armed + replies []messageItem + edits []messageItem + // activity is signaled (non-blocking) on every relevant event. + activity chan event +} + +// event is a kind of activity seen by a replyCollector. +type event int + +const ( + eventMessage event = iota // a reply or an edit was collected + eventTyping // the peer is typing +) + +func newReplyCollector() *replyCollector { + return &replyCollector{activity: make(chan event, 16)} +} + +// register wires the collector onto d and resets it, so one collector can serve +// sequential connections (e.g. --account all). +func (c *replyCollector) register(d tg.UpdateDispatcher) { + c.mu.Lock() + c.peerID, c.replies, c.edits = 0, nil, nil + c.mu.Unlock() + + d.OnNewMessage(func(_ context.Context, e tg.Entities, u *tg.UpdateNewMessage) error { + c.onMessage(u.Message, e, false) + return nil + }) + d.OnNewChannelMessage(func(_ context.Context, e tg.Entities, u *tg.UpdateNewChannelMessage) error { + c.onMessage(u.Message, e, false) + return nil + }) + d.OnEditMessage(func(_ context.Context, e tg.Entities, u *tg.UpdateEditMessage) error { + c.onMessage(u.Message, e, true) + return nil + }) + d.OnEditChannelMessage(func(_ context.Context, e tg.Entities, u *tg.UpdateEditChannelMessage) error { + c.onMessage(u.Message, e, true) + return nil + }) + d.OnUserTyping(func(_ context.Context, _ tg.Entities, u *tg.UpdateUserTyping) error { + c.onTyping(u.UserID, u.Action) + return nil + }) +} + +// arm starts collecting messages in the chat with peerID. Call it before the +// action, so a response that arrives before the action's RPC returns is kept. +func (c *replyCollector) arm(peerID int64) { + c.mu.Lock() + defer c.mu.Unlock() + c.peerID = peerID +} + +func (c *replyCollector) onMessage(msg tg.MessageClass, e tg.Entities, edited bool) { + m, ok := msg.(*tg.Message) + // Our own messages (and edits of them) are not responses. + if !ok || m.Out { + return + } + c.mu.Lock() + defer c.mu.Unlock() + if c.peerID == 0 || peerIDOf(m.PeerID) != c.peerID { + return + } + item := buildMessageItem(m, peer.EntitiesFromUpdate(e)) + switch { + case upsertMessage(c.replies, item): + // A reply edited again (e.g. a bot streaming its answer): keep the + // latest version in place. + case edited: + if !upsertMessage(c.edits, item) { + c.edits = append(c.edits, item) + } + default: + c.replies = append(c.replies, item) + } + c.signal(eventMessage) +} + +func (c *replyCollector) onTyping(userID int64, action tg.SendMessageActionClass) { + if _, cancel := action.(*tg.SendMessageCancelAction); cancel { + return + } + c.mu.Lock() + armed := c.peerID != 0 && c.peerID == userID + c.mu.Unlock() + if armed { + c.signal(eventTyping) + } +} + +func (c *replyCollector) signal(ev event) { + select { + case c.activity <- ev: + default: // the waiter is already behind; it re-reads counts anyway + } +} + +// upsertMessage replaces the message with item's id in msgs, reporting whether +// one was found. +func upsertMessage(msgs []messageItem, item messageItem) bool { + for i := range msgs { + if msgs[i].ID == item.ID { + msgs[i] = item + return true + } + } + return false +} + +func (c *replyCollector) counts() (replies, edits int) { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.replies), len(c.edits) +} + +func (c *replyCollector) snapshot(timedOut bool) *conversation { + c.mu.Lock() + defer c.mu.Unlock() + conv := &conversation{ + Replies: append([]messageItem{}, c.replies...), + Edits: append([]messageItem(nil), c.edits...), + TimedOut: timedOut, + } + for _, msgs := range [][]messageItem{conv.Replies, conv.Edits} { + sort.Slice(msgs, func(i, j int) bool { return msgs[i].ID < msgs[j].ID }) + } + return conv +} + +// collect blocks until the peer's response is complete and returns it. The +// response is complete when o.replies messages arrived (if set), or else when +// the peer was quiet for o.settle after its first reply or edit; o.wait bounds +// the whole wait. An earlier context deadline (--timeout) shortens the wait so +// the result can still be printed. +func (c *replyCollector) collect(ctx context.Context, o waitOptions) (*conversation, error) { + limit := o.wait + if dl, ok := ctx.Deadline(); ok { + limit = min(limit, max(time.Until(dl)-deadlineMargin, 0)) + } + deadline := time.NewTimer(limit) + defer deadline.Stop() + + // settle runs only once something was collected; nil channel otherwise. + var ( + settle *time.Timer + settleCh <-chan time.Time + ) + defer func() { + if settle != nil { + settle.Stop() + } + }() + resetSettle := func(d time.Duration) { + if settle == nil { + settle = time.NewTimer(d) + settleCh = settle.C + return + } + settle.Reset(d) + } + + for { + select { + case <-ctx.Done(): + return c.snapshot(true), ctx.Err() + case <-deadline.C: + return c.snapshot(true), nil + case <-settleCh: + return c.snapshot(false), nil + case ev := <-c.activity: + replies, edits := c.counts() + if o.replies > 0 { + if replies >= o.replies { + return c.snapshot(false), nil + } + continue + } + if replies+edits == 0 { + continue // typing before any response: keep waiting + } + if ev == eventTyping { + resetSettle(max(o.settle, typingGrace)) + } else { + resetSettle(o.settle) + } + } + } +} + +// peerIDOf returns the bare id of a peer. +func peerIDOf(p tg.PeerClass) int64 { + switch v := p.(type) { + case *tg.PeerUser: + return v.UserID + case *tg.PeerChat: + return v.ChatID + case *tg.PeerChannel: + return v.ChannelID + default: + return 0 + } +} diff --git a/cmd/tg/conversation_test.go b/cmd/tg/conversation_test.go new file mode 100644 index 0000000..5fbd31d --- /dev/null +++ b/cmd/tg/conversation_test.go @@ -0,0 +1,176 @@ +package main + +import ( + "context" + "testing" + "time" + + "github.com/gotd/td/tg" +) + +const testBotID = 7001 + +// collectorHarness feeds updates into a replyCollector through a real +// dispatcher, the way the client delivers them. +type collectorHarness struct { + t *testing.T + d tg.UpdateDispatcher + c *replyCollector +} + +func newCollectorHarness(t *testing.T) *collectorHarness { + d := tg.NewUpdateDispatcher() + c := newReplyCollector() + c.register(d) + return &collectorHarness{t: t, d: d, c: c} +} + +func (h *collectorHarness) push(u tg.UpdateClass) { + h.t.Helper() + if err := h.d.Handle(context.Background(), &tg.Updates{Updates: []tg.UpdateClass{u}}); err != nil { + h.t.Fatal(err) + } +} + +func botMessage(id int, text string) *tg.Message { + return &tg.Message{ID: id, PeerID: &tg.PeerUser{UserID: testBotID}, Message: text, Date: id} +} + +func (h *collectorHarness) newMessage(m *tg.Message) { h.push(&tg.UpdateNewMessage{Message: m}) } +func (h *collectorHarness) editMessage(m *tg.Message) { h.push(&tg.UpdateEditMessage{Message: m}) } + +func TestCollectorFiltersAndUpserts(t *testing.T) { + h := newCollectorHarness(t) + + h.newMessage(botMessage(1, "before arm")) // ignored: not armed yet + h.c.arm(testBotID) + + h.newMessage(botMessage(10, "hello")) + h.newMessage(&tg.Message{ID: 11, PeerID: &tg.PeerUser{UserID: 999}, Message: "other chat"}) + h.newMessage(&tg.Message{ID: 12, PeerID: &tg.PeerUser{UserID: testBotID}, Message: "/start", Out: true}) + + streamed := botMessage(10, "hello, world") // the bot edits its reply + streamed.SetEditDate(50) + h.editMessage(streamed) + + menu := botMessage(5, "menu (page 2)") // an older message edited after a button press + h.editMessage(menu) + + conv := h.c.snapshot(false) + if len(conv.Replies) != 1 || conv.Replies[0].ID != 10 || conv.Replies[0].Text != "hello, world" || conv.Replies[0].EditDate != 50 { + t.Errorf("replies = %+v", conv.Replies) + } + if len(conv.Edits) != 1 || conv.Edits[0].ID != 5 { + t.Errorf("edits = %+v", conv.Edits) + } +} + +func TestCollectorRegisterResets(t *testing.T) { + h := newCollectorHarness(t) + h.c.arm(testBotID) + h.newMessage(botMessage(10, "first account")) + + h.c.register(tg.NewUpdateDispatcher()) // next connection (e.g. --account all) + if conv := h.c.snapshot(false); len(conv.Replies) != 0 || h.c.peerID != 0 { + t.Errorf("collector not reset: %+v", conv) + } +} + +func TestCollectSettles(t *testing.T) { + h := newCollectorHarness(t) + h.c.arm(testBotID) + go func() { + h.newMessage(botMessage(10, "one")) + h.newMessage(botMessage(11, "two")) + }() + + start := time.Now() + conv, err := h.c.collect(context.Background(), waitOptions{wait: 5 * time.Second, settle: 100 * time.Millisecond}) + if err != nil { + t.Fatal(err) + } + if conv.TimedOut || len(conv.Replies) != 2 { + t.Errorf("conv = %+v", conv) + } + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Errorf("did not settle early: %s", elapsed) + } +} + +func TestCollectReplyCount(t *testing.T) { + h := newCollectorHarness(t) + h.c.arm(testBotID) + go func() { + h.newMessage(botMessage(10, "one")) + time.Sleep(50 * time.Millisecond) + h.newMessage(botMessage(11, "two")) + }() + + // A settle shorter than the gap would stop after one reply; --replies + // ignores settle and waits for both. + conv, err := h.c.collect(context.Background(), waitOptions{wait: 5 * time.Second, settle: time.Millisecond, replies: 2}) + if err != nil { + t.Fatal(err) + } + if conv.TimedOut || len(conv.Replies) != 2 { + t.Errorf("conv = %+v", conv) + } +} + +func TestCollectTimesOutWithoutReply(t *testing.T) { + h := newCollectorHarness(t) + h.c.arm(testBotID) + conv, err := h.c.collect(context.Background(), waitOptions{wait: 50 * time.Millisecond, settle: time.Second}) + if err != nil { + t.Fatal(err) + } + if !conv.TimedOut || len(conv.Replies) != 0 || conv.Replies == nil { + t.Errorf("conv = %+v; want timed out with an empty (non-nil) reply list", conv) + } +} + +func TestCollectTypingKeepsWaiting(t *testing.T) { + h := newCollectorHarness(t) + h.c.arm(testBotID) + h.newMessage(botMessage(10, "thinking…")) + h.push(&tg.UpdateUserTyping{UserID: testBotID, Action: &tg.SendMessageTypingAction{}}) + + // Typing extends the settle period to typingGrace, so a short settle does + // not end the wait; the overall --wait does. + start := time.Now() + conv, err := h.c.collect(context.Background(), waitOptions{wait: 300 * time.Millisecond, settle: 10 * time.Millisecond}) + if err != nil { + t.Fatal(err) + } + if !conv.TimedOut { + t.Errorf("typing should have kept the wait open; settled after %s", time.Since(start)) + } +} + +func TestCollectRespectsContextDeadline(t *testing.T) { + h := newCollectorHarness(t) + h.c.arm(testBotID) + ctx, cancel := context.WithTimeout(context.Background(), deadlineMargin+200*time.Millisecond) + defer cancel() + + // --wait is longer than --timeout: collect must return before the deadline. + conv, err := h.c.collect(ctx, waitOptions{wait: time.Minute, settle: time.Second}) + if err != nil { + t.Fatalf("collect should finish before the deadline: %v", err) + } + if !conv.TimedOut || ctx.Err() != nil { + t.Errorf("conv = %+v, ctx err = %v", conv, ctx.Err()) + } +} + +func TestPeerIDOf(t *testing.T) { + for p, want := range map[tg.PeerClass]int64{ + &tg.PeerUser{UserID: 1}: 1, + &tg.PeerChat{ChatID: 2}: 2, + &tg.PeerChannel{ChannelID: 3}: 3, + } { + if got := peerIDOf(p); got != want { + t.Errorf("peerIDOf(%T) = %d, want %d", p, got, want) + } + } +} diff --git a/cmd/tg/download.go b/cmd/tg/download.go index 70154d6..cf01115 100644 --- a/cmd/tg/download.go +++ b/cmd/tg/download.go @@ -49,12 +49,21 @@ func getMessage(ctx context.Context, api *tg.Client, peer tg.InputPeerClass, id if !ok { return nil, errors.Errorf("unexpected messages type %T", res) } + // messages.getMessages looks ids up across all private chats and basic + // groups, so check the message belongs to the requested chat. Channel + // lookups are already scoped; self (-1) is not checked. + want := inputPeerID(peer) for _, m := range msgs.GetMessages() { - if msg, ok := m.(*tg.Message); ok && msg.ID == id { - return msg, nil + msg, ok := m.(*tg.Message) + if !ok || msg.ID != id { + continue } + if _, channel := peer.(*tg.InputPeerChannel); !channel && want > 0 && peerIDOf(msg.PeerID) != want { + break + } + return msg, nil } - return nil, errors.Errorf("message #%d not found", id) + return nil, notFoundf("message #%d not found in this chat", id) } // documentFilename returns the filename attribute of a document, or a fallback. @@ -149,9 +158,9 @@ filename is used in the current directory.`, Args: cobra.ExactArgs(2), ValidArgsFunction: peerArgCompletion, RunE: func(cmd *cobra.Command, args []string) error { - id, err := strconv.Atoi(args[1]) + id, err := parseMessageID(args[1]) if err != nil { - return errors.Wrap(err, "message-id must be an integer") + return err } return a.run(cmd.Context(), runParams{auth: authUser}, func(ctx context.Context, api *tg.Client) error { diff --git a/cmd/tg/edit.go b/cmd/tg/edit.go index e72ab08..70890ab 100644 --- a/cmd/tg/edit.go +++ b/cmd/tg/edit.go @@ -2,7 +2,6 @@ package main import ( "context" - "strconv" "github.com/go-faster/errors" "github.com/spf13/cobra" @@ -14,23 +13,30 @@ import ( ) func (a *app) newEditCmd() *cobra.Command { - var useHTML bool + var ( + useHTML bool + fromStdin bool + ) cmd := &cobra.Command{ - Use: "edit ", + Use: "edit [text]", Short: "Edit a message", GroupID: groupMessaging, - Long: "Edit the text of a message you sent. The peer is me/self, @username, phone, or a t.me link.", + Long: `Edit the text of a message you sent. The peer is me/self, @username, phone, +or a t.me link. The new text may come from --stdin.`, Example: ` tg edit @durov 12345 "updated text" tg edit me 1000 --html "bold"`, - Args: cobra.ExactArgs(3), + Args: cobra.RangeArgs(2, 3), ValidArgsFunction: peerArgCompletion, RunE: func(cmd *cobra.Command, args []string) error { - id, err := strconv.Atoi(args[1]) + id, err := parseMessageID(args[1]) if err != nil { - return errors.Wrap(err, "message-id must be an integer") + return err + } + text, err := messageText(args[2:], fromStdin, cmd.InOrStdin()) + if err != nil { + return err } - text := args[2] return a.run(cmd.Context(), runParams{auth: authUser}, func(ctx context.Context, api *tg.Client) error { sender, m, err := a.sender(api) @@ -56,6 +62,7 @@ func (a *app) newEditCmd() *cobra.Command { } cmd.Flags().BoolVar(&useHTML, "html", false, "use HTML styling") + cmd.Flags().BoolVar(&fromStdin, "stdin", false, "read the new text from standard input") return cmd } diff --git a/cmd/tg/exitcode.go b/cmd/tg/exitcode.go new file mode 100644 index 0000000..92b1fc8 --- /dev/null +++ b/cmd/tg/exitcode.go @@ -0,0 +1,181 @@ +package main + +import ( + "context" + "strconv" + "strings" + + "github.com/go-faster/errors" + "github.com/spf13/cobra" + + "github.com/gotd/td/tgerr" +) + +// Process exit codes. Scripts and agents can branch on these instead of parsing +// error text. Every failure is still non-zero, so callers that only check for +// success are unaffected. +const ( + exitOK = 0 + exitError = 1 // unclassified failure + exitUsage = 2 // invalid command, flags or arguments + exitAuth = 3 // not logged in, or the session was revoked + exitNotFound = 4 // peer, message or button does not exist + exitFloodWait = 5 // rate limited by Telegram beyond the retry budget + exitTimeout = 6 // --timeout or a wait deadline elapsed + exitRPC = 7 // any other Telegram API error + exitInterrupted = 130 // stopped by SIGINT/SIGTERM +) + +// Error classes, reported as "code" in the JSON error envelope. They map 1:1 to +// the exit codes above. +const ( + errClassError = "error" + errClassUsage = "usage" + errClassAuth = "auth" + errClassNotFound = "not_found" + errClassFloodWait = "flood_wait" + errClassTimeout = "timeout" + errClassRPC = "rpc" + errClassInterrupted = "interrupted" +) + +// errorInfo is the machine-readable description of a failed command, emitted +// as {"schema":1,"error":{...}} on stdout with --output json. +type errorInfo struct { + Code string `json:"code"` + Exit int `json:"exit"` + Message string `json:"message"` + // RetryAfter is the server-requested wait in seconds (flood_wait only). + RetryAfter int `json:"retry_after,omitempty"` + // RPC carries the Telegram error when the failure came from the API. + RPC *rpcErrorInfo `json:"rpc,omitempty"` +} + +type rpcErrorInfo struct { + Code int `json:"code"` + Type string `json:"type"` + Argument int `json:"argument,omitempty"` +} + +// usageError marks invalid invocations (bad flags, wrong argument count). +type usageError struct{ error } + +func (e usageError) Unwrap() error { return e.error } + +// notFoundError marks a missing peer, message or button. +type notFoundError struct{ error } + +func (e notFoundError) Unwrap() error { return e.error } + +// notFoundf returns an error classified as not_found. +func notFoundf(format string, args ...any) error { + return notFoundError{errors.Errorf(format, args...)} +} + +// errWaitTimeout is returned when a command gave up waiting for an event. +var errWaitTimeout = errors.New("timed out waiting") + +// RPC error types that mean the addressed entity does not exist. +var notFoundRPCTypes = []string{ + "USERNAME_NOT_OCCUPIED", "USERNAME_INVALID", "PEER_ID_INVALID", + "MESSAGE_ID_INVALID", "MSG_ID_INVALID", "CHANNEL_INVALID", + "CHAT_ID_INVALID", "USER_ID_INVALID", "PHONE_NOT_OCCUPIED", +} + +// RPC error types that mean the session is unusable and needs `tg login`. +var authRPCTypes = []string{ + "AUTH_KEY_UNREGISTERED", "AUTH_KEY_INVALID", "AUTH_KEY_PERM_EMPTY", + "SESSION_REVOKED", "SESSION_EXPIRED", "USER_DEACTIVATED", "USER_DEACTIVATED_BAN", +} + +// cobraUsagePrefixes are messages of usage errors cobra raises outside the flag +// and argument validation hooks (see markUsageErrors). +var cobraUsagePrefixes = []string{ + "unknown command ", + "required flag(s) ", + "if any flags in the group ", + "at least one of the flags in the group ", +} + +// classifyError maps an error to its class and exit code. +func classifyError(err error) errorInfo { + info := errorInfo{Code: errClassError, Exit: exitError, Message: err.Error()} + set := func(code string, exit int) errorInfo { + info.Code, info.Exit = code, exit + return info + } + + if rpcErr, ok := tgerr.As(err); ok { + info.RPC = &rpcErrorInfo{Code: rpcErr.Code, Type: rpcErr.Type, Argument: rpcErr.Argument} + } + + var ( + usage usageError + notFound notFoundError + ) + switch { + case errors.Is(err, context.DeadlineExceeded), errors.Is(err, errWaitTimeout): + return set(errClassTimeout, exitTimeout) + case errors.Is(err, context.Canceled): + return set(errClassInterrupted, exitInterrupted) + case errors.As(err, &usage) || hasAnyPrefix(err.Error(), cobraUsagePrefixes): + return set(errClassUsage, exitUsage) + case errors.Is(err, errNotAuthorized) || tgerr.Is(err, authRPCTypes...) || tgerr.IsCode(err, 401): + return set(errClassAuth, exitAuth) + case errors.As(err, ¬Found) || tgerr.Is(err, notFoundRPCTypes...): + return set(errClassNotFound, exitNotFound) + } + if d, ok := tgerr.AsFloodWait(err); ok { + info.RetryAfter = int(d.Seconds()) + return set(errClassFloodWait, exitFloodWait) + } + if rpcErr, ok := tgerr.AsType(err, "SLOWMODE_WAIT"); ok { + info.RetryAfter = rpcErr.Argument + return set(errClassFloodWait, exitFloodWait) + } + if info.RPC != nil { + return set(errClassRPC, exitRPC) + } + return info +} + +func hasAnyPrefix(s string, prefixes []string) bool { + for _, p := range prefixes { + if strings.HasPrefix(s, p) { + return true + } + } + return false +} + +// markUsageErrors classifies flag-parsing and argument-validation failures of +// cmd and all its subcommands as usage errors. +func markUsageErrors(cmd *cobra.Command) { + cmd.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { + return usageError{err} + }) + var mark func(c *cobra.Command) + mark = func(c *cobra.Command) { + if args := c.Args; args != nil { + c.Args = func(c *cobra.Command, a []string) error { + if err := args(c, a); err != nil { + return usageError{err} + } + return nil + } + } + for _, sub := range c.Commands() { + mark(sub) + } + } + mark(cmd) +} + +// parseMessageID parses a argument. +func parseMessageID(s string) (int, error) { + id, err := strconv.Atoi(s) + if err != nil || id <= 0 { + return 0, usageError{errors.Errorf("message-id must be a positive integer, got %q", s)} + } + return id, nil +} diff --git a/cmd/tg/exitcode_test.go b/cmd/tg/exitcode_test.go new file mode 100644 index 0000000..6ad234e --- /dev/null +++ b/cmd/tg/exitcode_test.go @@ -0,0 +1,175 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/go-faster/errors" + + "github.com/gotd/td/tgerr" +) + +func TestClassifyError(t *testing.T) { + cases := []struct { + name string + err error + code string + exit int + retryAfter int + rpcType string + }{ + {"generic", errors.New("boom"), errClassError, exitError, 0, ""}, + {"usage", usageError{errors.New("bad flag")}, errClassUsage, exitUsage, 0, ""}, + {"cobra unknown command", errors.New(`unknown command "x" for "tg"`), errClassUsage, exitUsage, 0, ""}, + {"not authorized", errors.Wrap(errNotAuthorized, "run"), errClassAuth, exitAuth, 0, ""}, + {"session revoked", errors.Wrap(tgerr.New(401, "SESSION_REVOKED"), "rpc"), errClassAuth, exitAuth, 0, "SESSION_REVOKED"}, + {"not found sentinel", notFoundf("message #%d not found", 7), errClassNotFound, exitNotFound, 0, ""}, + {"username not occupied", tgerr.New(400, "USERNAME_NOT_OCCUPIED"), errClassNotFound, exitNotFound, 0, "USERNAME_NOT_OCCUPIED"}, + {"flood wait", errors.Wrap(tgerr.New(420, "FLOOD_WAIT_42"), "send"), errClassFloodWait, exitFloodWait, 42, "FLOOD_WAIT"}, + {"slow mode", tgerr.New(420, "SLOWMODE_WAIT_9"), errClassFloodWait, exitFloodWait, 9, "SLOWMODE_WAIT"}, + {"other rpc", tgerr.New(400, "MESSAGE_TOO_LONG"), errClassRPC, exitRPC, 0, "MESSAGE_TOO_LONG"}, + {"deadline", errors.Wrap(context.DeadlineExceeded, "rpc"), errClassTimeout, exitTimeout, 0, ""}, + {"wait timeout", errors.Wrap(errWaitTimeout, "no message"), errClassTimeout, exitTimeout, 0, ""}, + {"canceled", context.Canceled, errClassInterrupted, exitInterrupted, 0, ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := classifyError(c.err) + if got.Code != c.code || got.Exit != c.exit || got.RetryAfter != c.retryAfter { + t.Errorf("classifyError() = %+v; want code=%s exit=%d retry_after=%d", got, c.code, c.exit, c.retryAfter) + } + if c.rpcType == "" && got.RPC != nil { + t.Errorf("unexpected rpc info %+v", got.RPC) + } + if c.rpcType != "" && (got.RPC == nil || got.RPC.Type != c.rpcType) { + t.Errorf("rpc = %+v, want type %s", got.RPC, c.rpcType) + } + if got.Message != c.err.Error() { + t.Errorf("message = %q, want %q", got.Message, c.err.Error()) + } + }) + } +} + +// runCLI runs the CLI in-process with an empty (but present) config and no +// sessions, so commands get as far as needing a login. +func runCLI(t *testing.T, args ...string) (code int, stdout, stderr string) { + t.Helper() + path := filepath.Join(t.TempDir(), "gotd.cli.yaml") + if err := os.WriteFile(path, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + return runCLIConfig(t, path, args...) +} + +// runCLIConfig runs the CLI in-process with the given config path. +func runCLIConfig(t *testing.T, configPath string, args ...string) (code int, stdout, stderr string) { + t.Helper() + var out, errOut bytes.Buffer + args = append([]string{"--config", configPath}, args...) + code = execute(context.Background(), args, &out, &errOut) + return code, out.String(), errOut.String() +} + +func TestExecuteUsageErrors(t *testing.T) { + for name, args := range map[string][]string{ + "unknown command": {"no-such-command"}, + "unknown flag": {"history", "--no-such-flag", "me"}, + "missing args": {"history"}, + "bad message id": {"context", "me", "abc"}, + "no selector": {"click", "@bot"}, + } { + t.Run(name, func(t *testing.T) { + code, _, stderr := runCLI(t, args...) + if code != exitUsage { + t.Errorf("exit = %d, want %d (stderr: %s)", code, exitUsage, stderr) + } + if !strings.HasPrefix(stderr, "tg: ") { + t.Errorf("stderr = %q, want a tg: prefixed message", stderr) + } + }) + } +} + +func TestExecuteJSONErrorEnvelope(t *testing.T) { + code, stdout, _ := runCLI(t, "-o", "json", "history") + if code != exitUsage { + t.Fatalf("exit = %d, want %d", code, exitUsage) + } + var env struct { + Schema int `json:"schema"` + Error *errorInfo `json:"error"` + Data any `json:"data"` + } + if err := json.Unmarshal([]byte(stdout), &env); err != nil { + t.Fatalf("stdout is not a JSON envelope: %v\n%s", err, stdout) + } + if env.Schema != 1 || env.Error == nil || env.Error.Code != errClassUsage || env.Error.Exit != exitUsage || env.Data != nil { + t.Errorf("unexpected envelope: %s", stdout) + } +} + +func TestExecuteTextModeKeepsStdoutClean(t *testing.T) { + code, stdout, _ := runCLI(t, "history") + if code != exitUsage || stdout != "" { + t.Errorf("exit = %d, stdout = %q; want usage exit and empty stdout", code, stdout) + } +} + +func TestExecuteMissingConfig(t *testing.T) { + // A command that needs a config fails cleanly (generic error) without one. + code, _, stderr := runCLIConfig(t, filepath.Join(t.TempDir(), "missing.yaml"), "whoami") + if code != exitError || !strings.Contains(stderr, "tg init") { + t.Errorf("exit = %d, stderr = %q", code, stderr) + } +} + +func TestExecuteVersion(t *testing.T) { + code, stdout, stderr := runCLI(t, "version", "-o", "json") + if code != exitOK { + t.Fatalf("exit = %d, stderr = %s", code, stderr) + } + var env struct { + Data versionInfo `json:"data"` + } + if err := json.Unmarshal([]byte(stdout), &env); err != nil || env.Data.Version == "" { + t.Fatalf("bad version output %q: %v", stdout, err) + } + + code, stdout, _ = runCLI(t, "--version") + if code != exitOK || !strings.HasPrefix(stdout, "tg ") { + t.Errorf("--version: exit = %d, stdout = %q", code, stdout) + } +} + +func TestWaitTimeoutFlagShadowsGlobal(t *testing.T) { + // `wait` keeps its own --timeout; the global one must not break parsing. + code, _, stderr := runCLI(t, "wait", "--timeout", "1s", "--help") + if code != exitOK { + t.Fatalf("exit = %d, stderr = %s", code, stderr) + } + code, _, stderr = runCLI(t, "--timeout", "1s", "history", "--help") + if code != exitOK { + t.Fatalf("global --timeout: exit = %d, stderr = %s", code, stderr) + } +} + +func TestParseMessageID(t *testing.T) { + for in, want := range map[string]int{"1": 1, "4242": 4242} { + if got, err := parseMessageID(in); err != nil || got != want { + t.Errorf("parseMessageID(%q) = %d, %v", in, got, err) + } + } + for _, in := range []string{"", "0", "-3", "1.5", "abc", " 7"} { + _, err := parseMessageID(in) + var u usageError + if !errors.As(err, &u) { + t.Errorf("parseMessageID(%q) err = %v, want usage error", in, err) + } + } +} diff --git a/cmd/tg/format.go b/cmd/tg/format.go index fe22e63..6152c05 100644 --- a/cmd/tg/format.go +++ b/cmd/tg/format.go @@ -5,6 +5,7 @@ import ( "io" "strconv" "strings" + "unicode/utf16" "github.com/go-faster/errors" @@ -173,9 +174,140 @@ func buildMessageItem(msg *tg.Message, ent peer.Entities) messageItem { item.ReplyTo = h.ReplyToMsgID } } + item.EditDate, _ = msg.GetEditDate() + item.ViaBotID, _ = msg.GetViaBotID() + item.GroupedID, _ = msg.GetGroupedID() + if media, ok := msg.GetMedia(); ok { + item.File = newFileInfo(media) + } + if ents, ok := msg.GetEntities(); ok { + item.Entities = newEntityItems(msg.Message, ents) + } + if rm, ok := msg.GetReplyMarkup(); ok { + item.ReplyMarkup = newReplyMarkup(rm) + } return item } +// fileInfo describes a message's document or photo. +type fileInfo struct { + // Kind refines a document: "file", "video", "video_note", "audio", + // "voice", "animation" or "sticker"; photos are "photo". + Kind string `json:"kind"` + Name string `json:"name,omitempty"` + MIME string `json:"mime,omitempty"` + Size int64 `json:"size,omitempty"` + Duration float64 `json:"duration,omitempty"` // seconds + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` +} + +// newFileInfo describes document and photo media; nil for other media. +func newFileInfo(m tg.MessageMediaClass) *fileInfo { + switch v := m.(type) { + case *tg.MessageMediaDocument: + doc, ok := v.Document.AsNotEmpty() + if !ok { + return nil + } + return documentInfo(doc) + case *tg.MessageMediaPhoto: + ph, ok := v.Photo.AsNotEmpty() + if !ok { + return nil + } + info := &fileInfo{Kind: "photo", MIME: "image/jpeg"} + for _, s := range ph.Sizes { + if size, ok := s.(*tg.PhotoSize); ok && size.W*size.H >= info.Width*info.Height { + info.Width, info.Height, info.Size = size.W, size.H, int64(size.Size) + } + if size, ok := s.(*tg.PhotoSizeProgressive); ok && size.W*size.H >= info.Width*info.Height { + info.Width, info.Height = size.W, size.H + if n := len(size.Sizes); n > 0 { + info.Size = int64(size.Sizes[n-1]) + } + } + } + return info + default: + return nil + } +} + +// documentInfo classifies a document by its attributes. +func documentInfo(doc *tg.Document) *fileInfo { + info := &fileInfo{Kind: "file", MIME: doc.MimeType, Size: doc.Size} + for _, attr := range doc.Attributes { + switch a := attr.(type) { + case *tg.DocumentAttributeFilename: + info.Name = a.FileName + case *tg.DocumentAttributeVideo: + info.Kind = "video" + if a.RoundMessage { + info.Kind = "video_note" + } + info.Duration, info.Width, info.Height = a.Duration, a.W, a.H + case *tg.DocumentAttributeAudio: + info.Kind = "audio" + if a.Voice { + info.Kind = "voice" + } + info.Duration = float64(a.Duration) + case *tg.DocumentAttributeImageSize: + info.Width, info.Height = a.W, a.H + } + } + // Animated and sticker attributes override video/image classification. + for _, attr := range doc.Attributes { + switch attr.(type) { + case *tg.DocumentAttributeAnimated: + info.Kind = "animation" + case *tg.DocumentAttributeSticker: + info.Kind = "sticker" + } + } + return info +} + +// entityItem is one formatting entity of a message text. Offset and Length +// count UTF-16 code units, as Telegram does; Text is the covered substring. +type entityItem struct { + Type string `json:"type"` + Offset int `json:"offset"` + Length int `json:"length"` + Text string `json:"text,omitempty"` + URL string `json:"url,omitempty"` + UserID int64 `json:"user_id,omitempty"` + Language string `json:"language,omitempty"` +} + +// newEntityItems converts message entities, e.g. messageEntityTextUrl -> +// {"type":"text_url","url":...}. +func newEntityItems(text string, ents []tg.MessageEntityClass) []entityItem { + units := utf16.Encode([]rune(text)) + out := make([]entityItem, 0, len(ents)) + for _, e := range ents { + item := entityItem{ + Type: snakeCase(strings.TrimPrefix(e.TypeName(), "messageEntity")), + Offset: e.GetOffset(), + Length: e.GetLength(), + } + if start, end := item.Offset, item.Offset+item.Length; start >= 0 && start <= end && end <= len(units) { + item.Text = string(utf16.Decode(units[start:end])) + } + switch v := e.(type) { + case *tg.MessageEntityTextURL: + item.URL = v.URL + case *tg.MessageEntityMentionName: + item.UserID = v.UserID + case *tg.MessageEntityPre: + item.Language = v.Language + } + out = append(out, item) + } + return out +} + // mediaType returns a short lowercase name for a message media, e.g. "photo". func mediaType(m tg.MessageMediaClass) string { if _, ok := m.(*tg.MessageMediaEmpty); ok { diff --git a/cmd/tg/history.go b/cmd/tg/history.go index 5844fa3..87bbddc 100644 --- a/cmd/tg/history.go +++ b/cmd/tg/history.go @@ -12,7 +12,8 @@ import ( "github.com/gotd/td/tg" ) -// messageItem describes one message. +// messageItem describes one message. Fields after ReplyTo were added later; +// all are omitted when empty, so older consumers see the same shape. type messageItem struct { ID int `json:"id"` Date int `json:"date"` @@ -21,6 +22,20 @@ type messageItem struct { Text string `json:"text,omitempty"` Media string `json:"media,omitempty"` ReplyTo int `json:"reply_to,omitempty"` + + // EditDate is the unix time of the last edit (0 = never edited). + EditDate int `json:"edit_date,omitempty"` + // ViaBotID is the inline bot the message was sent through. + ViaBotID int64 `json:"via_bot_id,omitempty"` + // GroupedID links the messages of one album. + GroupedID int64 `json:"grouped_id,omitempty"` + // File describes attached document or photo media. + File *fileInfo `json:"file,omitempty"` + // Entities are the text's formatting and links. + Entities []entityItem `json:"entities,omitempty"` + // ReplyMarkup is the keyboard a bot attached (inline buttons or a reply + // keyboard); press buttons with `tg click`. + ReplyMarkup *replyMarkup `json:"reply_markup,omitempty"` } // historyResult is the result of `tg history`. @@ -49,6 +64,12 @@ func (h historyResult) MarshalText(w io.Writer) error { if m.ReplyTo != 0 { line += fmt.Sprintf(" (reply to #%d)", m.ReplyTo) } + if m.EditDate != 0 { + line += " (edited)" + } + if m.ReplyMarkup != nil { + line += " " + m.ReplyMarkup.label() + } if _, err := fmt.Fprintln(w, line); err != nil { return err } @@ -57,8 +78,9 @@ func (h historyResult) MarshalText(w io.Writer) error { } // listHistory reads up to limit recent messages from peer (newest-first from the -// API), returning them oldest-first. -func listHistory(ctx context.Context, api *tg.Client, peer tg.InputPeerClass, limit int) (historyResult, error) { +// API), returning them oldest-first. With after > 0 only messages newer than +// that id are returned. +func listHistory(ctx context.Context, api *tg.Client, peer tg.InputPeerClass, limit, after int) (historyResult, error) { // Fetch in large batches to minimize round trips. The iterator's default // batch size is 1, so it issues one messages.getHistory RPC per message, // which makes larger --limit values extremely slow. Telegram does not @@ -74,6 +96,9 @@ func listHistory(ctx context.Context, api *tg.Client, peer tg.InputPeerClass, li break } elem := iter.Value() + if after > 0 && elem.Msg.GetID() <= after { + break // newest-first: everything from here on is older + } msg, ok := elem.Msg.(*tg.Message) if !ok { continue @@ -95,7 +120,7 @@ func listHistory(ctx context.Context, api *tg.Client, peer tg.InputPeerClass, li } func (a *app) newHistoryCmd() *cobra.Command { - var limit int + var limit, after int cmd := &cobra.Command{ Use: "history ", @@ -103,10 +128,15 @@ func (a *app) newHistoryCmd() *cobra.Command { Short: "Read recent messages from a peer", GroupID: groupMessaging, Long: `Read recent messages from a peer (newest-first from the API, printed -oldest-first). The peer is me/self, @username, phone, or a t.me link.`, +oldest-first). The peer is me/self, @username, phone, or a t.me link. + +JSON output includes each message's buttons ("reply_markup"), formatting +("entities"), attached file details ("file") and "edit_date". --after returns +only messages newer than a given id, e.g. the replies to a message you sent.`, Example: ` tg history me tg history @durov --limit 20 - tg history @somechannel --output json`, + tg history @somechannel --output json + tg history @mybot --after 4242 -o json`, Args: cobra.ExactArgs(1), ValidArgsFunction: peerArgCompletion, RunE: func(cmd *cobra.Command, args []string) error { @@ -119,7 +149,7 @@ oldest-first). The peer is me/self, @username, phone, or a t.me link.`, if err != nil { return err } - res, err := listHistory(ctx, api, peer, limit) + res, err := listHistory(ctx, api, peer, limit, after) if err != nil { return err } @@ -129,6 +159,7 @@ oldest-first). The peer is me/self, @username, phone, or a t.me link.`, } cmd.Flags().IntVarP(&limit, "limit", "n", 30, "maximum number of messages to read") + cmd.Flags().IntVar(&after, "after", 0, "only messages with an id greater than this") return cmd } diff --git a/cmd/tg/history_test.go b/cmd/tg/history_test.go index 278055d..327c7bd 100644 --- a/cmd/tg/history_test.go +++ b/cmd/tg/history_test.go @@ -25,7 +25,7 @@ func TestListHistory(t *testing.T) { return nil, errors.Errorf("unexpected request %T", req) }) - res, err := listHistory(context.Background(), api, &tg.InputPeerSelf{}, 30) + res, err := listHistory(context.Background(), api, &tg.InputPeerSelf{}, 30, 0) if err != nil { t.Fatal(err) } @@ -56,7 +56,7 @@ func TestListHistoryLimit(t *testing.T) { return nil, errors.Errorf("unexpected request %T", req) }) - res, err := listHistory(context.Background(), api, &tg.InputPeerSelf{}, 2) + res, err := listHistory(context.Background(), api, &tg.InputPeerSelf{}, 2, 0) if err != nil { t.Fatal(err) } @@ -94,7 +94,7 @@ func TestListHistoryBatchSize(t *testing.T) { }, nil }) - if _, err := listHistory(context.Background(), api, &tg.InputPeerSelf{}, tt.limit); err != nil { + if _, err := listHistory(context.Background(), api, &tg.InputPeerSelf{}, tt.limit, 0); err != nil { t.Fatalf("limit %d: %v", tt.limit, err) } if gotBatch != tt.wantBatch { diff --git a/cmd/tg/init.go b/cmd/tg/init.go index 0f707ee..10ebd88 100644 --- a/cmd/tg/init.go +++ b/cmd/tg/init.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "strconv" + "time" "github.com/go-faster/errors" "github.com/spf13/cobra" @@ -15,6 +16,13 @@ func envInt(key string) int { return v } +// envDuration returns the duration value of the environment variable, or 0 +// when it is unset or malformed. +func envDuration(key string) time.Duration { + v, _ := time.ParseDuration(os.Getenv(key)) + return v +} + func newInitCmd(a *app) *cobra.Command { var ( appID int diff --git a/cmd/tg/link.go b/cmd/tg/link.go index 32be011..e41d20b 100644 --- a/cmd/tg/link.go +++ b/cmd/tg/link.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "io" - "strconv" "github.com/go-faster/errors" "github.com/spf13/cobra" @@ -35,9 +34,9 @@ func (a *app) newLinkCmd() *cobra.Command { Args: cobra.ExactArgs(2), ValidArgsFunction: peerArgCompletion, RunE: func(cmd *cobra.Command, args []string) error { - id, err := strconv.Atoi(args[1]) + id, err := parseMessageID(args[1]) if err != nil { - return errors.Wrap(err, "message-id must be an integer") + return err } return a.run(cmd.Context(), runParams{auth: authUser}, func(ctx context.Context, api *tg.Client) error { m, err := a.manager(api) diff --git a/cmd/tg/main.go b/cmd/tg/main.go index edab731..0743b02 100644 --- a/cmd/tg/main.go +++ b/cmd/tg/main.go @@ -3,22 +3,60 @@ package main import ( "context" "fmt" + "io" "os" "os/signal" + "syscall" + + "github.com/gotd/cli/internal/output" ) func main() { - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) - defer cancel() + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + code := execute(ctx, os.Args[1:], os.Stdout, os.Stderr) + cancel() + os.Exit(code) +} + +// execute runs the CLI with args and returns the process exit code (see +// exitcode.go). Errors are always reported on stderr; with --output json a +// machine-readable error envelope is also written to stdout, the stream agents +// parse. +func execute(ctx context.Context, args []string, stdout, stderr io.Writer) int { + root, a := newRoot() + root.SetArgs(args) + root.SetOut(stdout) + root.SetErr(stderr) + + err := root.ExecuteContext(ctx) + a.close() + if err == nil { + return exitOK + } - if err := newRootCmd().ExecuteContext(ctx); err != nil { - // Concise message by default; full chain + stack with TG_DEBUG for - // troubleshooting. All diagnostics go to stderr. - if os.Getenv("TG_DEBUG") != "" { - fmt.Fprintf(os.Stderr, "%+v\n", err) - } else { - fmt.Fprintf(os.Stderr, "tg: %v\n", err) + info := classifyError(err) + if ctx.Err() != nil { + // Stopped by a signal: whatever failed, failed because of it. + info.Code, info.Exit = errClassInterrupted, exitInterrupted + } + if info.Code == errClassTimeout && a.timeout > 0 { + info.Message = fmt.Sprintf("timed out after %s (--timeout): %s", a.timeout, info.Message) + } + + // Concise message by default; full chain + stack with TG_DEBUG for + // troubleshooting. All diagnostics go to stderr. + if os.Getenv("TG_DEBUG") != "" { + _, _ = fmt.Fprintf(stderr, "%+v\n", err) + } else { + _, _ = fmt.Fprintf(stderr, "tg: %s\n", info.Message) + } + if a.outputFormat == string(output.JSON) { + p := a.printer + if p.Format() != output.JSON { + // Failed before the printer was configured (e.g. a flag error). + p = output.New(output.JSON, stdout) } - os.Exit(1) + _ = p.EmitError(info) } + return info.Exit } diff --git a/cmd/tg/markup.go b/cmd/tg/markup.go new file mode 100644 index 0000000..a30a0a9 --- /dev/null +++ b/cmd/tg/markup.go @@ -0,0 +1,315 @@ +package main + +import ( + "encoding/base64" + "fmt" + "strings" + "unicode" + "unicode/utf8" + + "github.com/go-faster/errors" + + "github.com/gotd/td/tg" +) + +// Reply markup types, the "type" of a replyMarkup. +const ( + markupInline = "inline" // buttons attached to the message + markupKeyboard = "keyboard" // custom keyboard replacing the user's + markupRemove = "remove" // hide the custom keyboard + markupForceReply = "force_reply" // ask the user to reply to the message +) + +// Button types handled specially by `tg click`. All types are named after the +// Telegram constructor, e.g. keyboardButtonSwitchInline -> "switch_inline". +const ( + buttonText = "text" // plain reply-keyboard button: pressing sends its text + buttonCallback = "callback" + buttonGame = "game" + buttonURL = "url" + buttonURLAuth = "url_auth" + buttonCopy = "copy" +) + +// replyMarkup is a message's keyboard, as a bot sent it. +type replyMarkup struct { + Type string `json:"type"` + Rows [][]buttonItem `json:"rows,omitempty"` + Placeholder string `json:"placeholder,omitempty"` + SingleUse bool `json:"single_use,omitempty"` + Persistent bool `json:"persistent,omitempty"` +} + +// buttonItem is one keyboard button. +type buttonItem struct { + Type string `json:"type"` + Text string `json:"text"` + // Data is the callback data when it is printable text; binary data is + // reported as DataBase64 instead. + Data string `json:"data,omitempty"` + DataBase64 string `json:"data_base64,omitempty"` + URL string `json:"url,omitempty"` + Query string `json:"query,omitempty"` + CopyText string `json:"copy_text,omitempty"` + RequiresPassword bool `json:"requires_password,omitempty"` +} + +// newReplyMarkup converts a message's reply markup; nil when there is none. +func newReplyMarkup(rm tg.ReplyMarkupClass) *replyMarkup { + switch v := rm.(type) { + case *tg.ReplyInlineMarkup: + return &replyMarkup{Type: markupInline, Rows: buttonRows(v.Rows)} + case *tg.ReplyKeyboardMarkup: + return &replyMarkup{ + Type: markupKeyboard, + Rows: buttonRows(v.Rows), + Placeholder: v.Placeholder, + SingleUse: v.SingleUse, + Persistent: v.Persistent, + } + case *tg.ReplyKeyboardHide: + return &replyMarkup{Type: markupRemove} + case *tg.ReplyKeyboardForceReply: + return &replyMarkup{Type: markupForceReply, Placeholder: v.Placeholder, SingleUse: v.SingleUse} + default: + return nil + } +} + +func buttonRows(rows []tg.KeyboardButtonRow) [][]buttonItem { + out := make([][]buttonItem, 0, len(rows)) + for _, row := range rows { + items := make([]buttonItem, 0, len(row.Buttons)) + for _, b := range row.Buttons { + items = append(items, newButtonItem(b)) + } + out = append(out, items) + } + return out +} + +func newButtonItem(b tg.KeyboardButtonClass) buttonItem { + item := buttonItem{Type: buttonType(b), Text: b.GetText()} + switch v := b.(type) { + case *tg.KeyboardButtonCallback: + item.RequiresPassword = v.RequiresPassword + if isPrintable(v.Data) { + item.Data = string(v.Data) + } else { + item.DataBase64 = base64.StdEncoding.EncodeToString(v.Data) + } + case *tg.KeyboardButtonURL: + item.URL = v.URL + case *tg.KeyboardButtonURLAuth: + item.URL = v.URL + case *tg.KeyboardButtonWebView: + item.URL = v.URL + case *tg.KeyboardButtonSimpleWebView: + item.URL = v.URL + case *tg.KeyboardButtonSwitchInline: + item.Query = v.Query + case *tg.KeyboardButtonCopy: + item.CopyText = v.CopyText + } + return item +} + +// buttonType names a button after its constructor: keyboardButtonCallback -> +// "callback", keyboardButtonRequestGeoLocation -> "request_geo_location"; the +// plain keyboardButton is "text". +func buttonType(b tg.KeyboardButtonClass) string { + name := strings.TrimPrefix(b.TypeName(), "keyboardButton") + if name == "" { + return buttonText + } + return snakeCase(name) +} + +// snakeCase converts a CamelCase identifier to snake_case. +func snakeCase(s string) string { + var b strings.Builder + for i, r := range s { + if unicode.IsUpper(r) { + if i > 0 { + b.WriteByte('_') + } + r = unicode.ToLower(r) + } + b.WriteRune(r) + } + return b.String() +} + +// isPrintable reports whether callback data can be shown as text. +func isPrintable(data []byte) bool { + if !utf8.Valid(data) { + return false + } + for _, r := range string(data) { + if !unicode.IsPrint(r) { + return false + } + } + return true +} + +// label renders the markup compactly for text output, e.g. +// "[buttons: Yes | No / Back]". +func (m *replyMarkup) label() string { + if m == nil { + return "" + } + kind := "buttons" + switch m.Type { + case markupKeyboard: + kind = "keyboard" + case markupRemove: + return "[keyboard removed]" + case markupForceReply: + return "[force reply]" + } + rows := make([]string, 0, len(m.Rows)) + for _, row := range m.Rows { + labels := make([]string, 0, len(row)) + for _, b := range row { + labels = append(labels, b.Text) + } + rows = append(rows, strings.Join(labels, " | ")) + } + return "[" + kind + ": " + strings.Join(rows, " / ") + "]" +} + +// keyboardButton is one button of a message's keyboard with its position. +type keyboardButton struct { + Index int // 1-based position in reading order + Row, Col int // 1-based + raw tg.KeyboardButtonClass + item buttonItem +} + +// keyboardButtons flattens a message's inline or reply keyboard in reading +// order. Markup without buttons yields nil. +func keyboardButtons(rm tg.ReplyMarkupClass) []keyboardButton { + var rows []tg.KeyboardButtonRow + switch v := rm.(type) { + case *tg.ReplyInlineMarkup: + rows = v.Rows + case *tg.ReplyKeyboardMarkup: + rows = v.Rows + default: + return nil + } + var out []keyboardButton + for r, row := range rows { + for c, b := range row.Buttons { + out = append(out, keyboardButton{ + Index: len(out) + 1, Row: r + 1, Col: c + 1, + raw: b, item: newButtonItem(b), + }) + } + } + return out +} + +// buttonSelector picks one button of a keyboard. Exactly one way of selecting +// is set: text, data, index, or row+col. +type buttonSelector struct { + text string // label: exact, case-insensitive, then unique substring + data string // exact callback data (text form, as printed in "data") + index int // 1-based position in reading order + row int // 1-based row, with col + col int // 1-based column, with row +} + +// validate checks that exactly one selection method is set. +func (s buttonSelector) validate() error { + n := 0 + for _, set := range []bool{s.text != "", s.data != "", s.index != 0, s.row != 0 || s.col != 0} { + if set { + n++ + } + } + switch { + case n == 0: + return usageError{errors.New("choose a button with --text, --data, --index or --row/--col")} + case n > 1: + return usageError{errors.New("use only one of --text, --data, --index or --row/--col")} + case (s.row != 0) != (s.col != 0): + return usageError{errors.New("--row and --col must be used together")} + case s.index < 0 || s.row < 0 || s.col < 0: + return usageError{errors.New("button positions are 1-based")} + } + return nil +} + +// selectButton finds the button sel refers to. +func selectButton(buttons []keyboardButton, sel buttonSelector) (keyboardButton, error) { + if len(buttons) == 0 { + return keyboardButton{}, notFoundf("message has no keyboard buttons") + } + switch { + case sel.index != 0: + if sel.index > len(buttons) { + return keyboardButton{}, notFoundf("no button #%d; %s", sel.index, describeButtons(buttons)) + } + return buttons[sel.index-1], nil + case sel.row != 0: + for _, b := range buttons { + if b.Row == sel.row && b.Col == sel.col { + return b, nil + } + } + return keyboardButton{}, notFoundf("no button at row %d, col %d; %s", sel.row, sel.col, describeButtons(buttons)) + case sel.data != "": + for _, b := range buttons { + if cb, ok := b.raw.(*tg.KeyboardButtonCallback); ok && string(cb.Data) == sel.data { + return b, nil + } + } + return keyboardButton{}, notFoundf("no callback button with data %q; %s", sel.data, describeButtons(buttons)) + default: + return selectButtonByText(buttons, sel.text) + } +} + +// selectButtonByText matches a label exactly, then case-insensitively, then as +// a case-insensitive substring that must be unique (labels often carry emoji +// prefixes such as "📋 Menu"). +func selectButtonByText(buttons []keyboardButton, text string) (keyboardButton, error) { + for _, b := range buttons { + if b.item.Text == text { + return b, nil + } + } + for _, b := range buttons { + if strings.EqualFold(b.item.Text, text) { + return b, nil + } + } + needle := strings.ToLower(text) + var matches []keyboardButton + for _, b := range buttons { + if strings.Contains(strings.ToLower(b.item.Text), needle) { + matches = append(matches, b) + } + } + switch len(matches) { + case 1: + return matches[0], nil + case 0: + return keyboardButton{}, notFoundf("no button labeled %q; %s", text, describeButtons(buttons)) + default: + return keyboardButton{}, notFoundf("%q matches %d buttons; be more specific: %s", + text, len(matches), describeButtons(matches)) + } +} + +// describeButtons lists buttons for error messages, e.g. +// `buttons: #1 "Yes" (1,1), #2 "No" (1,2)`. +func describeButtons(buttons []keyboardButton) string { + parts := make([]string, 0, len(buttons)) + for _, b := range buttons { + parts = append(parts, fmt.Sprintf("#%d %q (%d,%d)", b.Index, b.item.Text, b.Row, b.Col)) + } + return "buttons: " + strings.Join(parts, ", ") +} diff --git a/cmd/tg/markup_test.go b/cmd/tg/markup_test.go new file mode 100644 index 0000000..2d8c5ef --- /dev/null +++ b/cmd/tg/markup_test.go @@ -0,0 +1,231 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/go-faster/errors" + + "github.com/gotd/td/tg" +) + +func testInlineMarkup() *tg.ReplyInlineMarkup { + return &tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{ + {Buttons: []tg.KeyboardButtonClass{ + &tg.KeyboardButtonCallback{Text: "📋 Menu", Data: []byte("menu")}, + &tg.KeyboardButtonCallback{Text: "Settings", Data: []byte{0xff, 0x00}}, + }}, + {Buttons: []tg.KeyboardButtonClass{ + &tg.KeyboardButtonURL{Text: "Docs", URL: "https://example.com"}, + &tg.KeyboardButtonSwitchInline{Text: "Share", Query: "q"}, + }}, + }} +} + +func TestNewReplyMarkupInline(t *testing.T) { + m := newReplyMarkup(testInlineMarkup()) + if m == nil || m.Type != markupInline || len(m.Rows) != 2 { + t.Fatalf("markup = %+v", m) + } + menu, settings, docs, share := m.Rows[0][0], m.Rows[0][1], m.Rows[1][0], m.Rows[1][1] + if menu.Type != buttonCallback || menu.Data != "menu" || menu.DataBase64 != "" { + t.Errorf("menu = %+v", menu) + } + if settings.Data != "" || settings.DataBase64 != "/wA=" { + t.Errorf("binary data = %+v, want base64", settings) + } + if docs.Type != buttonURL || docs.URL != "https://example.com" { + t.Errorf("docs = %+v", docs) + } + if share.Type != "switch_inline" || share.Query != "q" { + t.Errorf("share = %+v", share) + } + if got := m.label(); got != "[buttons: 📋 Menu | Settings / Docs | Share]" { + t.Errorf("label = %q", got) + } +} + +func TestNewReplyMarkupKinds(t *testing.T) { + kb := newReplyMarkup(&tg.ReplyKeyboardMarkup{ + SingleUse: true, + Placeholder: "Pick one", + Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{ + &tg.KeyboardButton{Text: "Yes"}, + &tg.KeyboardButtonRequestPhone{Text: "Share phone"}, + }}}, + }) + if kb.Type != markupKeyboard || !kb.SingleUse || kb.Placeholder != "Pick one" { + t.Errorf("keyboard = %+v", kb) + } + if kb.Rows[0][0].Type != buttonText || kb.Rows[0][1].Type != "request_phone" { + t.Errorf("button types = %q, %q", kb.Rows[0][0].Type, kb.Rows[0][1].Type) + } + if got := newReplyMarkup(&tg.ReplyKeyboardHide{}); got.Type != markupRemove { + t.Errorf("hide = %+v", got) + } + if got := newReplyMarkup(&tg.ReplyKeyboardForceReply{}); got.Type != markupForceReply { + t.Errorf("force reply = %+v", got) + } + if got := newReplyMarkup(nil); got != nil { + t.Errorf("nil markup = %+v", got) + } +} + +func TestButtonTypeNames(t *testing.T) { + for b, want := range map[tg.KeyboardButtonClass]string{ + &tg.KeyboardButton{}: "text", + &tg.KeyboardButtonCallback{}: "callback", + &tg.KeyboardButtonURL{}: "url", + &tg.KeyboardButtonURLAuth{}: "url_auth", + &tg.KeyboardButtonWebView{}: "web_view", + &tg.KeyboardButtonSimpleWebView{}: "simple_web_view", + &tg.KeyboardButtonRequestGeoLocation{}: "request_geo_location", + &tg.KeyboardButtonGame{}: "game", + &tg.KeyboardButtonCopy{}: "copy", + } { + if got := buttonType(b); got != want { + t.Errorf("buttonType(%T) = %q, want %q", b, got, want) + } + } +} + +func TestSelectButton(t *testing.T) { + buttons := keyboardButtons(testInlineMarkup()) + cases := []struct { + name string + sel buttonSelector + want string // label of the selected button; "" = expect not found + }{ + {"exact", buttonSelector{text: "Settings"}, "Settings"}, + {"case-insensitive", buttonSelector{text: "settings"}, "Settings"}, + {"substring past emoji", buttonSelector{text: "menu"}, "📋 Menu"}, + {"no match", buttonSelector{text: "Logout"}, ""}, + {"data", buttonSelector{data: "menu"}, "📋 Menu"}, + {"data missing", buttonSelector{data: "nope"}, ""}, + {"index", buttonSelector{index: 3}, "Docs"}, + {"index out of range", buttonSelector{index: 9}, ""}, + {"row/col", buttonSelector{row: 2, col: 2}, "Share"}, + {"row/col missing", buttonSelector{row: 3, col: 1}, ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := selectButton(buttons, c.sel) + if c.want == "" { + var nf notFoundError + if !errors.As(err, &nf) { + t.Fatalf("err = %v, want not-found", err) + } + if !strings.Contains(err.Error(), `#1 "📋 Menu" (1,1)`) { + t.Errorf("error should list the buttons: %v", err) + } + return + } + if err != nil || got.item.Text != c.want { + t.Fatalf("selected %q, %v; want %q", got.item.Text, err, c.want) + } + }) + } +} + +func TestSelectButtonAmbiguous(t *testing.T) { + buttons := keyboardButtons(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{ + &tg.KeyboardButtonCallback{Text: "Plan A", Data: []byte("a")}, + &tg.KeyboardButtonCallback{Text: "Plan B", Data: []byte("b")}, + }}}}) + _, err := selectButton(buttons, buttonSelector{text: "plan"}) + if err == nil || !strings.Contains(err.Error(), "matches 2 buttons") { + t.Fatalf("err = %v, want ambiguity error", err) + } + if _, err := selectButton(nil, buttonSelector{text: "x"}); err == nil { + t.Fatal("expected error for a message without buttons") + } +} + +func TestButtonSelectorValidate(t *testing.T) { + valid := []buttonSelector{{text: "a"}, {data: "x"}, {index: 1}, {row: 1, col: 2}} + for _, s := range valid { + if err := s.validate(); err != nil { + t.Errorf("%+v: %v", s, err) + } + } + invalid := []buttonSelector{{}, {text: "a", index: 1}, {row: 1}, {col: 1}, {index: -1}} + for _, s := range invalid { + var u usageError + if err := s.validate(); !errors.As(err, &u) { + t.Errorf("%+v: err = %v, want usage error", s, err) + } + } +} + +func TestBuildMessageItemDetails(t *testing.T) { + msg := &tg.Message{ + ID: 10, + PeerID: &tg.PeerUser{UserID: 5}, + Message: "👋 Hi, see docs", + Date: 100, + } + msg.SetEditDate(120) + msg.SetViaBotID(77) + msg.SetReplyMarkup(testInlineMarkup()) + // "👋" is two UTF-16 code units, so "Hi" starts at offset 3. + msg.SetEntities([]tg.MessageEntityClass{ + &tg.MessageEntityBold{Offset: 3, Length: 2}, + &tg.MessageEntityTextURL{Offset: 11, Length: 4, URL: "https://example.com"}, + }) + msg.SetMedia(&tg.MessageMediaDocument{Document: &tg.Document{ + MimeType: "audio/ogg", + Size: 2048, + Attributes: []tg.DocumentAttributeClass{ + &tg.DocumentAttributeAudio{Voice: true, Duration: 3}, + }, + }}) + + item := buildMessageItem(msg, entitiesOf(nil, nil)) + if item.EditDate != 120 || item.ViaBotID != 77 { + t.Errorf("edit/via = %d/%d", item.EditDate, item.ViaBotID) + } + if item.ReplyMarkup == nil || item.ReplyMarkup.Type != markupInline { + t.Errorf("markup = %+v", item.ReplyMarkup) + } + if len(item.Entities) != 2 || + item.Entities[0].Type != "bold" || item.Entities[0].Text != "Hi" || + item.Entities[1].Type != "text_url" || item.Entities[1].Text != "docs" || item.Entities[1].URL != "https://example.com" { + t.Errorf("entities = %+v", item.Entities) + } + if item.File == nil || item.File.Kind != "voice" || item.File.Size != 2048 || item.File.Duration != 3 { + t.Errorf("file = %+v", item.File) + } +} + +func TestDocumentKinds(t *testing.T) { + cases := map[string][]tg.DocumentAttributeClass{ + "file": {&tg.DocumentAttributeFilename{FileName: "a.pdf"}}, + "video": {&tg.DocumentAttributeVideo{Duration: 2, W: 640, H: 480}}, + "video_note": {&tg.DocumentAttributeVideo{RoundMessage: true}}, + "audio": {&tg.DocumentAttributeAudio{Duration: 60}}, + "animation": {&tg.DocumentAttributeVideo{}, &tg.DocumentAttributeAnimated{}}, + "sticker": {&tg.DocumentAttributeImageSize{W: 512, H: 512}, &tg.DocumentAttributeSticker{}}, + } + for want, attrs := range cases { + if got := documentInfo(&tg.Document{Attributes: attrs}).Kind; got != want { + t.Errorf("kind = %q, want %q", got, want) + } + } + if name := documentInfo(&tg.Document{Attributes: cases["file"]}).Name; name != "a.pdf" { + t.Errorf("name = %q", name) + } +} + +// TestMessageItemJSONCompat pins the JSON of a plain message to the shape +// consumers parsed before the bot-testing fields were added. +func TestMessageItemJSONCompat(t *testing.T) { + msg := &tg.Message{ID: 3, PeerID: &tg.PeerUser{UserID: 5}, Message: "hi", Date: 9, Out: true} + got, err := json.Marshal(buildMessageItem(msg, entitiesOf(nil, nil))) + if err != nil { + t.Fatal(err) + } + if want := `{"id":3,"date":9,"out":true,"text":"hi"}`; string(got) != want { + t.Errorf("json = %s, want %s", got, want) + } +} diff --git a/cmd/tg/pin.go b/cmd/tg/pin.go index d44bd95..6cff620 100644 --- a/cmd/tg/pin.go +++ b/cmd/tg/pin.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "io" - "strconv" "github.com/go-faster/errors" "github.com/spf13/cobra" @@ -52,9 +51,9 @@ func (a *app) pinCmd(use, short string, unpin bool) *cobra.Command { Args: cobra.ExactArgs(2), ValidArgsFunction: peerArgCompletion, RunE: func(cmd *cobra.Command, args []string) error { - id, err := strconv.Atoi(args[1]) + id, err := parseMessageID(args[1]) if err != nil { - return errors.Wrap(err, "message-id must be an integer") + return err } return a.run(cmd.Context(), runParams{auth: authUser}, func(ctx context.Context, api *tg.Client) error { m, err := a.manager(api) diff --git a/cmd/tg/reactions.go b/cmd/tg/reactions.go index 42d5ef6..79aac29 100644 --- a/cmd/tg/reactions.go +++ b/cmd/tg/reactions.go @@ -84,9 +84,9 @@ func (a *app) reactCmd(use, short string, remove bool) *cobra.Command { Args: cobra.ExactArgs(minArgs), ValidArgsFunction: peerArgCompletion, RunE: func(cmd *cobra.Command, args []string) error { - id, err := strconv.Atoi(args[1]) + id, err := parseMessageID(args[1]) if err != nil { - return errors.Wrap(err, "message-id must be an integer") + return err } emoji := "" if !remove { @@ -128,9 +128,9 @@ func (a *app) newReactionsCmd() *cobra.Command { Args: cobra.ExactArgs(2), ValidArgsFunction: peerArgCompletion, RunE: func(cmd *cobra.Command, args []string) error { - id, err := strconv.Atoi(args[1]) + id, err := parseMessageID(args[1]) if err != nil { - return errors.Wrap(err, "message-id must be an integer") + return err } return a.run(cmd.Context(), runParams{auth: authUser}, func(ctx context.Context, api *tg.Client) error { m, err := a.manager(api) diff --git a/cmd/tg/reply.go b/cmd/tg/reply.go index 75075d4..ad51efd 100644 --- a/cmd/tg/reply.go +++ b/cmd/tg/reply.go @@ -2,7 +2,6 @@ package main import ( "context" - "strconv" "github.com/go-faster/errors" "github.com/spf13/cobra" @@ -12,32 +11,42 @@ import ( ) func (a *app) newReplyCmd() *cobra.Command { - var msg messageOptions + var ( + fromStdin bool + msg messageOptions + wait waitOptions + ) cmd := &cobra.Command{ - Use: "reply ", + Use: "reply [text]", Short: "Reply to a specific message", GroupID: groupMessaging, Long: `Send a reply to a specific message in a peer's history. The peer is -me/self, @username, phone, or a t.me link.`, +me/self, @username, phone, or a t.me link. The text may come from --stdin. +With --wait the peer's response is collected, as for ` + "`tg send`.", Example: ` tg reply @durov 12345 "great post" - tg reply me 1000 "note to self"`, - Args: cobra.ExactArgs(3), + tg reply me 1000 "note to self" + tg reply @mybot 4242 "Berlin" --wait 20s -o json`, + Args: cobra.RangeArgs(2, 3), ValidArgsFunction: peerArgCompletion, RunE: func(cmd *cobra.Command, args []string) error { peer := args[0] - replyTo, err := strconv.Atoi(args[1]) + replyTo, err := parseMessageID(args[1]) if err != nil { - return errors.Wrap(err, "message-id must be an integer") + return err + } + text, err := messageText(args[2:], fromStdin, cmd.InOrStdin()) + if err != nil { + return err } - text := args[2] - return a.run(cmd.Context(), runParams{auth: authUser}, func(ctx context.Context, api *tg.Client) error { + collector := newReplyCollector() + return a.run(cmd.Context(), wait.runParams(collector), func(ctx context.Context, api *tg.Client) error { sender, m, err := a.sender(api) if err != nil { return err } - bf, err := builderFor(ctx, m, sender, peer) + bf, err := targetFor(ctx, m, sender, peer, collector, wait) if err != nil { return err } @@ -47,12 +56,19 @@ me/self, @username, phone, or a t.me link.`, if err != nil { return errors.Wrap(err, "reply") } - return a.printer.Emit(sentResult{Peer: peer, MessageID: id}) + res := sentResult{Peer: peer, MessageID: id} + if res.conversation, err = awaitResponse(ctx, collector, wait); err != nil { + return err + } + return a.printer.Emit(res) }) }, } - msg.register(cmd.Flags()) + fs := cmd.Flags() + fs.BoolVar(&fromStdin, "stdin", false, "read the reply text from standard input") + msg.register(fs) + wait.register(fs) return cmd } diff --git a/cmd/tg/root.go b/cmd/tg/root.go index d214682..7031358 100644 --- a/cmd/tg/root.go +++ b/cmd/tg/root.go @@ -45,7 +45,15 @@ func normalizeFlag(_ *pflag.FlagSet, name string) pflag.NormalizedName { return pflag.NormalizedName(name) } +// newRootCmd builds the root command. func newRootCmd() *cobra.Command { + root, _ := newRoot() + return root +} + +// newRoot builds the root command and returns the app state behind it, which +// execute needs to report errors in the selected output format. +func newRoot() (*cobra.Command, *app) { a := newApp() root := &cobra.Command{ @@ -82,6 +90,8 @@ below apply to every command.`, } return append(cfg.labels(), "all"), cobra.ShellCompDirectiveNoFileComp }) + pf.DurationVar(&a.timeout, "timeout", envDuration("TG_TIMEOUT"), + "abort the command after this long, exiting with code 6 (0 = no limit)") pf.BoolVar(&a.debugInvoker, "debug-invoker", false, "use pretty-printing debug invoker") _ = root.RegisterFlagCompletionFunc("output", cobra.FixedCompletions(output.Formats(), cobra.ShellCompDirectiveNoFileComp)) @@ -123,6 +133,9 @@ below apply to every command.`, a.newScheduleCmd(), a.newPollCmd(), a.newReadCmd(), + a.newClickCmd(), + a.newStartBotCmd(), + a.newBotInfoCmd(), a.newUploadCmd(), a.newAlbumCmd(), a.newStickersCmd(), @@ -160,7 +173,10 @@ below apply to every command.`, a.newProfileCmd(), a.newFoldersCmd(), ) - root.AddCommand(newDocsCmd(root)) + root.AddCommand(newDocsCmd(root), newVersionCmd(a)) + root.Version = buildVersion().String() + root.SetVersionTemplate("tg {{.Version}}\n") + markUsageErrors(root) - return root + return root, a } diff --git a/cmd/tg/send.go b/cmd/tg/send.go index a0b3bc6..89c8cfc 100644 --- a/cmd/tg/send.go +++ b/cmd/tg/send.go @@ -12,22 +12,28 @@ import ( "github.com/gotd/td/tg" ) -// sentResult is the result of a command that sends a message. +// sentResult is the result of a command that sends a message. With --wait it +// also carries the peer's response. type sentResult struct { Peer string `json:"peer,omitempty"` MessageID int `json:"message_id"` + *conversation } -// MarshalText renders a short acknowledgement. +// MarshalText renders a short acknowledgement and any collected response. func (r sentResult) MarshalText(w io.Writer) error { - _, err := fmt.Fprintf(w, "sent message #%d\n", r.MessageID) - return err + if _, err := fmt.Fprintf(w, "sent message #%d\n", r.MessageID); err != nil { + return err + } + return r.conversation.marshalText(w) } func (a *app) newSendCmd() *cobra.Command { var ( - peer string - msg messageOptions + peer string + fromStdin bool + msg messageOptions + wait waitOptions ) cmd := &cobra.Command{ @@ -39,7 +45,11 @@ Saved Messages, which is handy for notes and agent self-messaging. A peer with no username or phone can be addressed by its numeric id with the "id:" prefix (the "id" field in ` + "`tg chats list --output json`" + `), provided -it has been cached first by running ` + "`tg chats list`" + `.`, +it has been cached first by running ` + "`tg chats list`" + `. + +With --wait, tg listens for the peer's response before sending and returns the +replies (and edits) it collected: it stops when the peer has been quiet for +--settle after responding, when --replies messages arrived, or at --wait.`, Example: ` # Message yourself (Saved Messages) tg send "hello" @@ -50,20 +60,27 @@ it has been cached first by running ` + "`tg chats list`" + `.`, tg chats list >/dev/null && tg send --peer id:2201861038 "hi" # HTML formatting, sent silently - tg send --peer @durov --html --silent "bold"`, + tg send --peer @durov --html --silent "bold" + + # Test a bot: send a command and collect its replies until it goes quiet + tg send --peer @mybot /start --wait 30s -o json + + # Keep a secret out of the process list + printf '%s' "$CODE" | tg send --peer @mybot --stdin --wait 20s`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - var text string - if len(args) > 0 { - text = args[0] + text, err := messageText(args, fromStdin, cmd.InOrStdin()) + if err != nil { + return err } - return a.run(cmd.Context(), runParams{auth: authUser}, func(ctx context.Context, api *tg.Client) error { + collector := newReplyCollector() + return a.run(cmd.Context(), wait.runParams(collector), func(ctx context.Context, api *tg.Client) error { sender, m, err := a.sender(api) if err != nil { return err } - bf, err := builderFor(ctx, m, sender, peer) + bf, err := targetFor(ctx, m, sender, peer, collector, wait) if err != nil { return err } @@ -73,7 +90,11 @@ it has been cached first by running ` + "`tg chats list`" + `.`, if err != nil { return errors.Wrap(err, "send") } - return a.printer.Emit(sentResult{Peer: peer, MessageID: id}) + res := sentResult{Peer: peer, MessageID: id} + if res.conversation, err = awaitResponse(ctx, collector, wait); err != nil { + return err + } + return a.printer.Emit(res) }) }, } @@ -81,7 +102,9 @@ it has been cached first by running ` + "`tg chats list`" + `.`, fs := cmd.Flags() fs.StringVarP(&peer, "peer", "p", "", "peer to write (username, phone, deep link, or id:); default: yourself") + fs.BoolVar(&fromStdin, "stdin", false, "read the message text from standard input") msg.register(fs) + wait.register(fs) registerPeerCompletion(cmd, "peer") return cmd diff --git a/cmd/tg/version.go b/cmd/tg/version.go new file mode 100644 index 0000000..e793c0e --- /dev/null +++ b/cmd/tg/version.go @@ -0,0 +1,92 @@ +package main + +import ( + "fmt" + "io" + "runtime/debug" + "strings" + + "github.com/spf13/cobra" +) + +// versionInfo describes the running binary, read from the build info Go embeds. +type versionInfo struct { + Version string `json:"version"` + Commit string `json:"commit,omitempty"` + CommitTime string `json:"commit_time,omitempty"` + Modified bool `json:"modified,omitempty"` + Go string `json:"go"` + // BuiltinCredentials reports whether app credentials were embedded at + // build time (their values are never printed). + BuiltinCredentials bool `json:"builtin_credentials"` +} + +func buildVersion() versionInfo { + v := versionInfo{ + Version: "(devel)", + BuiltinCredentials: builtinAppID != "" && builtinAppHash != "", + } + bi, ok := debug.ReadBuildInfo() + if !ok { + return v + } + v.Go = bi.GoVersion + if bi.Main.Version != "" { + v.Version = bi.Main.Version + } + for _, s := range bi.Settings { + switch s.Key { + case "vcs.revision": + v.Commit = s.Value + case "vcs.time": + v.CommitTime = s.Value + case "vcs.modified": + v.Modified = s.Value == "true" + } + } + return v +} + +// String renders a one-line version, e.g. "v0.12.0 (4bbb4ac, go1.25.11)". +func (v versionInfo) String() string { + details := make([]string, 0, 2) + if v.Commit != "" { + c := v.Commit + if len(c) > 12 { + c = c[:12] + } + if v.Modified { + c += "-dirty" + } + details = append(details, c) + } + if v.Go != "" { + details = append(details, v.Go) + } + if len(details) == 0 { + return v.Version + } + return v.Version + " (" + strings.Join(details, ", ") + ")" +} + +// MarshalText renders the version line. +func (v versionInfo) MarshalText(w io.Writer) error { + _, err := fmt.Fprintln(w, "tg", v.String()) + return err +} + +func newVersionCmd(a *app) *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print version and build information", + GroupID: groupAuth, + Long: `Print the version, commit and Go toolchain of this binary, and whether app +credentials were embedded at build time. Use it to confirm which build an agent +or script is running.`, + Example: " tg version\n tg version -o json", + Args: cobra.NoArgs, + RunE: func(*cobra.Command, []string) error { + return a.printer.Emit(buildVersion()) + }, + } +} diff --git a/cmd/tg/wait.go b/cmd/tg/wait.go index 2d962f4..3039210 100644 --- a/cmd/tg/wait.go +++ b/cmd/tg/wait.go @@ -24,8 +24,17 @@ func (a *app) newWaitCmd() *cobra.Command { Args: cobra.MaximumNArgs(1), ValidArgsFunction: peerArgCompletion, RunE: func(cmd *cobra.Command, args []string) error { - return a.connect(cmd.Context(), runParams{auth: authUser, updates: true}, - func(ctx context.Context, client *telegram.Client, d tg.UpdateDispatcher) error { + events := make(chan watchEvent, 1) + stream := &messageStream{ + onEvent: func(ev watchEvent) { + select { + case events <- ev: + default: + } + }, + } + return a.connect(cmd.Context(), runParams{auth: authUser, updates: true, register: stream.register}, + func(ctx context.Context, client *telegram.Client, _ tg.UpdateDispatcher) error { if err := requireAuth(ctx, client); err != nil { return err } @@ -33,18 +42,7 @@ func (a *app) newWaitCmd() *cobra.Command { if err != nil { return err } - - events := make(chan watchEvent, 1) - stream := &messageStream{ - filterID: filterID, - onEvent: func(ev watchEvent) { - select { - case events <- ev: - default: - } - }, - } - stream.register(d) + stream.arm(filterID) var timer <-chan time.Time if timeout > 0 { @@ -57,7 +55,7 @@ func (a *app) newWaitCmd() *cobra.Command { case <-ctx.Done(): return ctx.Err() case <-timer: - return errors.New("timeout waiting for message") + return errors.Wrapf(errWaitTimeout, "no message within %s", timeout) case ev := <-events: return a.printer.Emit(ev) } diff --git a/cmd/tg/watch.go b/cmd/tg/watch.go index 012de71..1cd0e09 100644 --- a/cmd/tg/watch.go +++ b/cmd/tg/watch.go @@ -25,11 +25,21 @@ type watchEvent struct { } // messageStream turns incoming new-message updates into watchEvents, filtered by -// an optional peer id, delivered to onEvent. Safe for concurrent dispatch. +// an optional peer id, delivered to onEvent. It is registered before the client +// connects and drops events until armed. Safe for concurrent dispatch. type messageStream struct { - filterID int64 - onEvent func(watchEvent) + onEvent func(watchEvent) + mu sync.Mutex + armed bool + filterID int64 // 0 = all peers +} + +// arm starts delivering events, limited to filterID when non-zero. +func (s *messageStream) arm(filterID int64) { + s.mu.Lock() + defer s.mu.Unlock() + s.armed, s.filterID = true, filterID } func (s *messageStream) handle(msg tg.MessageClass, e tg.Entities) { @@ -39,11 +49,11 @@ func (s *messageStream) handle(msg tg.MessageClass, e tg.Entities) { } ent := peer.EntitiesFromUpdate(e) ev := watchEvent{Peer: describePeer(m.PeerID, ent), Message: buildMessageItem(m, ent)} - if s.filterID != 0 && ev.Peer.ID != s.filterID { - return - } s.mu.Lock() defer s.mu.Unlock() + if !s.armed || (s.filterID != 0 && ev.Peer.ID != s.filterID) { + return + } s.onEvent(ev) } @@ -105,11 +115,12 @@ func emitLine(format output.Format, account string, ev watchEvent) { func (a *app) newWatchCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "watch [peer]", - Short: "Stream new messages as they arrive", - GroupID: groupMessaging, - Long: "Stream incoming messages (optionally for one peer) as JSON lines until interrupted.", - Example: " tg watch\n tg watch @durov --output json", + Use: "watch [peer]", + Short: "Stream new messages as they arrive", + GroupID: groupMessaging, + Long: `Stream incoming messages (optionally for one peer) as JSON lines until +interrupted. With the global --timeout the stream ends normally after that long.`, + Example: " tg watch\n tg watch @durov --output json\n tg watch @mybot -o json --timeout 2m", Args: cobra.MaximumNArgs(1), ValidArgsFunction: peerArgCompletion, RunE: func(cmd *cobra.Command, args []string) error { @@ -160,8 +171,17 @@ func (a *app) watchAll(ctx context.Context, labels, args []string) error { // serializes stdout across concurrent accounts. func (a *app) watchWith(ctx context.Context, st *accountState, header string, args []string, mu *sync.Mutex) error { format := a.printer.Format() - return a.connectWith(ctx, st, runParams{auth: authUser, updates: true}, - func(ctx context.Context, client *telegram.Client, d tg.UpdateDispatcher) error { + stream := &messageStream{ + onEvent: func(ev watchEvent) { + if mu != nil { + mu.Lock() + defer mu.Unlock() + } + emitLine(format, header, ev) + }, + } + err := a.connectWith(ctx, st, runParams{auth: authUser, updates: true, register: stream.register}, + func(ctx context.Context, client *telegram.Client, _ tg.UpdateDispatcher) error { if err := requireAuth(ctx, client); err != nil { return err } @@ -169,23 +189,17 @@ func (a *app) watchWith(ctx context.Context, st *accountState, header string, ar if err != nil { return err } - - stream := &messageStream{ - filterID: filterID, - onEvent: func(ev watchEvent) { - if mu != nil { - mu.Lock() - defer mu.Unlock() - } - emitLine(format, header, ev) - }, - } - stream.register(d) + stream.arm(filterID) _, _ = fmt.Fprintf(os.Stderr, "Watching %s for new messages (Ctrl-C to stop)…\n", st.label) <-ctx.Done() return nil }) + // A watch bounded by --timeout ends normally at the deadline. + if errors.Is(err, context.DeadlineExceeded) { + return nil + } + return err } // requireAuth returns errNotAuthorized if the session is not logged in. diff --git a/internal/output/output.go b/internal/output/output.go index d2f269d..440465e 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -73,6 +73,28 @@ type envelope struct { Data any `json:"data"` } +// errorEnvelope is the JSON shape of a failed command: the same envelope with +// "error" in place of "data", so a parser can tell the two apart by key. +type errorEnvelope struct { + Schema int `json:"schema"` + Account string `json:"account,omitempty"` + Error any `json:"error"` +} + +// EmitError writes a failure description in JSON mode. In text mode it writes +// nothing: errors are reported on stderr by the caller. +func (p *Printer) EmitError(v any) error { + if p.format != JSON { + return nil + } + enc := json.NewEncoder(p.w) + enc.SetIndent("", " ") + if err := enc.Encode(errorEnvelope{Schema: schemaVersion, Account: p.account, Error: v}); err != nil { + return errors.Wrap(err, "encode json") + } + return nil +} + // Emit writes a single result value. func (p *Printer) Emit(v any) error { if p.format == JSON { diff --git a/internal/output/output_test.go b/internal/output/output_test.go index ab5824c..e53ba0e 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -66,3 +66,22 @@ func TestPrinterTextUsesMarshaler(t *testing.T) { t.Errorf("text = %q, want %q", got, "name=durov") } } + +func TestPrinterEmitError(t *testing.T) { + var buf bytes.Buffer + if err := New(JSON, &buf).EmitError(map[string]string{"code": "auth"}); err != nil { + t.Fatal(err) + } + got := buf.String() + if !strings.Contains(got, `"schema": 1`) || !strings.Contains(got, `"error": {`) || strings.Contains(got, `"data"`) { + t.Errorf("unexpected error envelope: %s", got) + } + + buf.Reset() + if err := New(Text, &buf).EmitError(map[string]string{"code": "auth"}); err != nil { + t.Fatal(err) + } + if buf.Len() != 0 { + t.Errorf("text mode should write nothing, got %q", buf.String()) + } +} From 4bf1debb0954e4da1d34557aa6cac7b54fa15515 Mon Sep 17 00:00:00 2001 From: Logan Pratt Date: Sun, 18 Oct 2026 01:54:36 -0400 Subject: [PATCH 03/19] fix(watch,wait): accept me/self and id: peer filters watch and wait passed their peer argument straight to the username resolver, so `tg wait me` failed with USERNAME_INVALID and id: filters never worked. A shared resolveManagedPeer handles me/self, id: and the usual username/phone/link forms; bot-info uses it too, so `bot-info me` reports "not a bot" instead of a lookup failure. Co-Authored-By: Claude Opus 5.5 --- cmd/tg/bot.go | 2 +- cmd/tg/peers.go | 16 ++++++++++++++++ cmd/tg/peers_test.go | 34 ++++++++++++++++++++++++++++++++++ cmd/tg/watch.go | 2 +- 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/cmd/tg/bot.go b/cmd/tg/bot.go index 15ab8b5..bc76f8f 100644 --- a/cmd/tg/bot.go +++ b/cmd/tg/bot.go @@ -102,7 +102,7 @@ func newBotInfoResult(ref peerRef, u *tg.User, full *tg.UserFull) botInfoResult // resolveBot resolves arg and checks that it is a bot. func resolveBot(ctx context.Context, m *peerManager, arg string) (peers.User, error) { - p, err := resolvePeerArg(ctx, m, arg) + p, err := resolveManagedPeer(ctx, m, arg) if err != nil { return peers.User{}, err } diff --git a/cmd/tg/peers.go b/cmd/tg/peers.go index 6659211..4152b63 100644 --- a/cmd/tg/peers.go +++ b/cmd/tg/peers.go @@ -150,6 +150,22 @@ func builderFor(ctx context.Context, m *peerManager, sender *message.Sender, pee return sender.Resolve(peer), nil } +// resolveManagedPeer resolves any argument — me/self, id:, +// @username, phone or link — to a typed peer. +func resolveManagedPeer(ctx context.Context, m *peerManager, arg string) (peers.Peer, error) { + if isSelf(arg) { + self, err := m.Self(ctx) + if err != nil { + return nil, errors.Wrap(err, "resolve self") + } + return self, nil + } + if isIDArg(arg) { + return m.resolveID(ctx, arg) + } + return m.Resolve(ctx, arg) +} + // resolvePeer turns a peer string into an InputPeer using the cached manager. // The empty string, "me" and "self" resolve to the current account; an "id:" // argument resolves a cached peer by numeric id. diff --git a/cmd/tg/peers_test.go b/cmd/tg/peers_test.go index e5c3007..a6d42c6 100644 --- a/cmd/tg/peers_test.go +++ b/cmd/tg/peers_test.go @@ -5,6 +5,9 @@ import ( "path/filepath" "testing" + "github.com/go-faster/errors" + + "github.com/gotd/td/bin" "github.com/gotd/td/telegram/message" "github.com/gotd/td/telegram/peers" "github.com/gotd/td/tg" @@ -139,3 +142,34 @@ func TestBuilderForSelf(t *testing.T) { } } } + +func TestResolveManagedPeerSelf(t *testing.T) { + // Regression: watch/wait/bot-info passed "me" to the username resolver, + // which failed with USERNAME_INVALID. + store, err := peercache.Open(filepath.Join(t.TempDir(), "peers.json")) + if err != nil { + t.Fatal(err) + } + api := newFuncAPI(t, func(req bin.Encoder) (bin.Encoder, error) { + if _, ok := req.(*tg.UsersGetUsersRequest); !ok { + return nil, errors.Errorf("unexpected request %T", req) + } + return &tg.UserClassVector{Elems: []tg.UserClass{&tg.User{ID: 42, Self: true, AccessHash: 1}}}, nil + }) + m := &peerManager{Manager: peers.Options{Storage: store}.Build(api), store: store} + + for _, arg := range []string{"me", "self", ""} { + p, err := resolveManagedPeer(context.Background(), m, arg) + if err != nil || p.ID() != 42 { + t.Errorf("resolveManagedPeer(%q) = %v, %v; want self (42)", arg, p, err) + } + } + if _, err := resolveBot(context.Background(), m, "me"); err == nil { + t.Error("resolveBot(me) should fail: the account is not a bot") + } else { + var u usageError + if !errors.As(err, &u) { + t.Errorf("resolveBot(me) err = %v, want usage error", err) + } + } +} diff --git a/cmd/tg/watch.go b/cmd/tg/watch.go index 1cd0e09..fdeaaa1 100644 --- a/cmd/tg/watch.go +++ b/cmd/tg/watch.go @@ -79,7 +79,7 @@ func (a *app) resolveFilterFor(ctx context.Context, api *tg.Client, st *accountS if err != nil { return 0, err } - p, err := m.Resolve(ctx, args[0]) + p, err := resolveManagedPeer(ctx, m, args[0]) if err != nil { return 0, errors.Wrapf(err, "resolve %q", args[0]) } From 84dedf7868e9fae0cfdea6eb29cf350bc50b2dd1 Mon Sep 17 00:00:00 2001 From: Logan Pratt Date: Sun, 18 Oct 2026 01:54:36 -0400 Subject: [PATCH 04/19] feat(upload): --wait for bot media tests; fix panic on a missing path The walk callback called info.IsDir() before checking the walk error, so `tg upload ./missing` dereferenced a nil FileInfo and panicked. The path is now checked before connecting, and the walk lives in a tested helper. upload gains --wait/--settle/--replies, so a bot's handling of documents, photos and voice notes can be verified in one call. Co-Authored-By: Claude Opus 5.5 --- cmd/tg/click_test.go | 11 +++++---- cmd/tg/download_test.go | 31 +++++++++++++++++++++++++ cmd/tg/upload.go | 50 +++++++++++++++++++++++++++++++---------- 3 files changed, 76 insertions(+), 16 deletions(-) diff --git a/cmd/tg/click_test.go b/cmd/tg/click_test.go index 6928879..ccf5f2a 100644 --- a/cmd/tg/click_test.go +++ b/cmd/tg/click_test.go @@ -207,10 +207,13 @@ func TestMessageText(t *testing.T) { t.Errorf("multi-line stdin: %q, %v", got, err) } for name, run := range map[string]func() error{ - "missing": func() error { _, err := messageText(nil, false, nil); return err }, - "both": func() error { _, err := messageText([]string{"x"}, true, strings.NewReader("y")); return err }, - "empty": func() error { _, err := messageText(nil, true, strings.NewReader("\n")); return err }, - "oversized": func() error { _, err := messageText(nil, true, strings.NewReader(strings.Repeat("a", maxStdinText+1))); return err }, + "missing": func() error { _, err := messageText(nil, false, nil); return err }, + "both": func() error { _, err := messageText([]string{"x"}, true, strings.NewReader("y")); return err }, + "empty": func() error { _, err := messageText(nil, true, strings.NewReader("\n")); return err }, + "oversized": func() error { + _, err := messageText(nil, true, strings.NewReader(strings.Repeat("a", maxStdinText+1))) + return err + }, "empty argv": func() error { _, err := messageText([]string{""}, false, nil); return err }, } { var u usageError diff --git a/cmd/tg/download_test.go b/cmd/tg/download_test.go index 5fa49ae..9f06689 100644 --- a/cmd/tg/download_test.go +++ b/cmd/tg/download_test.go @@ -1,7 +1,10 @@ package main import ( + "io/fs" + "os" "path/filepath" + "strings" "testing" "github.com/gotd/td/tg" @@ -82,3 +85,31 @@ func TestResolveOutPath(t *testing.T) { t.Errorf("dir join = %q", got) } } + +func TestWalkFiles(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"a.txt", "sub/b.txt"} { + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } + var got []string + if err := walkFiles(dir, func(path string, _ fs.FileInfo) error { + got = append(got, filepath.Base(path)) + return nil + }); err != nil { + t.Fatal(err) + } + if strings.Join(got, ",") != "a.txt,b.txt" { + t.Errorf("visited %v", got) + } + + // Regression: a missing path used to panic on a nil FileInfo. + if err := walkFiles(filepath.Join(dir, "missing"), func(string, fs.FileInfo) error { return nil }); err == nil { + t.Error("expected an error for a missing path") + } +} diff --git a/cmd/tg/upload.go b/cmd/tg/upload.go index 0436674..c1b4ae9 100644 --- a/cmd/tg/upload.go +++ b/cmd/tg/upload.go @@ -35,6 +35,7 @@ type uploadFlags struct { docType *enumValue threads int msg messageOptions + wait waitOptions } // uploadedFile is one uploaded file in an uploadResult. @@ -43,19 +44,21 @@ type uploadedFile struct { MessageID int `json:"message_id"` } -// uploadResult is the result of `tg upload`. +// uploadResult is the result of `tg upload`. With --wait it also carries the +// peer's response. type uploadResult struct { Files []uploadedFile `json:"files"` + *conversation } -// MarshalText renders one uploaded file per line. +// MarshalText renders one uploaded file per line, then any collected response. func (r uploadResult) MarshalText(w io.Writer) error { for _, f := range r.Files { if _, err := fmt.Fprintf(w, "uploaded %s as message #%d\n", f.Path, f.MessageID); err != nil { return err } } - return nil + return r.conversation.marshalText(w) } // documentTypes are the allowed values of the --type flag. @@ -224,6 +227,20 @@ func (a *app) uploadOne( return msgID, nil } +// walkFiles calls fn for each regular file under root, or for root itself when +// it is a file. +func walkFiles(root string, fn func(path string, info fs.FileInfo) error) error { + return filepath.Walk(root, func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err // info is nil here + } + if info.IsDir() { + return nil + } + return fn(path, info) + }) +} + func (a *app) newUploadCmd() *cobra.Command { uf := &uploadFlags{docType: newEnumValue("", documentTypes()...)} @@ -235,7 +252,8 @@ func (a *app) newUploadCmd() *cobra.Command { Long: `Upload a file to a peer. If is a directory, every file under it is uploaded. With no --peer, files go to your own Saved Messages. -The document type is detected from the file's MIME type unless --type is set.`, +The document type is detected from the file's MIME type unless --type is set. +With --wait the peer's response to the upload is collected, as for ` + "`tg send`.", Example: ` # Upload a file to Saved Messages tg upload ./report.pdf @@ -243,12 +261,20 @@ The document type is detected from the file's MIME type unless --type is set.`, tg upload --peer @me --type video clip.mp4 # Upload with a caption - tg upload --peer @durov --message "here you go" photo.jpg`, + tg upload --peer @durov --message "here you go" photo.jpg + + # Test a bot's media handling: send a file and collect its replies + tg upload --peer @mybot ./invoice.pdf --wait 30s -o json`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { arg := args[0] + // Fail before connecting when there is nothing to upload. + if _, err := os.Stat(arg); err != nil { + return errors.Wrap(err, "upload") + } - return a.run(cmd.Context(), runParams{auth: authUser}, func(ctx context.Context, api *tg.Client) error { + collector := newReplyCollector() + return a.run(cmd.Context(), uf.wait.runParams(collector), func(ctx context.Context, api *tg.Client) error { upld := uploader.NewUploader(api). WithThreads(uf.threads). WithPartSize(uploader.MaximumPartSize) @@ -258,18 +284,14 @@ The document type is detected from the file's MIME type unless --type is set.`, } sender = sender.WithUploader(upld) - builder, err := builderFor(ctx, m, sender, uf.peer) + builder, err := targetFor(ctx, m, sender, uf.peer, collector, uf.wait) if err != nil { return err } quiet := a.printer.Format() == output.JSON var result uploadResult - if err := filepath.Walk(arg, func(path string, info fs.FileInfo, err error) error { - // Stop if got error, skip if current file is directory. - if info.IsDir() || err != nil { - return err - } + if err := walkFiles(arg, func(path string, info fs.FileInfo) error { id, err := a.uploadOne(ctx, uf, upld, builder, path, info, quiet) if err != nil { return err @@ -279,6 +301,9 @@ The document type is detected from the file's MIME type unless --type is set.`, }); err != nil { return err } + if result.conversation, err = awaitResponse(ctx, collector, uf.wait); err != nil { + return err + } return a.printer.Emit(result) }) }, @@ -293,6 +318,7 @@ The document type is detected from the file's MIME type unless --type is set.`, flags.Var(uf.docType, "type", "type of uploaded document (default: detect from MIME)") flags.IntVarP(&uf.threads, "threads", "j", 1, "concurrency limit") uf.msg.register(flags) + uf.wait.register(flags) registerPeerCompletion(cmd, "peer") registerEnumCompletion(cmd, "type", documentTypes()) From b31f929715c5dd0c4cf12283ee6b1d5c00a39194 Mon Sep 17 00:00:00 2001 From: Logan Pratt Date: Sun, 18 Oct 2026 01:54:37 -0400 Subject: [PATCH 05/19] docs: bot-testing playbook, exit codes, local install script - README: fork notes, Testing bots, Exit codes, --timeout, source install. - skills/tg: bot-testing loop for agents (bot-info, --wait, click, start-bot, media), result shapes, exit-code branching, rate-limit etiquette. - CLAUDE.md: error classification, collector registration rule, JSON compatibility rule, atomic shared files, local toolchain paths. - scripts/install-local.sh: build with -trimpath, embed app credentials from APP_ID/APP_HASH or an existing binary's build info (values never printed), keep versioned builds and switch /bin/tg atomically. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 28 +++++++++++- README.md | 87 ++++++++++++++++++++++++++++++++++-- scripts/install-local.sh | 96 ++++++++++++++++++++++++++++++++++++++++ skills/tg/SKILL.md | 73 +++++++++++++++++++++++++++++- 4 files changed, 277 insertions(+), 7 deletions(-) create mode 100755 scripts/install-local.sh diff --git a/CLAUDE.md b/CLAUDE.md index ca766b5..05600c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,13 @@ gofmt -l . # list misformatted files; gofmt -w to fix go mod tidy # CI fails if this produces a diff ``` -Go 1.25. Releases are cut by pushing a `v*` tag, which triggers GoReleaser +Go 1.25. On the maintainer's workstation Go is not on PATH: use +`export PATH="$HOME/.local/opt/go-1.27.2/bin:$PATH"` and +`~/.local/opt/golangci-lint-2.12.1/golangci-lint`. `scripts/install-local.sh` builds and installs +`~/.local/bin/tg` with embedded app credentials (see its header). The first build of `gotd/td` +takes a long time on this laptop; later builds are cached. + +Releases are cut by pushing a `v*` tag, which triggers GoReleaser (`.github/workflows/release.yml`); the release build embeds app credentials via `-ldflags` and fails if the `APP_ID`/`APP_HASH` secrets are missing. @@ -89,6 +95,26 @@ A `` argument accepts `me`/`self`, `@username`, a phone number, a `t.me/ only after the peer has been cached). For sending, `builderFor(sender, peer)` turns a peer string into a `message.RequestBuilder` (the sender's resolver also understands `id:`). +**Errors and exit codes** (`exitcode.go`). `main` calls `execute`, which maps the returned +error to an exit code and, with `-o json`, also writes `{"schema":1,"error":{...}}` to stdout. +Classification is by type, not text: return `usageError{err}` for bad invocations, +`notFoundf(...)` for a missing peer/message/button, wrap `errWaitTimeout` when giving up waiting, +and let Telegram RPC errors (`tgerr`) and context errors propagate wrapped. Use +`parseMessageID` for `` arguments. Exit codes are part of the contract. + +**Bot-testing primitives.** `conversation.go`: a `replyCollector` registered via +`runParams.register` (handlers must be attached before the client connects — the dispatcher map +is not safe for concurrent registration) collects a peer's replies and edits; commands opt in with +`waitOptions` (`--wait/--settle/--replies`), `targetFor` (arms the collector before sending) and +`awaitResponse`. `markup.go` models reply markup and button selection; `click.go` presses buttons; +`bot.go` has `bot-info` and `start-bot`. + +**JSON compatibility.** Downstream scripts parse `history`/`send` output. Only add fields, always +with `omitempty` (see `TestMessageItemJSONCompat`); never rename or remove one. + +**Files shared between processes** (sessions, peer cache, config) are written through +`internal/atomicfile` so concurrent `tg` invocations never read a torn file. + **Testing convention.** Keep network code thin and factor the logic into pure functions that are unit-tested directly (e.g. `inviteLinkFromFull`, `newDevicesResult`). For code that must issue RPCs, use the mock invoker helpers in tests: `newTestAPI(t)` (tgmock, `Expect()`-based) diff --git a/README.md b/README.md index d45d008..b2b9dda 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,30 @@ groups and channels, and stream new messages in real time. Built on [`gotd/td`](https://github.com/gotd/td). +> **This is the ApexAiOfficial fork of [`gotd/cli`](https://github.com/gotd/cli).** +> It stays command- and output-compatible with upstream and adds what an agent +> needs to test Telegram bots end to end: pressing inline and keyboard buttons, +> collecting a bot's replies and edits in one call, deep-link starts, bot +> introspection, richer message JSON (buttons, entities, file details), stable +> exit codes with JSON errors, a global `--timeout`, and crash-safe session +> storage shared by concurrent commands. See [Testing bots](#testing-bots). + ## Installation Download a prebuilt binary or package (`.deb`/`.rpm`/`.apk`) for your platform from -the [latest release](https://github.com/gotd/cli/releases/latest). +the [latest upstream release](https://github.com/gotd/cli/releases/latest). + +To build this fork from source and install it as `~/.local/bin/tg`: + +```console +$ scripts/install-local.sh # reuses the app credentials of the installed tg +$ APP_ID=… APP_HASH=… scripts/install-local.sh # or embed your own +$ scripts/install-local.sh --creds-from /path/to/old/tg +``` + +Sessions are tied to the app credentials they were created with, so replacing a +release binary with a source build should embed the same credentials: +`--creds-from` reads them from an existing binary's build info. ## Quick start @@ -52,6 +72,9 @@ Bot login still works too: provide a token via `tg init --token ` and - **Consistent peers:** every `` accepts `me`/`self`, `@username`, a phone number, or a `t.me/…` link. Resolved access-hashes are cached locally. - **Safety:** destructive actions (`delete`, `delete-history`, `unpin-all`) require `--yes`. +- **Stable exit codes and JSON errors** (see [Exit codes](#exit-codes)), and a global + `--timeout` so no call can hang an agent. +- **Secrets off the command line:** `send`, `reply` and `edit` take `--stdin`. - **Shell completion:** `tg completion bash|zsh|fish|powershell`, with dynamic completion for peers, accounts, output format and enum flags. @@ -64,13 +87,14 @@ $ tg history @durov --limit 20 -o json This repo ships an installable [Claude Code](https://claude.com/claude-code) skill (`skills/tg`) that teaches an agent how to drive Telegram with `tg` — setup checks, -the JSON/peers conventions, the `--yes` safety gate, and common recipes. Install it +the JSON/peers conventions, the `--yes` safety gate, exit codes, common recipes, +and a bot-testing playbook. Install it with [`skills`](https://www.npmjs.com/package/skills): ```console -$ npx skills add https://github.com/gotd/cli --all +$ npx skills add https://github.com/ApexAiOfficial/tg-cli --all # or a single skill: -$ npx skills add https://github.com/gotd/cli --skill tg +$ npx skills add https://github.com/ApexAiOfficial/tg-cli --skill tg ``` ## What you can do @@ -80,6 +104,8 @@ Run `tg --help` for the full, grouped command list, or `tg --help` for - **Messaging:** `send`, `reply`, `edit`, `delete`, `delete-history`, `forward`, `history`, `search` (`--global`), `read`, `pin`/`unpin`/`unpin-all`/`pinned`, `react`/`unreact`/`reactions`, `draft`/`drafts`, `schedule`, `poll`, `link`, `context`. +- **Bots:** `click` (press inline/keyboard buttons), `start-bot` (deep-link start), + `bot-info`, and `--wait` reply collection on `send`/`reply`/`click`/`start-bot`/`upload`. - **Media:** `upload` (with `--type` video/audio/voice/gif/sticker), `album`, `download`, `stickers`. - **Chats & contacts:** `chats list`, `chat get`/`full`, `mute`/`unmute`, @@ -94,6 +120,58 @@ Run `tg --help` for the full, grouped command list, or `tg --help` for - **Realtime:** `watch [peer]` streams new messages as JSON lines; `wait [peer]` blocks until the next message (with `--timeout`). +## Testing bots + +`tg` acts as a real user, so an agent (or a test script) can drive any bot the way +a person would and check what actually happens: + +```console +$ tg bot-info @mybot # descriptions, command menu, menu button +$ tg send --peer @mybot /start --wait 30s -o json # send, then collect replies until the bot settles +$ tg start-bot @mybot ref_42 --wait 20s # deep link: t.me/mybot?start=ref_42 +$ tg click @mybot --text "Settings" --wait 15s -o json # press an inline button, get the answer + edits +$ tg click @mybot 4242 --data "page:2" # by callback data, on a given message +$ tg reply @mybot 4250 "Berlin" --wait 20s # answer a prompt +$ tg upload --peer @mybot ./sample.pdf --wait 30s # media handling +$ tg history @mybot --after 4242 -o json # everything newer than a message +``` + +`--wait ` (on `send`, `reply`, `click`, `start-bot`, `upload`) subscribes to +updates *before* acting, so a fast reply is never missed, and returns the bot's +new messages (`replies`, in their latest version: bots that stream by editing are +handled) and the other messages it edited (`edits`, e.g. the message whose button +was pressed). It stops when the bot has been quiet for `--settle` (default 2s, +extended while the bot shows "typing…"), when `--replies N` messages arrived, or +when `--wait` elapses (`timed_out: true`). + +Message JSON includes `reply_markup` (inline buttons or reply keyboard, with each +button's `type`, `text`, callback `data`, `url`), `entities` (formatting and links, +with the covered `text`), `file` (`kind`, `name`, `mime`, `size`, `duration`, +dimensions), `edit_date` and `via_bot_id`. New fields are omitted when empty, so +existing consumers see the same shape. + +## Exit codes + +| Code | Meaning | +| --- | --- | +| 0 | success | +| 1 | other failure | +| 2 | usage: unknown command, bad flags or arguments | +| 3 | auth: not logged in or session revoked (`tg login`) | +| 4 | not found: peer, message or button | +| 5 | flood wait: rate limited beyond the retry budget | +| 6 | timeout: `--timeout`, or `wait --timeout` | +| 7 | other Telegram API error | +| 130 | interrupted | + +Errors are printed to stderr as `tg: `. With `--output json` a failure +also writes an envelope to stdout, so a parser always gets JSON: + +```json +{"schema": 1, "error": {"code": "flood_wait", "exit": 5, "message": "…", "retry_after": 42, + "rpc": {"code": 420, "type": "FLOOD_WAIT", "argument": 42}}} +``` + ## Global flags | Flag | Description | @@ -102,6 +180,7 @@ Run `tg --help` for the full, grouped command list, or `tg --help` for | `-a, --account