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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ client commands route through when present).
| `tg chat invite create <ref>` | Create an invite link. `--title`, `--expire <RFC3339\|dur>`, `--usage-limit <n>`, `--request-needed`. |
| `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 ban <ref> <user>` / `tg chat unban <ref> <user>` | Ban (remove + restrict) or unban a member of a group/channel. Ban prompts unless `--yes`. |
| `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. |
Expand Down Expand Up @@ -159,6 +159,7 @@ client commands route through when present).
| `tg contact delete <ref>` | Delete a contact. |
| `tg contact block <ref>` | Block a user, bot, or channel. |
| `tg contact unblock <ref>` | Unblock a user, bot, or channel. |
| `tg contact report <ref>` | Report a peer to Telegram. `--reason` (spam (default), violence, porn, child-abuse, copyright, fake, drugs, personal-details, geo-irrelevant, other), `--message <comment>`, `--block` (also block the peer). Prompts unless `--yes`. |
| `tg me` | Print the current Telegram identity. |
| `tg profile set-name <first>` | Set first name; `--last` sets or clears last name. |
| `tg profile set-username <username>` | Set or clear the public username. |
Expand Down
74 changes: 74 additions & 0 deletions internal/action/contact/report.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package contact

import (
"context"
"fmt"

"github.com/vika2603/telegram-cli/internal/command"
"github.com/vika2603/telegram-cli/internal/ref"
"github.com/vika2603/telegram-cli/internal/ui"
)

// ReportReasons are the keywords accepted by `tg contact report --reason`.
// Keep this set in sync with reportReason() in internal/telegram/contacts.go,
// which maps each keyword to a tg.ReportReasonClass — a keyword present here but
// missing there would silently report as spam.
var ReportReasons = map[string]bool{
"spam": true,
"violence": true,
"porn": true,
"child-abuse": true,
"copyright": true,
"fake": true,
"drugs": true,
"personal-details": true,
"geo-irrelevant": true,
"other": true,
}

// ReportRequest is the raw request for `tg contact report`.
type ReportRequest struct {
RawRef string
Reason string
Message string
Block bool // also block the peer after reporting
Yes bool
Prompter ui.Prompter
}

// ReportQuery is the validated payload passed to the Telegram layer.
type ReportQuery struct {
Ref ref.Ref
Reason string
Message string
Block bool
}

// ReportFunc reports one peer after request validation.
type ReportFunc func(context.Context, ReportQuery) error

// Report validates a report request, confirms it, and delegates the call.
func Report(ctx context.Context, req ReportRequest, do ReportFunc) error {
parsed, err := ref.ParseRef(req.RawRef)
if err != nil {
return fmt.Errorf("%w: %s", command.ErrUsage, err.Error())
}
reason := req.Reason
if reason == "" {
reason = "spam"
}
if !ReportReasons[reason] {
return fmt.Errorf("%w: unknown report reason %q (valid: spam violence porn child-abuse copyright fake drugs personal-details geo-irrelevant other)", command.ErrUsage, reason)
}
if do == nil {
return fmt.Errorf("%w: contact report called without report function", command.ErrPrecondition)
}
prompt := fmt.Sprintf("report %s for %s?", req.RawRef, reason)
if req.Block {
prompt = fmt.Sprintf("report %s for %s and block them?", req.RawRef, reason)
}
if err := ui.ConfirmDestructive(req.Prompter, prompt, req.Yes); err != nil {
return err
}
return do(ctx, ReportQuery{Ref: parsed, Reason: reason, Message: req.Message, Block: req.Block})
}
60 changes: 60 additions & 0 deletions internal/action/contact/report_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package contact_test

import (
"context"
"testing"

"github.com/stretchr/testify/require"

"github.com/vika2603/telegram-cli/internal/action/contact"
"github.com/vika2603/telegram-cli/internal/command"
)

func TestReportRequiresFunction(t *testing.T) {
err := contact.Report(context.Background(), contact.ReportRequest{RawRef: "@bob", Yes: true}, nil)
require.ErrorIs(t, err, command.ErrPrecondition)
}

func TestReportDefaultsToSpam(t *testing.T) {
err := contact.Report(context.Background(), contact.ReportRequest{RawRef: "@bob", Yes: true},
func(_ context.Context, q contact.ReportQuery) error {
require.Equal(t, "bob", q.Ref.Value)
require.Equal(t, "spam", q.Reason)
require.False(t, q.Block)
return nil
})
require.NoError(t, err)
}

func TestReportPassesReasonMessageAndBlock(t *testing.T) {
err := contact.Report(context.Background(), contact.ReportRequest{
RawRef: "@bob", Reason: "fake", Message: "scam", Block: true, Yes: true,
}, func(_ context.Context, q contact.ReportQuery) error {
require.Equal(t, "fake", q.Reason)
require.Equal(t, "scam", q.Message)
require.True(t, q.Block)
return nil
})
require.NoError(t, err)
}

func TestReportRejectsUnknownReason(t *testing.T) {
err := contact.Report(context.Background(), contact.ReportRequest{RawRef: "@bob", Reason: "bogus", Yes: true},
func(context.Context, contact.ReportQuery) error {
t.Fatal("must not dispatch with an unknown reason")
return nil
})
require.ErrorIs(t, err, command.ErrUsage)
}

func TestReportDeclined(t *testing.T) {
called := false
err := contact.Report(context.Background(), contact.ReportRequest{
RawRef: "@bob", Prompter: stubPrompter{ok: false},
}, func(context.Context, contact.ReportQuery) error {
called = true
return nil
})
require.ErrorIs(t, err, command.ErrNotConfirmed)
require.False(t, called)
}
4 changes: 3 additions & 1 deletion internal/cli/contact/contact.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/vika2603/telegram-cli/internal/cli/contact/block"
del "github.com/vika2603/telegram-cli/internal/cli/contact/delete"
"github.com/vika2603/telegram-cli/internal/cli/contact/list"
"github.com/vika2603/telegram-cli/internal/cli/contact/report"
"github.com/vika2603/telegram-cli/internal/cli/contact/unblock"
"github.com/vika2603/telegram-cli/internal/runtime"
)
Expand All @@ -16,13 +17,14 @@ import (
func New(f *runtime.Invocation) *cobra.Command {
cmd := &cobra.Command{
Use: "contact",
Short: "Address book: list, add, delete, block, unblock",
Short: "Address book: list, add, delete, block, unblock, report",
GroupID: "core",
}
cmd.AddCommand(list.New(f, nil))
cmd.AddCommand(add.New(f, nil))
cmd.AddCommand(del.New(f, nil))
cmd.AddCommand(block.New(f, nil))
cmd.AddCommand(unblock.New(f, nil))
cmd.AddCommand(report.New(f, nil))
return cmd
}
99 changes: 99 additions & 0 deletions internal/cli/contact/report/report.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Package report implements "tg contact report <ref>".
package report

import (
"context"
"fmt"

"github.com/gotd/td/telegram/peers"
"github.com/gotd/td/tg"
"github.com/spf13/cobra"

actioncontact "github.com/vika2603/telegram-cli/internal/action/contact"
"github.com/vika2603/telegram-cli/internal/command"
"github.com/vika2603/telegram-cli/internal/runtime"
"github.com/vika2603/telegram-cli/internal/telegram"
"github.com/vika2603/telegram-cli/internal/telegram/peer"
"github.com/vika2603/telegram-cli/internal/ui"
)

// Options holds the resolved flags and injected dependencies for Run.
type Options struct {
RawRef string
Reason string
Message string
Block bool
Yes bool

IOStreams *ui.IOStreams
Prompter ui.Prompter

// Report is the closure that performs the actual Telegram call. Production
// code sets it via newReport; tests stub it directly.
Report actioncontact.ReportFunc
}

// New builds the cobra command for "tg contact report".
func New(f *runtime.Invocation, runF func(*Options) error) *cobra.Command {
opts := &Options{}
cmd := &cobra.Command{
Use: "report <ref>",
Short: "Report a user, bot, group, or channel to Telegram",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
opts.RawRef = args[0]
opts.IOStreams = f.IOStreams
opts.Prompter = f.Prompter
if runF != nil {
return runF(opts)
}
opts.Report = newReport(f)
return Run(cmd.Context(), opts)
},
}
cmd.Flags().StringVar(&opts.Reason, "reason", "spam", "Report reason: spam, violence, porn, child-abuse, copyright, fake, drugs, personal-details, geo-irrelevant, other")
cmd.Flags().StringVar(&opts.Message, "message", "", "Optional comment for report moderation")
cmd.Flags().BoolVar(&opts.Block, "block", false, "Also block the peer after reporting")
cmd.Flags().BoolVarP(&opts.Yes, "yes", "y", false, "Skip confirmation prompt")
command.SetMeta(cmd, command.Meta{NeedsAccount: true, NeedsClient: true})
return cmd
}

// Run collects the raw request, delegates validation/mutation, and renders status.
func Run(ctx context.Context, opts *Options) error {
if err := actioncontact.Report(ctx, actioncontact.ReportRequest{
RawRef: opts.RawRef,
Reason: opts.Reason,
Message: opts.Message,
Block: opts.Block,
Yes: opts.Yes,
Prompter: opts.Prompter,
}, opts.Report); err != nil {
return err
}
verb := "reported"
if opts.Block {
verb = "reported+blocked"
}
_, _ = fmt.Fprintf(opts.IOStreams.Out, "%s\t%s\n", verb, opts.RawRef)
return nil
}

// newReport returns the production Report closure that calls the Telegram API.
func newReport(f *runtime.Invocation) actioncontact.ReportFunc {
return func(ctx context.Context, q actioncontact.ReportQuery) error {
acct, err := f.Account("")
if err != nil {
return err
}
if cl, _ := runtime.MaybeDialDaemon(ctx, f, acct); cl != nil {
defer func() { _ = cl.Close() }()
_, err := cl.Call(ctx, "contact.report", q)
return err
}
return f.WithPeers(ctx, acct, runtime.ClientOptsFrom(f, acct),
func(ctx context.Context, api *tg.Client, _ *peers.Manager, res *peer.Resolver) error {
return telegram.ReportPeer(ctx, api, res, q)
})
}
}
11 changes: 11 additions & 0 deletions internal/daemon/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,17 @@ func registerHandlers(
return json.RawMessage("true"), nil
})

srv.Register("contact.report", func(ctx context.Context, params json.RawMessage) (json.RawMessage, error) {
var q actioncontact.ReportQuery
if err := json.Unmarshal(params, &q); err != nil { //nolint:musttag
return nil, fmt.Errorf("invalid contact.report params: %w", err)
}
if err := telegram.ReportPeer(ctx, api, res, q); err != nil {
return nil, err
}
return json.RawMessage("true"), nil
})

srv.Register("profile.set_name", func(ctx context.Context, params json.RawMessage) (json.RawMessage, error) {
var q actionprofile.SetNameRequest
if err := json.Unmarshal(params, &q); err != nil { //nolint:musttag
Expand Down
1 change: 1 addition & 0 deletions internal/daemon/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ func TestRegisterHandlers_BindsExpectedMethods(t *testing.T) {
"contact.delete",
"contact.block",
"contact.unblock",
"contact.report",
"profile.set_name",
"profile.set_bio",
"profile.set_username",
Expand Down
52 changes: 52 additions & 0 deletions internal/telegram/contacts.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,58 @@ func UnblockContact(ctx context.Context, api *tg.Client, resolver *peer.Resolver
return err
}

// ReportPeer reports one resolved peer to Telegram moderation via
// account.reportPeer with the given reason and optional comment. When q.Block
// is set it also blocks the peer (contacts.block) after a successful report.
func ReportPeer(ctx context.Context, api *tg.Client, resolver *peer.Resolver, q actioncontact.ReportQuery) error {
resolved, err := resolver.Resolve(ctx, q.Ref)
if err != nil {
return err
}
if _, err := api.AccountReportPeer(ctx, &tg.AccountReportPeerRequest{
Peer: resolved.InputPeer,
Reason: reportReason(q.Reason),
Message: q.Message,
}); err != nil {
return err
}
if q.Block {
if _, err := api.ContactsBlock(ctx, &tg.ContactsBlockRequest{ID: resolved.InputPeer}); err != nil {
return err
}
}
return nil
}

// reportReason maps a validated reason keyword to a tg.ReportReasonClass. Keep
// the cases in sync with ReportReasons in internal/action/contact/report.go;
// the action layer validates the keyword before it reaches here, so the default
// is only a safety net.
func reportReason(reason string) tg.ReportReasonClass {
switch reason {
case "violence":
return &tg.InputReportReasonViolence{}
case "porn":
return &tg.InputReportReasonPornography{}
case "child-abuse":
return &tg.InputReportReasonChildAbuse{}
case "copyright":
return &tg.InputReportReasonCopyright{}
case "fake":
return &tg.InputReportReasonFake{}
case "drugs":
return &tg.InputReportReasonIllegalDrugs{}
case "personal-details":
return &tg.InputReportReasonPersonalDetails{}
case "geo-irrelevant":
return &tg.InputReportReasonGeoIrrelevant{}
case "other":
return &tg.InputReportReasonOther{}
default:
return &tg.InputReportReasonSpam{}
}
}

func listBlockedContacts(ctx context.Context, api *tg.Client) ([]output.ContactRow, error) {
var out []output.ContactRow
err := query.GetBlocked(api).ForEach(ctx, func(_ context.Context, e blocked.Elem) error {
Expand Down