Skip to content

Commit 3fb05d9

Browse files
author
cuiko tang
authored
Merge pull request #31 from cuiko/feat/contact-report
feat(contact): Add `contact report` to report a peer, with optional ban
2 parents d956fb8 + 162e322 commit 3fb05d9

8 files changed

Lines changed: 302 additions & 2 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ client commands route through when present).
123123
| `tg chat invite create <ref>` | Create an invite link. `--title`, `--expire <RFC3339\|dur>`, `--usage-limit <n>`, `--request-needed`. |
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. |
126-
| `tg chat ban <ref> <user>` / `tg chat unban <ref> <user>` | Ban (remove + block) or unban a member. Ban prompts unless `--yes`. |
126+
| `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`. |
127127
| `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. |
@@ -159,6 +159,7 @@ client commands route through when present).
159159
| `tg contact delete <ref>` | Delete a contact. |
160160
| `tg contact block <ref>` | Block a user, bot, or channel. |
161161
| `tg contact unblock <ref>` | Unblock a user, bot, or channel. |
162+
| `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`. |
162163
| `tg me` | Print the current Telegram identity. |
163164
| `tg profile set-name <first>` | Set first name; `--last` sets or clears last name. |
164165
| `tg profile set-username <username>` | Set or clear the public username. |

internal/action/contact/report.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package contact
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/vika2603/telegram-cli/internal/command"
8+
"github.com/vika2603/telegram-cli/internal/ref"
9+
"github.com/vika2603/telegram-cli/internal/ui"
10+
)
11+
12+
// ReportReasons are the keywords accepted by `tg contact report --reason`.
13+
// Keep this set in sync with reportReason() in internal/telegram/contacts.go,
14+
// which maps each keyword to a tg.ReportReasonClass — a keyword present here but
15+
// missing there would silently report as spam.
16+
var ReportReasons = map[string]bool{
17+
"spam": true,
18+
"violence": true,
19+
"porn": true,
20+
"child-abuse": true,
21+
"copyright": true,
22+
"fake": true,
23+
"drugs": true,
24+
"personal-details": true,
25+
"geo-irrelevant": true,
26+
"other": true,
27+
}
28+
29+
// ReportRequest is the raw request for `tg contact report`.
30+
type ReportRequest struct {
31+
RawRef string
32+
Reason string
33+
Message string
34+
Block bool // also block the peer after reporting
35+
Yes bool
36+
Prompter ui.Prompter
37+
}
38+
39+
// ReportQuery is the validated payload passed to the Telegram layer.
40+
type ReportQuery struct {
41+
Ref ref.Ref
42+
Reason string
43+
Message string
44+
Block bool
45+
}
46+
47+
// ReportFunc reports one peer after request validation.
48+
type ReportFunc func(context.Context, ReportQuery) error
49+
50+
// Report validates a report request, confirms it, and delegates the call.
51+
func Report(ctx context.Context, req ReportRequest, do ReportFunc) error {
52+
parsed, err := ref.ParseRef(req.RawRef)
53+
if err != nil {
54+
return fmt.Errorf("%w: %s", command.ErrUsage, err.Error())
55+
}
56+
reason := req.Reason
57+
if reason == "" {
58+
reason = "spam"
59+
}
60+
if !ReportReasons[reason] {
61+
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)
62+
}
63+
if do == nil {
64+
return fmt.Errorf("%w: contact report called without report function", command.ErrPrecondition)
65+
}
66+
prompt := fmt.Sprintf("report %s for %s?", req.RawRef, reason)
67+
if req.Block {
68+
prompt = fmt.Sprintf("report %s for %s and block them?", req.RawRef, reason)
69+
}
70+
if err := ui.ConfirmDestructive(req.Prompter, prompt, req.Yes); err != nil {
71+
return err
72+
}
73+
return do(ctx, ReportQuery{Ref: parsed, Reason: reason, Message: req.Message, Block: req.Block})
74+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package contact_test
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/vika2603/telegram-cli/internal/action/contact"
10+
"github.com/vika2603/telegram-cli/internal/command"
11+
)
12+
13+
func TestReportRequiresFunction(t *testing.T) {
14+
err := contact.Report(context.Background(), contact.ReportRequest{RawRef: "@bob", Yes: true}, nil)
15+
require.ErrorIs(t, err, command.ErrPrecondition)
16+
}
17+
18+
func TestReportDefaultsToSpam(t *testing.T) {
19+
err := contact.Report(context.Background(), contact.ReportRequest{RawRef: "@bob", Yes: true},
20+
func(_ context.Context, q contact.ReportQuery) error {
21+
require.Equal(t, "bob", q.Ref.Value)
22+
require.Equal(t, "spam", q.Reason)
23+
require.False(t, q.Block)
24+
return nil
25+
})
26+
require.NoError(t, err)
27+
}
28+
29+
func TestReportPassesReasonMessageAndBlock(t *testing.T) {
30+
err := contact.Report(context.Background(), contact.ReportRequest{
31+
RawRef: "@bob", Reason: "fake", Message: "scam", Block: true, Yes: true,
32+
}, func(_ context.Context, q contact.ReportQuery) error {
33+
require.Equal(t, "fake", q.Reason)
34+
require.Equal(t, "scam", q.Message)
35+
require.True(t, q.Block)
36+
return nil
37+
})
38+
require.NoError(t, err)
39+
}
40+
41+
func TestReportRejectsUnknownReason(t *testing.T) {
42+
err := contact.Report(context.Background(), contact.ReportRequest{RawRef: "@bob", Reason: "bogus", Yes: true},
43+
func(context.Context, contact.ReportQuery) error {
44+
t.Fatal("must not dispatch with an unknown reason")
45+
return nil
46+
})
47+
require.ErrorIs(t, err, command.ErrUsage)
48+
}
49+
50+
func TestReportDeclined(t *testing.T) {
51+
called := false
52+
err := contact.Report(context.Background(), contact.ReportRequest{
53+
RawRef: "@bob", Prompter: stubPrompter{ok: false},
54+
}, func(context.Context, contact.ReportQuery) error {
55+
called = true
56+
return nil
57+
})
58+
require.ErrorIs(t, err, command.ErrNotConfirmed)
59+
require.False(t, called)
60+
}

internal/cli/contact/contact.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/vika2603/telegram-cli/internal/cli/contact/block"
99
del "github.com/vika2603/telegram-cli/internal/cli/contact/delete"
1010
"github.com/vika2603/telegram-cli/internal/cli/contact/list"
11+
"github.com/vika2603/telegram-cli/internal/cli/contact/report"
1112
"github.com/vika2603/telegram-cli/internal/cli/contact/unblock"
1213
"github.com/vika2603/telegram-cli/internal/runtime"
1314
)
@@ -16,13 +17,14 @@ import (
1617
func New(f *runtime.Invocation) *cobra.Command {
1718
cmd := &cobra.Command{
1819
Use: "contact",
19-
Short: "Address book: list, add, delete, block, unblock",
20+
Short: "Address book: list, add, delete, block, unblock, report",
2021
GroupID: "core",
2122
}
2223
cmd.AddCommand(list.New(f, nil))
2324
cmd.AddCommand(add.New(f, nil))
2425
cmd.AddCommand(del.New(f, nil))
2526
cmd.AddCommand(block.New(f, nil))
2627
cmd.AddCommand(unblock.New(f, nil))
28+
cmd.AddCommand(report.New(f, nil))
2729
return cmd
2830
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Package report implements "tg contact report <ref>".
2+
package report
3+
4+
import (
5+
"context"
6+
"fmt"
7+
8+
"github.com/gotd/td/telegram/peers"
9+
"github.com/gotd/td/tg"
10+
"github.com/spf13/cobra"
11+
12+
actioncontact "github.com/vika2603/telegram-cli/internal/action/contact"
13+
"github.com/vika2603/telegram-cli/internal/command"
14+
"github.com/vika2603/telegram-cli/internal/runtime"
15+
"github.com/vika2603/telegram-cli/internal/telegram"
16+
"github.com/vika2603/telegram-cli/internal/telegram/peer"
17+
"github.com/vika2603/telegram-cli/internal/ui"
18+
)
19+
20+
// Options holds the resolved flags and injected dependencies for Run.
21+
type Options struct {
22+
RawRef string
23+
Reason string
24+
Message string
25+
Block bool
26+
Yes bool
27+
28+
IOStreams *ui.IOStreams
29+
Prompter ui.Prompter
30+
31+
// Report is the closure that performs the actual Telegram call. Production
32+
// code sets it via newReport; tests stub it directly.
33+
Report actioncontact.ReportFunc
34+
}
35+
36+
// New builds the cobra command for "tg contact report".
37+
func New(f *runtime.Invocation, runF func(*Options) error) *cobra.Command {
38+
opts := &Options{}
39+
cmd := &cobra.Command{
40+
Use: "report <ref>",
41+
Short: "Report a user, bot, group, or channel to Telegram",
42+
Args: cobra.ExactArgs(1),
43+
RunE: func(cmd *cobra.Command, args []string) error {
44+
opts.RawRef = args[0]
45+
opts.IOStreams = f.IOStreams
46+
opts.Prompter = f.Prompter
47+
if runF != nil {
48+
return runF(opts)
49+
}
50+
opts.Report = newReport(f)
51+
return Run(cmd.Context(), opts)
52+
},
53+
}
54+
cmd.Flags().StringVar(&opts.Reason, "reason", "spam", "Report reason: spam, violence, porn, child-abuse, copyright, fake, drugs, personal-details, geo-irrelevant, other")
55+
cmd.Flags().StringVar(&opts.Message, "message", "", "Optional comment for report moderation")
56+
cmd.Flags().BoolVar(&opts.Block, "block", false, "Also block the peer after reporting")
57+
cmd.Flags().BoolVarP(&opts.Yes, "yes", "y", false, "Skip confirmation prompt")
58+
command.SetMeta(cmd, command.Meta{NeedsAccount: true, NeedsClient: true})
59+
return cmd
60+
}
61+
62+
// Run collects the raw request, delegates validation/mutation, and renders status.
63+
func Run(ctx context.Context, opts *Options) error {
64+
if err := actioncontact.Report(ctx, actioncontact.ReportRequest{
65+
RawRef: opts.RawRef,
66+
Reason: opts.Reason,
67+
Message: opts.Message,
68+
Block: opts.Block,
69+
Yes: opts.Yes,
70+
Prompter: opts.Prompter,
71+
}, opts.Report); err != nil {
72+
return err
73+
}
74+
verb := "reported"
75+
if opts.Block {
76+
verb = "reported+blocked"
77+
}
78+
_, _ = fmt.Fprintf(opts.IOStreams.Out, "%s\t%s\n", verb, opts.RawRef)
79+
return nil
80+
}
81+
82+
// newReport returns the production Report closure that calls the Telegram API.
83+
func newReport(f *runtime.Invocation) actioncontact.ReportFunc {
84+
return func(ctx context.Context, q actioncontact.ReportQuery) error {
85+
acct, err := f.Account("")
86+
if err != nil {
87+
return err
88+
}
89+
if cl, _ := runtime.MaybeDialDaemon(ctx, f, acct); cl != nil {
90+
defer func() { _ = cl.Close() }()
91+
_, err := cl.Call(ctx, "contact.report", q)
92+
return err
93+
}
94+
return f.WithPeers(ctx, acct, runtime.ClientOptsFrom(f, acct),
95+
func(ctx context.Context, api *tg.Client, _ *peers.Manager, res *peer.Resolver) error {
96+
return telegram.ReportPeer(ctx, api, res, q)
97+
})
98+
}
99+
}

internal/daemon/handlers.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,17 @@ func registerHandlers(
274274
return json.RawMessage("true"), nil
275275
})
276276

277+
srv.Register("contact.report", func(ctx context.Context, params json.RawMessage) (json.RawMessage, error) {
278+
var q actioncontact.ReportQuery
279+
if err := json.Unmarshal(params, &q); err != nil { //nolint:musttag
280+
return nil, fmt.Errorf("invalid contact.report params: %w", err)
281+
}
282+
if err := telegram.ReportPeer(ctx, api, res, q); err != nil {
283+
return nil, err
284+
}
285+
return json.RawMessage("true"), nil
286+
})
287+
277288
srv.Register("profile.set_name", func(ctx context.Context, params json.RawMessage) (json.RawMessage, error) {
278289
var q actionprofile.SetNameRequest
279290
if err := json.Unmarshal(params, &q); err != nil { //nolint:musttag

internal/daemon/handlers_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ func TestRegisterHandlers_BindsExpectedMethods(t *testing.T) {
4242
"contact.delete",
4343
"contact.block",
4444
"contact.unblock",
45+
"contact.report",
4546
"profile.set_name",
4647
"profile.set_bio",
4748
"profile.set_username",

internal/telegram/contacts.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,58 @@ func UnblockContact(ctx context.Context, api *tg.Client, resolver *peer.Resolver
115115
return err
116116
}
117117

118+
// ReportPeer reports one resolved peer to Telegram moderation via
119+
// account.reportPeer with the given reason and optional comment. When q.Block
120+
// is set it also blocks the peer (contacts.block) after a successful report.
121+
func ReportPeer(ctx context.Context, api *tg.Client, resolver *peer.Resolver, q actioncontact.ReportQuery) error {
122+
resolved, err := resolver.Resolve(ctx, q.Ref)
123+
if err != nil {
124+
return err
125+
}
126+
if _, err := api.AccountReportPeer(ctx, &tg.AccountReportPeerRequest{
127+
Peer: resolved.InputPeer,
128+
Reason: reportReason(q.Reason),
129+
Message: q.Message,
130+
}); err != nil {
131+
return err
132+
}
133+
if q.Block {
134+
if _, err := api.ContactsBlock(ctx, &tg.ContactsBlockRequest{ID: resolved.InputPeer}); err != nil {
135+
return err
136+
}
137+
}
138+
return nil
139+
}
140+
141+
// reportReason maps a validated reason keyword to a tg.ReportReasonClass. Keep
142+
// the cases in sync with ReportReasons in internal/action/contact/report.go;
143+
// the action layer validates the keyword before it reaches here, so the default
144+
// is only a safety net.
145+
func reportReason(reason string) tg.ReportReasonClass {
146+
switch reason {
147+
case "violence":
148+
return &tg.InputReportReasonViolence{}
149+
case "porn":
150+
return &tg.InputReportReasonPornography{}
151+
case "child-abuse":
152+
return &tg.InputReportReasonChildAbuse{}
153+
case "copyright":
154+
return &tg.InputReportReasonCopyright{}
155+
case "fake":
156+
return &tg.InputReportReasonFake{}
157+
case "drugs":
158+
return &tg.InputReportReasonIllegalDrugs{}
159+
case "personal-details":
160+
return &tg.InputReportReasonPersonalDetails{}
161+
case "geo-irrelevant":
162+
return &tg.InputReportReasonGeoIrrelevant{}
163+
case "other":
164+
return &tg.InputReportReasonOther{}
165+
default:
166+
return &tg.InputReportReasonSpam{}
167+
}
168+
}
169+
118170
func listBlockedContacts(ctx context.Context, api *tg.Client) ([]output.ContactRow, error) {
119171
var out []output.ContactRow
120172
err := query.GetBlocked(api).ForEach(ctx, func(_ context.Context, e blocked.Elem) error {

0 commit comments

Comments
 (0)