Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ client commands route through when present).
| `tg chat invite list <ref>` | List invite links. `--revoked`, `--admin <user>`, `--limit`. |
| `tg chat invite revoke <ref> <link>` / `delete <ref> <link>` | Revoke a link, or delete a revoked one. |
| `tg chat ban <ref> <user>` / `tg chat unban <ref> <user>` | Ban (remove + block) or unban a member. Ban prompts unless `--yes`. |
| `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. |
| `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. |
| `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). |
| `tg chat member unset-perms <ref> <user>` | Clear all permission restrictions on a member. |
| `tg chat perms <ref>` | Set the group's default member permissions with `--deny`/`--allow` (same keywords). |
Expand Down
28 changes: 18 additions & 10 deletions internal/action/chat/member.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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})
}
50 changes: 44 additions & 6 deletions internal/action/chat/member_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
30 changes: 30 additions & 0 deletions internal/action/chat/perms.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
// ---------------------------------------------------------------------------
Expand Down
34 changes: 12 additions & 22 deletions internal/cli/chat/admin/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package admin

import (
"context"
"fmt"

"github.com/gotd/td/telegram/peers"
"github.com/gotd/td/tg"
Expand All @@ -26,6 +25,7 @@ type Options struct {
Demote bool
Title string
SetTitle bool
Rights []string
Exporter output.Exporter
IOStreams *ui.IOStreams
Promote actionchat.PromoteFunc
Expand Down Expand Up @@ -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
}
}
2 changes: 1 addition & 1 deletion internal/daemon/launchd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.<account>".
const labelPrefix = "com.vika2603.telegram-cli"

type launchdManager struct {
Expand Down
35 changes: 23 additions & 12 deletions internal/output/rights.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
}
Expand Down
Loading