From bde89fc2f51f4df206d563be74993b6679eb7b3d Mon Sep 17 00:00:00 2001 From: cuiko Date: Thu, 4 Jun 2026 01:36:31 +0800 Subject: [PATCH] feat(chat): Add fine-grained admin rights to promote `tg chat admin promote` granted a fixed broad admin set with no way to scope it. Add a --rights flag accepting admin-rights keywords (info, post, edit, delete, ban, invite, pin, add_admins, anonymous, call, topics, post/edit/delete_stories) so callers grant exactly the rights they want; omitting --rights keeps the previous broad default. promote/demote now return a RightsRow whose `granted` field surfaces the applied rights in both JSON and table output. Also scrub a stray test account name from the launchd label doc comment. --- README.md | 2 +- internal/action/chat/member.go | 28 ++++-- internal/action/chat/member_test.go | 50 ++++++++-- internal/action/chat/perms.go | 30 ++++++ internal/cli/chat/admin/admin.go | 34 +++---- internal/daemon/launchd.go | 2 +- internal/output/rights.go | 35 ++++--- internal/telegram/member.go | 136 +++++++++++++++++++++++----- 8 files changed, 242 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 356c3d8..e036431 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ client commands route through when present). | `tg chat invite list ` | List invite links. `--revoked`, `--admin `, `--limit`. | | `tg chat invite revoke ` / `delete ` | Revoke a link, or delete a revoked one. | | `tg chat ban ` / `tg chat unban ` | Ban (remove + block) or unban a member. Ban prompts unless `--yes`. | -| `tg chat admin promote ` / `tg chat admin demote ` | Grant or revoke admin rights. Promote takes `--title ` (custom admin title, ≤16 chars); omit `--title` to keep the current title, pass `--title ""` to clear it. | +| `tg chat admin promote ` / `tg chat admin demote ` | Grant or revoke admin rights. `--rights ` grants a specific set (`info,post,edit,delete,ban,invite,pin,add_admins,anonymous,call,topics,post_stories,edit_stories,delete_stories`); omit `--rights` for a broad default set. `--title ` sets a custom admin title (≤16 chars); omit `--title` to keep the current title, pass `--title ""` to clear it. | | `tg chat member set-perms ` | Set a member's permissions with `--deny`/`--allow` keywords (`send,media,stickers,bots,polls,links,invite,pin,info,topics`) and optional `--until ` (default permanent). | | `tg chat member unset-perms ` | Clear all permission restrictions on a member. | | `tg chat perms ` | Set the group's default member permissions with `--deny`/`--allow` (same keywords). | diff --git a/internal/action/chat/member.go b/internal/action/chat/member.go index 0d4f724..0cee730 100644 --- a/internal/action/chat/member.go +++ b/internal/action/chat/member.go @@ -107,8 +107,9 @@ type PromoteRequest struct { RawRef string RawUser string Demote bool - Title string // custom admin rank/title (promote only, <=16 chars) - SetTitle bool // whether --title was given; if false, keep the current rank + Title string // custom admin rank/title (promote only, <=16 chars) + SetTitle bool // whether --title was given; if false, keep the current rank + Rights []string // admin rights to grant (promote only); empty = broad default set } // PromoteQuery is the normalized payload passed to the Telegram layer. @@ -118,26 +119,33 @@ type PromoteQuery struct { Demote bool Title string SetTitle bool + Rights []string } -// PromoteFunc promotes or demotes a user and returns the affected peer. -type PromoteFunc func(context.Context, PromoteQuery) (output.PeerRef, error) +// PromoteFunc promotes or demotes a user and returns the resulting rights row. +type PromoteFunc func(context.Context, PromoteQuery) (output.RightsRow, error) // Promote validates the request and dispatches the promote/demote operation. -func Promote(ctx context.Context, req PromoteRequest, do PromoteFunc) (output.PeerRef, error) { +func Promote(ctx context.Context, req PromoteRequest, do PromoteFunc) (output.RightsRow, error) { parsed, err := ref.ParseRef(req.RawRef) if err != nil { - return output.PeerRef{}, fmt.Errorf("%w: %s", command.ErrUsage, err.Error()) + return output.RightsRow{}, fmt.Errorf("%w: %s", command.ErrUsage, err.Error()) } userRef, err := ref.ParseRef(req.RawUser) if err != nil { - return output.PeerRef{}, fmt.Errorf("%w: invalid user ref %q: %s", command.ErrUsage, req.RawUser, err.Error()) + return output.RightsRow{}, fmt.Errorf("%w: invalid user ref %q: %s", command.ErrUsage, req.RawUser, err.Error()) } if utf8.RuneCountInString(req.Title) > 16 { - return output.PeerRef{}, fmt.Errorf("%w: --title must be at most 16 characters", command.ErrUsage) + return output.RightsRow{}, fmt.Errorf("%w: --title must be at most 16 characters", command.ErrUsage) + } + if req.Demote && len(req.Rights) > 0 { + return output.RightsRow{}, fmt.Errorf("%w: --rights cannot be combined with demote", command.ErrUsage) + } + if err := validateAdminRightKeys(req.Rights); err != nil { + return output.RightsRow{}, err } if do == nil { - return output.PeerRef{}, fmt.Errorf("%w: chat promote called without promote function", command.ErrPrecondition) + return output.RightsRow{}, fmt.Errorf("%w: chat promote called without promote function", command.ErrPrecondition) } - return do(ctx, PromoteQuery{Ref: parsed, User: userRef, Demote: req.Demote, Title: req.Title, SetTitle: req.SetTitle}) + return do(ctx, PromoteQuery{Ref: parsed, User: userRef, Demote: req.Demote, Title: req.Title, SetTitle: req.SetTitle, Rights: req.Rights}) } diff --git a/internal/action/chat/member_test.go b/internal/action/chat/member_test.go index 56721fb..456fa7a 100644 --- a/internal/action/chat/member_test.go +++ b/internal/action/chat/member_test.go @@ -93,11 +93,11 @@ func TestPromote_TitlePassesThrough(t *testing.T) { RawUser: "@alice", Title: "客服", SetTitle: true, - }, func(_ context.Context, q actionchat.PromoteQuery) (output.PeerRef, error) { + }, func(_ context.Context, q actionchat.PromoteQuery) (output.RightsRow, error) { require.Equal(t, "客服", q.Title) require.True(t, q.SetTitle) require.False(t, q.Demote) - return output.PeerRef{}, nil + return output.RightsRow{}, nil }) require.NoError(t, err) } @@ -107,9 +107,9 @@ func TestPromote_RejectsLongTitle(t *testing.T) { RawRef: "@grp", RawUser: "@alice", Title: "this-title-is-way-too-long-for-telegram", - }, func(context.Context, actionchat.PromoteQuery) (output.PeerRef, error) { + }, func(context.Context, actionchat.PromoteQuery) (output.RightsRow, error) { t.Fatal("must not dispatch with an over-long title") - return output.PeerRef{}, nil + return output.RightsRow{}, nil }) require.ErrorIs(t, err, command.ErrUsage) } @@ -119,10 +119,48 @@ func TestPromote_DemoteFlagPassesThrough(t *testing.T) { RawRef: "@grp", RawUser: "@alice", Demote: true, - }, func(_ context.Context, q actionchat.PromoteQuery) (output.PeerRef, error) { + }, func(_ context.Context, q actionchat.PromoteQuery) (output.RightsRow, error) { require.Equal(t, "alice", q.User.Value) require.True(t, q.Demote) - return output.PeerRef{}, nil + return output.RightsRow{}, nil }) require.NoError(t, err) } + +func TestPromote_RightsPassThrough(t *testing.T) { + _, err := actionchat.Promote(context.Background(), actionchat.PromoteRequest{ + RawRef: "@grp", + RawUser: "@alice", + Rights: []string{"pin", "delete"}, + }, func(_ context.Context, q actionchat.PromoteQuery) (output.RightsRow, error) { + require.Equal(t, []string{"pin", "delete"}, q.Rights) + require.False(t, q.Demote) + return output.RightsRow{}, nil + }) + require.NoError(t, err) +} + +func TestPromote_RejectsUnknownRight(t *testing.T) { + _, err := actionchat.Promote(context.Background(), actionchat.PromoteRequest{ + RawRef: "@grp", + RawUser: "@alice", + Rights: []string{"pin", "bogus"}, + }, func(context.Context, actionchat.PromoteQuery) (output.RightsRow, error) { + t.Fatal("must not dispatch with an unknown right") + return output.RightsRow{}, nil + }) + require.ErrorIs(t, err, command.ErrUsage) +} + +func TestPromote_RejectsRightsWithDemote(t *testing.T) { + _, err := actionchat.Promote(context.Background(), actionchat.PromoteRequest{ + RawRef: "@grp", + RawUser: "@alice", + Demote: true, + Rights: []string{"pin"}, + }, func(context.Context, actionchat.PromoteQuery) (output.RightsRow, error) { + t.Fatal("must not dispatch --rights with demote") + return output.RightsRow{}, nil + }) + require.ErrorIs(t, err, command.ErrUsage) +} diff --git a/internal/action/chat/perms.go b/internal/action/chat/perms.go index d9f4946..f734f62 100644 --- a/internal/action/chat/perms.go +++ b/internal/action/chat/perms.go @@ -34,6 +34,36 @@ func validateRightKeys(keys []string) error { return nil } +// AdminRightKeys are the admin-rights keywords accepted by `promote --rights`. +// Each maps (in the telegram layer) to one ChatAdminRights bit. "post"/"edit" +// only apply to broadcast channels; "ban"/"topics" only apply to supergroups; +// the rest apply to both. +var AdminRightKeys = map[string]bool{ + "info": true, // change chat info + "post": true, // post messages (broadcast) + "edit": true, // edit others' messages (broadcast) + "delete": true, // delete others' messages + "ban": true, // ban/restrict users + "invite": true, // add/invite users + "pin": true, // pin messages + "add_admins": true, // promote new admins + "anonymous": true, // act anonymously + "call": true, // manage video chats + "topics": true, // manage forum topics + "post_stories": true, + "edit_stories": true, + "delete_stories": true, +} + +func validateAdminRightKeys(keys []string) error { + for _, k := range keys { + if !AdminRightKeys[k] { + return fmt.Errorf("%w: unknown admin right %q (valid: info post edit delete ban invite pin add_admins anonymous call topics post_stories edit_stories delete_stories)", command.ErrUsage, k) + } + } + return nil +} + // --------------------------------------------------------------------------- // set-perms / unset-perms (per-user) // --------------------------------------------------------------------------- diff --git a/internal/cli/chat/admin/admin.go b/internal/cli/chat/admin/admin.go index 7fd27ef..a6561af 100644 --- a/internal/cli/chat/admin/admin.go +++ b/internal/cli/chat/admin/admin.go @@ -3,7 +3,6 @@ package admin import ( "context" - "fmt" "github.com/gotd/td/telegram/peers" "github.com/gotd/td/tg" @@ -26,6 +25,7 @@ type Options struct { Demote bool Title string SetTitle bool + Rights []string Exporter output.Exporter IOStreams *ui.IOStreams Promote actionchat.PromoteFunc @@ -79,54 +79,44 @@ func adminCmd(f *runtime.Invocation, runF func(*Options) error, demote bool) *co } if !demote { cmd.Flags().StringVar(&opts.Title, "title", "", "Custom admin title/rank (<=16 chars); omit to keep the current title, pass \"\" to clear it") + cmd.Flags().StringSliceVar(&opts.Rights, "rights", nil, "Admin rights to grant: info,post,edit,delete,ban,invite,pin,add_admins,anonymous,call,topics,post_stories,edit_stories,delete_stories (default: broad set)") } command.SetMeta(cmd, command.Meta{NeedsAccount: true, NeedsClient: true}) - output.AddJSONFlags(cmd, &opts.Exporter, []string{"ref", "id", "kind", "title", "username"}) + output.AddJSONFlags(cmd, &opts.Exporter, []string{"action", "peer", "granted"}) return cmd } // Run executes the promote/demote logic. func Run(ctx context.Context, opts *Options) error { - pr, err := actionchat.Promote(ctx, actionchat.PromoteRequest{ + row, err := actionchat.Promote(ctx, actionchat.PromoteRequest{ RawRef: opts.RawRef, RawUser: opts.RawUser, Demote: opts.Demote, Title: opts.Title, SetTitle: opts.SetTitle, + Rights: opts.Rights, }, opts.Promote) if err != nil { return err } if opts.Exporter != nil { - return opts.Exporter.Write(opts.IOStreams, pr) + return opts.Exporter.Write(opts.IOStreams, row) } - verb := "promoted" - if opts.Demote { - verb = "demoted" - } - name := pr.Title - if pr.Username != "" { - name = "@" + pr.Username - } - if name == "" { - name = pr.Ref - } - _, err = fmt.Fprintf(opts.IOStreams.Out, "%s %s\n", verb, name) - return err + return output.RenderRights(opts.IOStreams, row) } func newPromoteFn(f *runtime.Invocation) actionchat.PromoteFunc { - return func(ctx context.Context, q actionchat.PromoteQuery) (output.PeerRef, error) { + return func(ctx context.Context, q actionchat.PromoteQuery) (output.RightsRow, error) { acct, err := f.Account("") if err != nil { - return output.PeerRef{}, err + return output.RightsRow{}, err } - var pr output.PeerRef + var row output.RightsRow err = f.WithPeers(ctx, acct, runtime.ClientOptsFrom(f, acct), func(ctx context.Context, api *tg.Client, _ *peers.Manager, res *peer.Resolver) error { - pr, err = telegram.SetMemberAdmin(ctx, api, res, q) + row, err = telegram.SetMemberAdmin(ctx, api, res, q) return err }) - return pr, err + return row, err } } diff --git a/internal/daemon/launchd.go b/internal/daemon/launchd.go index 74831f0..bd66451 100644 --- a/internal/daemon/launchd.go +++ b/internal/daemon/launchd.go @@ -21,7 +21,7 @@ var runLaunchctl = func(args ...string) (string, error) { } // labelPrefix is the launchd reverse-DNS prefix. The actual label -// includes the account: e.g. "com.vika2603.telegram-cli.cuiko31". +// includes the account: e.g. "com.vika2603.telegram-cli.". const labelPrefix = "com.vika2603.telegram-cli" type launchdManager struct { diff --git a/internal/output/rights.go b/internal/output/rights.go index 8f5432a..5c863ca 100644 --- a/internal/output/rights.go +++ b/internal/output/rights.go @@ -8,15 +8,17 @@ import ( "github.com/vika2603/telegram-cli/internal/ui" ) -// RightsRow is emitted by `chat restrict` / `unrestrict` / `perms`. Peer is set -// for per-user restrict/unrestrict (absent for group-default perms). Denied -// lists the permission keywords currently revoked; Until is the restriction -// expiry (empty = permanent). +// RightsRow is emitted by the member-rights commands. Peer is set for per-user +// commands (absent for group-default perms). Denied lists the permission +// keywords currently revoked (set-perms/perms); Granted lists the admin rights +// keywords granted (promote); Until is the restriction expiry (empty = +// permanent). type RightsRow struct { - Action string `json:"action"` // "set-perms" | "unset-perms" | "perms" - Peer *PeerRef `json:"peer,omitempty"` - Denied []string `json:"denied,omitempty"` - Until string `json:"until,omitempty"` + Action string `json:"action"` // "set-perms" | "unset-perms" | "perms" | "promote" | "demote" + Peer *PeerRef `json:"peer,omitempty"` + Denied []string `json:"denied,omitempty"` + Granted []string `json:"granted,omitempty"` + Until string `json:"until,omitempty"` } // WriteRightsJSON emits one ndjson line. @@ -42,11 +44,20 @@ func RenderRights(io *ui.IOStreams, r RightsRow) error { } tp.AddRow("PEER", name) } - denied := "(none)" - if len(r.Denied) > 0 { - denied = strings.Join(r.Denied, ", ") + switch r.Action { + case "promote", "demote": + granted := "(none)" + if len(r.Granted) > 0 { + granted = strings.Join(r.Granted, ", ") + } + tp.AddRow("GRANTED", granted) + default: + denied := "(none)" + if len(r.Denied) > 0 { + denied = strings.Join(r.Denied, ", ") + } + tp.AddRow("DENIED", denied) } - tp.AddRow("DENIED", denied) if r.Until != "" { tp.AddRow("UNTIL", r.Until) } diff --git a/internal/telegram/member.go b/internal/telegram/member.go index b083721..b2f251c 100644 --- a/internal/telegram/member.go +++ b/internal/telegram/member.go @@ -112,43 +112,33 @@ func SetMemberBanned(ctx context.Context, api *tg.Client, resolver *peer.Resolve } // SetMemberAdmin promotes or demotes a user in a channel/supergroup via -// channels.editAdmin. Promote grants a standard admin rights set; demote -// clears all admin rights. -func SetMemberAdmin(ctx context.Context, api *tg.Client, resolver *peer.Resolver, q actionchat.PromoteQuery) (output.PeerRef, error) { +// channels.editAdmin. Promote grants either the keywords in q.Rights or, when +// none are given, a broad default admin set; demote clears all admin rights. +func SetMemberAdmin(ctx context.Context, api *tg.Client, resolver *peer.Resolver, q actionchat.PromoteQuery) (output.RightsRow, error) { groupResolved, err := resolver.Resolve(ctx, q.Ref) if err != nil { - return output.PeerRef{}, err + return output.RightsRow{}, err } inCh, ok := inputChannelFromPeer(groupResolved.InputPeer) if !ok { - return output.PeerRef{}, fmt.Errorf("%w: promote is only supported on channels/supergroups", command.ErrUnsupported) + return output.RightsRow{}, fmt.Errorf("%w: promote is only supported on channels/supergroups", command.ErrUnsupported) } userResolved, err := resolver.Resolve(ctx, q.User) if err != nil { - return output.PeerRef{}, err + return output.RightsRow{}, err } inUser, ok := inputUserFromPeer(userResolved.InputPeer) if !ok { - return output.PeerRef{}, fmt.Errorf("%w: %s is not a user", command.ErrUsage, q.User.String()) + return output.RightsRow{}, fmt.Errorf("%w: %s is not a user", command.ErrUsage, q.User.String()) } - // A broad admin set covering both supergroups and broadcast channels. - // PostMessages/EditMessages only apply to broadcast channels (ignored by - // supergroups); the rest apply to supergroups. AddAdmins is deliberately - // left off so a promoted admin can't mint further admins. var rights tg.ChatAdminRights if !q.Demote { - rights = tg.ChatAdminRights{ - ChangeInfo: true, - PostMessages: true, - EditMessages: true, - DeleteMessages: true, - BanUsers: true, - InviteUsers: true, - PinMessages: true, - ManageCall: true, - ManageTopics: true, + if len(q.Rights) > 0 { + rights = adminRightsFromKeys(q.Rights) + } else { + rights = defaultAdminRights() } } @@ -166,9 +156,109 @@ func SetMemberAdmin(ctx context.Context, api *tg.Client, resolver *peer.Resolver AdminRights: rights, Rank: rank, }); err != nil { - return output.PeerRef{}, err + return output.RightsRow{}, err } - return output.PeerRefFromResolved(userResolved), nil + pr := output.PeerRefFromResolved(userResolved) + row := output.RightsRow{Action: "promote", Peer: &pr} + if q.Demote { + row.Action = "demote" + } else { + row.Granted = grantedAdminKeys(rights) + } + return row, nil +} + +// adminKeyOrder is the canonical keyword order used when reporting granted +// admin rights, matching the ChatAdminRights field layout. +var adminKeyOrder = []string{ + "info", "post", "edit", "delete", "ban", "invite", "pin", + "add_admins", "anonymous", "call", "topics", + "post_stories", "edit_stories", "delete_stories", +} + +// defaultAdminRights is the broad admin set granted by `promote` when no +// explicit --rights keywords are passed. PostMessages/EditMessages only apply +// to broadcast channels (ignored by supergroups); the rest apply to +// supergroups. AddAdmins is deliberately left off so a promoted admin can't +// mint further admins. +func defaultAdminRights() tg.ChatAdminRights { + return tg.ChatAdminRights{ + ChangeInfo: true, + PostMessages: true, + EditMessages: true, + DeleteMessages: true, + BanUsers: true, + InviteUsers: true, + PinMessages: true, + ManageCall: true, + ManageTopics: true, + } +} + +// adminRightsFromKeys builds a ChatAdminRights mask from the validated +// keywords accepted by `promote --rights`. +func adminRightsFromKeys(keys []string) tg.ChatAdminRights { + var r tg.ChatAdminRights + for _, k := range keys { + switch k { + case "info": + r.ChangeInfo = true + case "post": + r.PostMessages = true + case "edit": + r.EditMessages = true + case "delete": + r.DeleteMessages = true + case "ban": + r.BanUsers = true + case "invite": + r.InviteUsers = true + case "pin": + r.PinMessages = true + case "add_admins": + r.AddAdmins = true + case "anonymous": + r.Anonymous = true + case "call": + r.ManageCall = true + case "topics": + r.ManageTopics = true + case "post_stories": + r.PostStories = true + case "edit_stories": + r.EditStories = true + case "delete_stories": + r.DeleteStories = true + } + } + return r +} + +// grantedAdminKeys reverses adminRightsFromKeys for display, in canonical order. +func grantedAdminKeys(r tg.ChatAdminRights) []string { + set := map[string]bool{ + "info": r.ChangeInfo, + "post": r.PostMessages, + "edit": r.EditMessages, + "delete": r.DeleteMessages, + "ban": r.BanUsers, + "invite": r.InviteUsers, + "pin": r.PinMessages, + "add_admins": r.AddAdmins, + "anonymous": r.Anonymous, + "call": r.ManageCall, + "topics": r.ManageTopics, + "post_stories": r.PostStories, + "edit_stories": r.EditStories, + "delete_stories": r.DeleteStories, + } + var out []string + for _, k := range adminKeyOrder { + if set[k] { + out = append(out, k) + } + } + return out } // currentAdminRank returns the participant's existing admin rank (title), or