Skip to content

Commit bde89fc

Browse files
author
cuiko
committed
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.
1 parent 492238c commit bde89fc

8 files changed

Lines changed: 242 additions & 75 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ client commands route through when present).
124124
| `tg chat invite list <ref>` | List invite links. `--revoked`, `--admin <user>`, `--limit`. |
125125
| `tg chat invite revoke <ref> <link>` / `delete <ref> <link>` | Revoke a link, or delete a revoked one. |
126126
| `tg chat ban <ref> <user>` / `tg chat unban <ref> <user>` | Ban (remove + block) or unban a member. Ban prompts unless `--yes`. |
127-
| `tg chat admin promote <ref> <user>` / `tg chat admin demote <ref> <user>` | Grant or revoke admin rights. Promote takes `--title <rank>` (custom admin title, ≤16 chars); omit `--title` to keep the current title, pass `--title ""` to clear it. |
127+
| `tg chat admin promote <ref> <user>` / `tg chat admin demote <ref> <user>` | Grant or revoke admin rights. `--rights <keywords>` 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 <rank>` sets a custom admin title (≤16 chars); omit `--title` to keep the current title, pass `--title ""` to clear it. |
128128
| `tg chat member set-perms <ref> <user>` | Set a member's permissions with `--deny`/`--allow` keywords (`send,media,stickers,bots,polls,links,invite,pin,info,topics`) and optional `--until <RFC3339\|dur>` (default permanent). |
129129
| `tg chat member unset-perms <ref> <user>` | Clear all permission restrictions on a member. |
130130
| `tg chat perms <ref>` | Set the group's default member permissions with `--deny`/`--allow` (same keywords). |

internal/action/chat/member.go

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,9 @@ type PromoteRequest struct {
107107
RawRef string
108108
RawUser string
109109
Demote bool
110-
Title string // custom admin rank/title (promote only, <=16 chars)
111-
SetTitle bool // whether --title was given; if false, keep the current rank
110+
Title string // custom admin rank/title (promote only, <=16 chars)
111+
SetTitle bool // whether --title was given; if false, keep the current rank
112+
Rights []string // admin rights to grant (promote only); empty = broad default set
112113
}
113114

114115
// PromoteQuery is the normalized payload passed to the Telegram layer.
@@ -118,26 +119,33 @@ type PromoteQuery struct {
118119
Demote bool
119120
Title string
120121
SetTitle bool
122+
Rights []string
121123
}
122124

123-
// PromoteFunc promotes or demotes a user and returns the affected peer.
124-
type PromoteFunc func(context.Context, PromoteQuery) (output.PeerRef, error)
125+
// PromoteFunc promotes or demotes a user and returns the resulting rights row.
126+
type PromoteFunc func(context.Context, PromoteQuery) (output.RightsRow, error)
125127

126128
// Promote validates the request and dispatches the promote/demote operation.
127-
func Promote(ctx context.Context, req PromoteRequest, do PromoteFunc) (output.PeerRef, error) {
129+
func Promote(ctx context.Context, req PromoteRequest, do PromoteFunc) (output.RightsRow, error) {
128130
parsed, err := ref.ParseRef(req.RawRef)
129131
if err != nil {
130-
return output.PeerRef{}, fmt.Errorf("%w: %s", command.ErrUsage, err.Error())
132+
return output.RightsRow{}, fmt.Errorf("%w: %s", command.ErrUsage, err.Error())
131133
}
132134
userRef, err := ref.ParseRef(req.RawUser)
133135
if err != nil {
134-
return output.PeerRef{}, fmt.Errorf("%w: invalid user ref %q: %s", command.ErrUsage, req.RawUser, err.Error())
136+
return output.RightsRow{}, fmt.Errorf("%w: invalid user ref %q: %s", command.ErrUsage, req.RawUser, err.Error())
135137
}
136138
if utf8.RuneCountInString(req.Title) > 16 {
137-
return output.PeerRef{}, fmt.Errorf("%w: --title must be at most 16 characters", command.ErrUsage)
139+
return output.RightsRow{}, fmt.Errorf("%w: --title must be at most 16 characters", command.ErrUsage)
140+
}
141+
if req.Demote && len(req.Rights) > 0 {
142+
return output.RightsRow{}, fmt.Errorf("%w: --rights cannot be combined with demote", command.ErrUsage)
143+
}
144+
if err := validateAdminRightKeys(req.Rights); err != nil {
145+
return output.RightsRow{}, err
138146
}
139147
if do == nil {
140-
return output.PeerRef{}, fmt.Errorf("%w: chat promote called without promote function", command.ErrPrecondition)
148+
return output.RightsRow{}, fmt.Errorf("%w: chat promote called without promote function", command.ErrPrecondition)
141149
}
142-
return do(ctx, PromoteQuery{Ref: parsed, User: userRef, Demote: req.Demote, Title: req.Title, SetTitle: req.SetTitle})
150+
return do(ctx, PromoteQuery{Ref: parsed, User: userRef, Demote: req.Demote, Title: req.Title, SetTitle: req.SetTitle, Rights: req.Rights})
143151
}

internal/action/chat/member_test.go

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,11 @@ func TestPromote_TitlePassesThrough(t *testing.T) {
9393
RawUser: "@alice",
9494
Title: "客服",
9595
SetTitle: true,
96-
}, func(_ context.Context, q actionchat.PromoteQuery) (output.PeerRef, error) {
96+
}, func(_ context.Context, q actionchat.PromoteQuery) (output.RightsRow, error) {
9797
require.Equal(t, "客服", q.Title)
9898
require.True(t, q.SetTitle)
9999
require.False(t, q.Demote)
100-
return output.PeerRef{}, nil
100+
return output.RightsRow{}, nil
101101
})
102102
require.NoError(t, err)
103103
}
@@ -107,9 +107,9 @@ func TestPromote_RejectsLongTitle(t *testing.T) {
107107
RawRef: "@grp",
108108
RawUser: "@alice",
109109
Title: "this-title-is-way-too-long-for-telegram",
110-
}, func(context.Context, actionchat.PromoteQuery) (output.PeerRef, error) {
110+
}, func(context.Context, actionchat.PromoteQuery) (output.RightsRow, error) {
111111
t.Fatal("must not dispatch with an over-long title")
112-
return output.PeerRef{}, nil
112+
return output.RightsRow{}, nil
113113
})
114114
require.ErrorIs(t, err, command.ErrUsage)
115115
}
@@ -119,10 +119,48 @@ func TestPromote_DemoteFlagPassesThrough(t *testing.T) {
119119
RawRef: "@grp",
120120
RawUser: "@alice",
121121
Demote: true,
122-
}, func(_ context.Context, q actionchat.PromoteQuery) (output.PeerRef, error) {
122+
}, func(_ context.Context, q actionchat.PromoteQuery) (output.RightsRow, error) {
123123
require.Equal(t, "alice", q.User.Value)
124124
require.True(t, q.Demote)
125-
return output.PeerRef{}, nil
125+
return output.RightsRow{}, nil
126126
})
127127
require.NoError(t, err)
128128
}
129+
130+
func TestPromote_RightsPassThrough(t *testing.T) {
131+
_, err := actionchat.Promote(context.Background(), actionchat.PromoteRequest{
132+
RawRef: "@grp",
133+
RawUser: "@alice",
134+
Rights: []string{"pin", "delete"},
135+
}, func(_ context.Context, q actionchat.PromoteQuery) (output.RightsRow, error) {
136+
require.Equal(t, []string{"pin", "delete"}, q.Rights)
137+
require.False(t, q.Demote)
138+
return output.RightsRow{}, nil
139+
})
140+
require.NoError(t, err)
141+
}
142+
143+
func TestPromote_RejectsUnknownRight(t *testing.T) {
144+
_, err := actionchat.Promote(context.Background(), actionchat.PromoteRequest{
145+
RawRef: "@grp",
146+
RawUser: "@alice",
147+
Rights: []string{"pin", "bogus"},
148+
}, func(context.Context, actionchat.PromoteQuery) (output.RightsRow, error) {
149+
t.Fatal("must not dispatch with an unknown right")
150+
return output.RightsRow{}, nil
151+
})
152+
require.ErrorIs(t, err, command.ErrUsage)
153+
}
154+
155+
func TestPromote_RejectsRightsWithDemote(t *testing.T) {
156+
_, err := actionchat.Promote(context.Background(), actionchat.PromoteRequest{
157+
RawRef: "@grp",
158+
RawUser: "@alice",
159+
Demote: true,
160+
Rights: []string{"pin"},
161+
}, func(context.Context, actionchat.PromoteQuery) (output.RightsRow, error) {
162+
t.Fatal("must not dispatch --rights with demote")
163+
return output.RightsRow{}, nil
164+
})
165+
require.ErrorIs(t, err, command.ErrUsage)
166+
}

internal/action/chat/perms.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,36 @@ func validateRightKeys(keys []string) error {
3434
return nil
3535
}
3636

37+
// AdminRightKeys are the admin-rights keywords accepted by `promote --rights`.
38+
// Each maps (in the telegram layer) to one ChatAdminRights bit. "post"/"edit"
39+
// only apply to broadcast channels; "ban"/"topics" only apply to supergroups;
40+
// the rest apply to both.
41+
var AdminRightKeys = map[string]bool{
42+
"info": true, // change chat info
43+
"post": true, // post messages (broadcast)
44+
"edit": true, // edit others' messages (broadcast)
45+
"delete": true, // delete others' messages
46+
"ban": true, // ban/restrict users
47+
"invite": true, // add/invite users
48+
"pin": true, // pin messages
49+
"add_admins": true, // promote new admins
50+
"anonymous": true, // act anonymously
51+
"call": true, // manage video chats
52+
"topics": true, // manage forum topics
53+
"post_stories": true,
54+
"edit_stories": true,
55+
"delete_stories": true,
56+
}
57+
58+
func validateAdminRightKeys(keys []string) error {
59+
for _, k := range keys {
60+
if !AdminRightKeys[k] {
61+
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)
62+
}
63+
}
64+
return nil
65+
}
66+
3767
// ---------------------------------------------------------------------------
3868
// set-perms / unset-perms (per-user)
3969
// ---------------------------------------------------------------------------

internal/cli/chat/admin/admin.go

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package admin
33

44
import (
55
"context"
6-
"fmt"
76

87
"github.com/gotd/td/telegram/peers"
98
"github.com/gotd/td/tg"
@@ -26,6 +25,7 @@ type Options struct {
2625
Demote bool
2726
Title string
2827
SetTitle bool
28+
Rights []string
2929
Exporter output.Exporter
3030
IOStreams *ui.IOStreams
3131
Promote actionchat.PromoteFunc
@@ -79,54 +79,44 @@ func adminCmd(f *runtime.Invocation, runF func(*Options) error, demote bool) *co
7979
}
8080
if !demote {
8181
cmd.Flags().StringVar(&opts.Title, "title", "", "Custom admin title/rank (<=16 chars); omit to keep the current title, pass \"\" to clear it")
82+
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)")
8283
}
8384
command.SetMeta(cmd, command.Meta{NeedsAccount: true, NeedsClient: true})
84-
output.AddJSONFlags(cmd, &opts.Exporter, []string{"ref", "id", "kind", "title", "username"})
85+
output.AddJSONFlags(cmd, &opts.Exporter, []string{"action", "peer", "granted"})
8586
return cmd
8687
}
8788

8889
// Run executes the promote/demote logic.
8990
func Run(ctx context.Context, opts *Options) error {
90-
pr, err := actionchat.Promote(ctx, actionchat.PromoteRequest{
91+
row, err := actionchat.Promote(ctx, actionchat.PromoteRequest{
9192
RawRef: opts.RawRef,
9293
RawUser: opts.RawUser,
9394
Demote: opts.Demote,
9495
Title: opts.Title,
9596
SetTitle: opts.SetTitle,
97+
Rights: opts.Rights,
9698
}, opts.Promote)
9799
if err != nil {
98100
return err
99101
}
100102
if opts.Exporter != nil {
101-
return opts.Exporter.Write(opts.IOStreams, pr)
103+
return opts.Exporter.Write(opts.IOStreams, row)
102104
}
103-
verb := "promoted"
104-
if opts.Demote {
105-
verb = "demoted"
106-
}
107-
name := pr.Title
108-
if pr.Username != "" {
109-
name = "@" + pr.Username
110-
}
111-
if name == "" {
112-
name = pr.Ref
113-
}
114-
_, err = fmt.Fprintf(opts.IOStreams.Out, "%s %s\n", verb, name)
115-
return err
105+
return output.RenderRights(opts.IOStreams, row)
116106
}
117107

118108
func newPromoteFn(f *runtime.Invocation) actionchat.PromoteFunc {
119-
return func(ctx context.Context, q actionchat.PromoteQuery) (output.PeerRef, error) {
109+
return func(ctx context.Context, q actionchat.PromoteQuery) (output.RightsRow, error) {
120110
acct, err := f.Account("")
121111
if err != nil {
122-
return output.PeerRef{}, err
112+
return output.RightsRow{}, err
123113
}
124-
var pr output.PeerRef
114+
var row output.RightsRow
125115
err = f.WithPeers(ctx, acct, runtime.ClientOptsFrom(f, acct),
126116
func(ctx context.Context, api *tg.Client, _ *peers.Manager, res *peer.Resolver) error {
127-
pr, err = telegram.SetMemberAdmin(ctx, api, res, q)
117+
row, err = telegram.SetMemberAdmin(ctx, api, res, q)
128118
return err
129119
})
130-
return pr, err
120+
return row, err
131121
}
132122
}

internal/daemon/launchd.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ var runLaunchctl = func(args ...string) (string, error) {
2121
}
2222

2323
// labelPrefix is the launchd reverse-DNS prefix. The actual label
24-
// includes the account: e.g. "com.vika2603.telegram-cli.cuiko31".
24+
// includes the account: e.g. "com.vika2603.telegram-cli.<account>".
2525
const labelPrefix = "com.vika2603.telegram-cli"
2626

2727
type launchdManager struct {

internal/output/rights.go

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,17 @@ import (
88
"github.com/vika2603/telegram-cli/internal/ui"
99
)
1010

11-
// RightsRow is emitted by `chat restrict` / `unrestrict` / `perms`. Peer is set
12-
// for per-user restrict/unrestrict (absent for group-default perms). Denied
13-
// lists the permission keywords currently revoked; Until is the restriction
14-
// expiry (empty = permanent).
11+
// RightsRow is emitted by the member-rights commands. Peer is set for per-user
12+
// commands (absent for group-default perms). Denied lists the permission
13+
// keywords currently revoked (set-perms/perms); Granted lists the admin rights
14+
// keywords granted (promote); Until is the restriction expiry (empty =
15+
// permanent).
1516
type RightsRow struct {
16-
Action string `json:"action"` // "set-perms" | "unset-perms" | "perms"
17-
Peer *PeerRef `json:"peer,omitempty"`
18-
Denied []string `json:"denied,omitempty"`
19-
Until string `json:"until,omitempty"`
17+
Action string `json:"action"` // "set-perms" | "unset-perms" | "perms" | "promote" | "demote"
18+
Peer *PeerRef `json:"peer,omitempty"`
19+
Denied []string `json:"denied,omitempty"`
20+
Granted []string `json:"granted,omitempty"`
21+
Until string `json:"until,omitempty"`
2022
}
2123

2224
// WriteRightsJSON emits one ndjson line.
@@ -42,11 +44,20 @@ func RenderRights(io *ui.IOStreams, r RightsRow) error {
4244
}
4345
tp.AddRow("PEER", name)
4446
}
45-
denied := "(none)"
46-
if len(r.Denied) > 0 {
47-
denied = strings.Join(r.Denied, ", ")
47+
switch r.Action {
48+
case "promote", "demote":
49+
granted := "(none)"
50+
if len(r.Granted) > 0 {
51+
granted = strings.Join(r.Granted, ", ")
52+
}
53+
tp.AddRow("GRANTED", granted)
54+
default:
55+
denied := "(none)"
56+
if len(r.Denied) > 0 {
57+
denied = strings.Join(r.Denied, ", ")
58+
}
59+
tp.AddRow("DENIED", denied)
4860
}
49-
tp.AddRow("DENIED", denied)
5061
if r.Until != "" {
5162
tp.AddRow("UNTIL", r.Until)
5263
}

0 commit comments

Comments
 (0)