Compare commits
64 commits
669e515061
...
6649b70d5e
| Author | SHA1 | Date | |
|---|---|---|---|
| 6649b70d5e | |||
| 7fef8438c5 | |||
| ecdb14aaca | |||
| b565efe9d1 | |||
| c1c7db325f | |||
| 798ea70b4e | |||
| 7f0921c82c | |||
| 2e60301a08 | |||
| e81742ba51 | |||
| 38112062a1 | |||
| 8df27528b9 | |||
| 655fdacb0a | |||
| 9353953cf4 | |||
| b71f8d7fa7 | |||
| 2e96d9490e | |||
| 2d42158ec5 | |||
| 367f2be59c | |||
| 5b2f68c61e | |||
| 90af881867 | |||
| 37a3462a22 | |||
| 4e2cf7c5ac | |||
| 93a4ed2bca | |||
| 241fd442fb | |||
| 7dbb931b31 | |||
| 65aaa263b1 | |||
| 515038aace | |||
| d9ee8cbee0 | |||
| 71da34607e | |||
| 578eb5a1f6 | |||
| 3229bfa6b3 | |||
| 36d0f19767 | |||
| 25008fddc5 | |||
| 284d8be365 | |||
| 485323dbfa | |||
| f940d3403b | |||
| 0e2fc6705b | |||
| a3fc5ba3b5 | |||
| f478c87a56 | |||
| c939f7c92e | |||
| 686adc1e10 | |||
| 7083faa786 | |||
| 7d280b6f8a | |||
| 1c477fe32a | |||
| 31a6ab4887 | |||
| f94edc6cad | |||
| 5f65a47d89 | |||
| 7b662ad647 | |||
| be3623797e | |||
| 55a6e0bb35 | |||
| 2c782aab95 | |||
| 879ff48b49 | |||
| 7daaaf164f | |||
| 045f39c3f9 | |||
| bf22bd4006 | |||
| 6d34843fd0 | |||
| f26468ef6d | |||
| 233a2399e9 | |||
| f8aefb7fd5 | |||
| b3281d66d7 | |||
| 8bc54fbfc5 | |||
| 165f81414c | |||
| 11142f74c5 | |||
| 1ebf8ddb5a | |||
| 80e99e8781 |
110 changed files with 5490 additions and 383 deletions
5
.containerignore
Normal file
5
.containerignore
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.git
|
||||
bin
|
||||
*.pem
|
||||
.env
|
||||
.env.*
|
||||
32
Containerfile
Normal file
32
Containerfile
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
FROM docker.io/library/golang:1.25 AS build
|
||||
# Build metadata for telesrv's startup log (git_commit/git_branch/... in
|
||||
# cmd/telesrv/buildinfo.go). .containerignore excludes .git, so go build's
|
||||
# automatic VCS stamping sees no repo; pass these in explicitly, e.g.:
|
||||
# podman build \
|
||||
# --build-arg GIT_COMMIT="$(git rev-parse HEAD)" \
|
||||
# --build-arg GIT_BRANCH="$(git rev-parse --abbrev-ref HEAD)" \
|
||||
# --build-arg GIT_TREE_STATE="$(git diff --quiet && echo clean || echo dirty)" \
|
||||
# --build-arg BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
# -t owpengram-server -f Containerfile .
|
||||
ARG GIT_COMMIT=unknown
|
||||
ARG GIT_BRANCH=unknown
|
||||
ARG GIT_TREE_STATE=unknown
|
||||
ARG BUILD_TIME=unknown
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags "-X main.gitCommit=${GIT_COMMIT} -X main.gitBranch=${GIT_BRANCH} -X main.gitTreeState=${GIT_TREE_STATE} -X main.buildTime=${BUILD_TIME}" \
|
||||
-o /out/gramsrv ./cmd/telesrv
|
||||
RUN CGO_ENABLED=0 go build -trimpath -o /out/telesrv-admin ./cmd/telesrv-admin
|
||||
RUN CGO_ENABLED=0 go build -trimpath -o /out/createuser ./cmd/createuser
|
||||
|
||||
FROM docker.io/library/alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates tzdata ffmpeg
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/gramsrv /app/gramsrv
|
||||
COPY --from=build /out/telesrv-admin /app/telesrv-admin
|
||||
COPY --from=build /out/createuser /app/createuser
|
||||
COPY --from=build /src/data/langpack /app/data/langpack
|
||||
COPY --from=build /src/data/sticker-seed /app/data/sticker-seed
|
||||
EXPOSE 2398 2600
|
||||
ENTRYPOINT ["/app/gramsrv"]
|
||||
47
build.sh
Executable file
47
build.sh
Executable file
|
|
@ -0,0 +1,47 @@
|
|||
#!/usr/bin/env bash
|
||||
# Build the owpengram-server container image (stamping the current git state into
|
||||
# the binary - .containerignore excludes .git, so go build can't see the repo and
|
||||
# the values are passed in here), then restart the owpengram-server and
|
||||
# owpengram-admin systemd services so they pick up the freshly built image.
|
||||
# Container creation/lifecycle beyond the pod itself is owned by those systemd
|
||||
# units, not this script.
|
||||
#
|
||||
# Usage: ./build.sh [extra podman build args...]
|
||||
# IMAGE=my/tag ./build.sh override the image tag (default: owpengram-server)
|
||||
# POD=name ./build.sh override the pod name (default: owpengram)
|
||||
# NO_DEPLOY=1 ./build.sh build the image only, don't restart the services
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
IMAGE="${IMAGE:-owpengram-server}"
|
||||
POD="${POD:-owpengram}"
|
||||
|
||||
podman build \
|
||||
--build-arg GIT_COMMIT="$(git rev-parse HEAD)" \
|
||||
--build-arg GIT_BRANCH="$(git rev-parse --abbrev-ref HEAD)" \
|
||||
--build-arg GIT_TREE_STATE="$(git diff --quiet && echo clean || echo dirty)" \
|
||||
--build-arg BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
-t "$IMAGE" \
|
||||
-f Containerfile \
|
||||
"$@" \
|
||||
.
|
||||
|
||||
if [ "${NO_DEPLOY:-0}" = "1" ]; then
|
||||
echo "built $IMAGE (NO_DEPLOY=1, services unchanged)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! podman pod exists "$POD"; then
|
||||
echo "pod '$POD' does not exist - creating it"
|
||||
podman pod create --name "$POD" \
|
||||
-p 2398:2398 \
|
||||
-p 127.0.0.1:2600:2600 \
|
||||
-p 2400:2400 \
|
||||
-p 2500:2500 \
|
||||
-p 12399:12399/udp \
|
||||
-p 12400:12400/udp \
|
||||
-p 12500-12999:12500-12999/udp
|
||||
fi
|
||||
|
||||
systemctl restart owpengram-server.service owpengram-admin.service
|
||||
systemctl --no-pager status owpengram-server.service owpengram-admin.service
|
||||
181
cmd/createuser/main.go
Normal file
181
cmd/createuser/main.go
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
// Command createuser inserts a users row with an operator-chosen id, bypassing
|
||||
// the normal users_id_seq auto-assignment. This works because users.id is
|
||||
// GENERATED BY DEFAULT AS IDENTITY (not GENERATED ALWAYS) -- an explicit id in
|
||||
// the INSERT is honored, the same mechanism ensureOfficialSystemUserWithDB
|
||||
// (internal/store/postgres/message_send.go) already relies on to seed the
|
||||
// built-in system accounts (ChatBot, BotFather, ...) at their fixed ids.
|
||||
//
|
||||
// Normal signup (auth.signUp) never lets a caller pick an id, so this exists
|
||||
// purely for local/dev tooling -- reserving a specific low id (below
|
||||
// OfficialSystemUserID=777000, say) for a test account.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// createuser -id 1000 [-first-name Test] [-last-name User] [-username testuser] -phone "15550001234"
|
||||
// createuser -id 1000 [-first-name Test] [-last-name User] [-username testuser] -email "test@example.com"
|
||||
//
|
||||
// -phone and -email are mutually exclusive: an email-signup account never
|
||||
// stores the address in users.phone directly (see internal/domain/emailphone.go)
|
||||
// -- it gets a synthetic "888"-prefixed display phone instead (the same one
|
||||
// assignEmailSignupDisplayPhone hands a real email-signup account), with the
|
||||
// real address recorded separately in signup_email.
|
||||
//
|
||||
// Reads TELESRV_POSTGRES_DSN the same way the server does (internal/config).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"telesrv/internal/config"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// maxEmailSignupPhoneAttempts bounds the display-phone collision-retry loop,
|
||||
// mirroring internal/app/auth/service.go's own constant of the same name.
|
||||
const maxEmailSignupPhoneAttempts = 20
|
||||
|
||||
func randomInt64() (int64, error) {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return 0, fmt.Errorf("rand: %w", err)
|
||||
}
|
||||
return int64(binary.LittleEndian.Uint64(b[:])), nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
id := flag.Int64("id", 0, "user id to create (required)")
|
||||
firstName := flag.String("first-name", "Test", "first name")
|
||||
lastName := flag.String("last-name", "", "last name")
|
||||
username := flag.String("username", "", "username, without @ (optional)")
|
||||
phone := flag.String("phone", "", "phone number (optional; mutually exclusive with -email)")
|
||||
email := flag.String("email", "", "email address for an email-signup account (optional; mutually exclusive with -phone)")
|
||||
force := flag.Bool("force", false, "skip the reserved-id / sequence-collision safety checks")
|
||||
flag.Parse()
|
||||
|
||||
if *id <= 0 {
|
||||
fmt.Fprintln(os.Stderr, "createuser: -id is required and must be positive")
|
||||
os.Exit(2)
|
||||
}
|
||||
if *phone != "" && *email != "" {
|
||||
fmt.Fprintln(os.Stderr, "createuser: -phone and -email are mutually exclusive")
|
||||
os.Exit(2)
|
||||
}
|
||||
if !*force {
|
||||
if domain.IsSystemUserID(*id) {
|
||||
fmt.Fprintf(os.Stderr, "createuser: %d is a reserved built-in system account id (see internal/domain/system.go) - refusing, pass -force to override\n", *id)
|
||||
os.Exit(2)
|
||||
}
|
||||
if *id >= domain.UserIDSequenceBase {
|
||||
fmt.Fprintf(os.Stderr, "createuser: %d is >= UserIDSequenceBase (%d) - a future organic signup could eventually collide with it; pass -force to proceed anyway (then consider bumping users_id_seq yourself)\n", *id, domain.UserIDSequenceBase)
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "createuser: load config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
pool, err := pgxpool.New(ctx, cfg.PostgresDSN)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "createuser: connect: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
accessHash, err := randomInt64()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "createuser: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
displayPhone := *phone
|
||||
signupEmail := ""
|
||||
if *email != "" {
|
||||
signupEmail = domain.NormalizeEmailForPhone(*email)
|
||||
displayPhone, err = assignEmailSignupDisplayPhone(ctx, pool)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "createuser: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// phone/username/signup_email all sit under partial unique indexes that
|
||||
// exclude '', so leaving any of them blank never collides with another
|
||||
// blank-valued account.
|
||||
row := pool.QueryRow(ctx, `
|
||||
INSERT INTO users (id, access_hash, phone, signup_email, first_name, last_name, username, country_code)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, '')
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
RETURNING id`,
|
||||
*id, accessHash, displayPhone, signupEmail, *firstName, *lastName, *username)
|
||||
|
||||
var createdID int64
|
||||
if err := row.Scan(&createdID); err != nil {
|
||||
fmt.Fprintln(os.Stderr, describeInsertFailure(*id, *username, displayPhone, signupEmail, err))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("created user id=%d access_hash=%d first_name=%q last_name=%q username=%q phone=%q signup_email=%q\n",
|
||||
createdID, accessHash, *firstName, *lastName, *username, displayPhone, signupEmail)
|
||||
}
|
||||
|
||||
// describeInsertFailure turns the INSERT's failure into a message naming the
|
||||
// actual thing that collided, instead of "id already exists" for every case:
|
||||
// ON CONFLICT (id) DO NOTHING only covers the id itself, so a duplicate
|
||||
// username/phone/signup_email surfaces here as a distinct unique-violation
|
||||
// error (pgx.ErrNoRows only means the id itself was the conflict).
|
||||
func describeInsertFailure(id int64, username, phone, signupEmail string, err error) string {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
switch pgErr.ConstraintName {
|
||||
case "users_username_lower_unique_idx":
|
||||
return fmt.Sprintf("createuser: username %q is already taken", username)
|
||||
case "users_phone_unique_idx":
|
||||
return fmt.Sprintf("createuser: phone %q is already in use", phone)
|
||||
case "users_signup_email_lower_unique_idx":
|
||||
return fmt.Sprintf("createuser: email %q is already in use by another account", signupEmail)
|
||||
default:
|
||||
return fmt.Sprintf("createuser: unique constraint %q violated: %v", pgErr.ConstraintName, err)
|
||||
}
|
||||
}
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return fmt.Sprintf("createuser: id %d already exists", id)
|
||||
}
|
||||
return fmt.Sprintf("createuser: insert failed: %v", err)
|
||||
}
|
||||
|
||||
// assignEmailSignupDisplayPhone mirrors internal/app/auth/service.go's method
|
||||
// of the same name: pick a random "888"-prefixed display phone and re-roll on
|
||||
// the astronomically unlikely collision with an existing account's phone.
|
||||
func assignEmailSignupDisplayPhone(ctx context.Context, pool *pgxpool.Pool) (string, error) {
|
||||
for range maxEmailSignupPhoneAttempts {
|
||||
candidate, err := domain.NewEmailSignupDisplayPhone(domain.EmailPhonePrefix)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var exists bool
|
||||
if err := pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM users WHERE phone = $1)`, candidate).Scan(&exists); err != nil {
|
||||
return "", fmt.Errorf("check display phone collision: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("assign email signup display phone: exhausted %d attempts", maxEmailSignupPhoneAttempts)
|
||||
}
|
||||
|
|
@ -837,18 +837,21 @@ WITH auth AS (
|
|||
SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.updated_at,
|
||||
COALESCE(r.frozen, false), COALESCE(r.reason, ''), u.verified, u.scam, u.fake,
|
||||
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint,
|
||||
auth.last_active_at, auth.device_count,
|
||||
COALESCE(auth.last_active_at, '0001-01-01 00:00:00+00'::timestamptz), COALESCE(auth.device_count, 0)::int,
|
||||
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username,
|
||||
COALESCE(ap.login_email, ''),
|
||||
`+accountCollectibleUsernamesColumn+` AS collectibles
|
||||
FROM users u
|
||||
JOIN auth ON auth.user_id = u.id
|
||||
-- LEFT JOIN, not JOIN: an account with no authorizations (never finished login,
|
||||
-- all sessions revoked, frozen-then-unfrozen) must still appear here, matching
|
||||
-- CountAccounts and SearchAccounts.
|
||||
LEFT JOIN auth ON auth.user_id = u.id
|
||||
LEFT JOIN account_restrictions r ON r.user_id = u.id
|
||||
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id AND p.editable
|
||||
LEFT JOIN account_passwords ap ON ap.user_id = u.id
|
||||
WHERE NOT u.is_bot
|
||||
AND ($1::bigint = 0 OR (auth.last_active_at, u.id) < (to_timestamp(($1::double precision) / 1000000.0), $2::bigint))
|
||||
ORDER BY auth.last_active_at DESC, u.id DESC
|
||||
AND ($1::bigint = 0 OR (COALESCE(auth.last_active_at, '0001-01-01 00:00:00+00'::timestamptz), u.id) < (to_timestamp(($1::double precision) / 1000000.0), $2::bigint))
|
||||
ORDER BY COALESCE(auth.last_active_at, '0001-01-01 00:00:00+00'::timestamptz) DESC, u.id DESC
|
||||
LIMIT $3`, beforeActiveUS, beforeID, limit+1)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("list accounts: %w", err)
|
||||
|
|
|
|||
|
|
@ -47,8 +47,8 @@ VALUES ($1, $2, $3, 'Collector', '', $4, now(), now())`,
|
|||
userID, userID, "+1889"+suffix, editable); err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
// The list query joins authorizations, so an account with no device never
|
||||
// appears there at all; an authorization in turn needs its auth key to exist.
|
||||
// Give this account a device so its device_count / last_active columns are
|
||||
// exercised; an authorization needs its auth key to exist first.
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO auth_keys (auth_key_id, body, server_salt) VALUES ($1, '\x00', 0)`, userID); err != nil {
|
||||
t.Fatalf("seed auth key: %v", err)
|
||||
|
|
@ -123,6 +123,44 @@ WHERE peer_type='user' AND peer_id=$1 AND collectible_id IS NOT NULL`, userID);
|
|||
}
|
||||
}
|
||||
|
||||
// An account with no authorizations (never finished login, all sessions revoked,
|
||||
// frozen-then-unfrozen) must still show up in the Accounts tab - it did not,
|
||||
// because ListAccounts inner-joined the authorizations aggregate.
|
||||
func TestReadStoreListAccountsIncludesAccountsWithoutSessions(t *testing.T) {
|
||||
store, pool := verificationReadStore(t)
|
||||
ctx := context.Background()
|
||||
suffix := fmt.Sprintf("%d", time.Now().UnixNano()%1_000_000)
|
||||
userID := 3_700_000_000 + time.Now().UnixNano()%1_000_000
|
||||
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id=$1`, userID)
|
||||
})
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO users (id, access_hash, phone, first_name, last_name, username, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, 'Sessionless', '', '', now(), now())`,
|
||||
userID, userID, "+42777"+suffix); err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
// Deliberately no auth_keys / authorizations rows.
|
||||
|
||||
rows, _, err := store.ListAccounts(ctx, 0, 0, 500)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAccounts: %v", err)
|
||||
}
|
||||
found := false
|
||||
for i := range rows {
|
||||
if rows[i].ID == userID {
|
||||
found = true
|
||||
if rows[i].DeviceCount != 0 {
|
||||
t.Fatalf("device count = %d, want 0 for a sessionless account", rows[i].DeviceCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("sessionless account %d absent from ListAccounts (%d rows)", userID, len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
func assertCollectibles(t *testing.T, surface string, row AccountRow, editable string, want []AccountUsername) {
|
||||
t.Helper()
|
||||
if row.Username != editable {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"io/fs"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -74,6 +75,7 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("GET /api/messages/groups", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessagesAPI)))
|
||||
mux.Handle("GET /api/messages/groups/detail", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessageDetailAPI)))
|
||||
mux.Handle("GET /api/collectible-usernames", s.requireAuthAPI(http.HandlerFunc(s.handleCollectibleUsernamesAPI)))
|
||||
mux.Handle("GET /api/reserved-usernames", s.requireAuthAPI(http.HandlerFunc(s.handleReservedUsernamesAPI)))
|
||||
mux.Handle("GET /api/collectible-usernames/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleCollectibleUsernameDetailAPI)))
|
||||
mux.Handle("GET /api/storage/stats", s.requireAuthAPI(http.HandlerFunc(s.handleStorageStatsAPI)))
|
||||
mux.Handle("GET /api/storage/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleStorageAccountsAPI)))
|
||||
|
|
@ -128,6 +130,8 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("POST /api/actions/auto-categorize-gif-catalog", s.requireAuthAPI(http.HandlerFunc(s.handleAutoCategorizeGifCatalogAPI)))
|
||||
mux.Handle("POST /api/actions/delete-uncategorized-gifs", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteUncategorizedGifsAPI)))
|
||||
mux.Handle("POST /api/actions/delete-gif-catalog-entry", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteGifCatalogEntryAPI)))
|
||||
mux.Handle("POST /api/actions/reserve-username", s.requireAuthAPI(http.HandlerFunc(s.handleReserveUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/unreserve-username", s.requireAuthAPI(http.HandlerFunc(s.handleUnreserveUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/mint-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleMintCollectibleUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/transfer-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleTransferCollectibleUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/revoke-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeCollectibleUsernameAPI)))
|
||||
|
|
@ -2239,6 +2243,69 @@ type mintCollectibleUsernameAPIRequest struct {
|
|||
PurchaseDate flexUnix `json:"purchase_date"`
|
||||
}
|
||||
|
||||
type reserveUsernameAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
func (s *server) handleReserveUsernameAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body reserveUsernameAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.ReserveUsernameRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "reserve-username"),
|
||||
Username: body.Username,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/reserved-usernames/reserve", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
func (s *server) handleUnreserveUsernameAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body reserveUsernameAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.UnreserveUsernameRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "unreserve-username"),
|
||||
Username: body.Username,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/reserved-usernames/unreserve", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
func (s *server) handleReservedUsernamesAPI(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
params := url.Values{}
|
||||
for _, name := range []string{"q", "limit", "offset"} {
|
||||
if v := strings.TrimSpace(q.Get(name)); v != "" {
|
||||
params.Set(name, v)
|
||||
}
|
||||
}
|
||||
apiPath := "/v1/reserved-usernames"
|
||||
if enc := params.Encode(); enc != "" {
|
||||
apiPath += "?" + enc
|
||||
}
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, s.cfg.AdminAPIURL+apiPath, nil)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "request build failed")
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadGateway, "admin api unreachable")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func (s *server) handleMintCollectibleUsernameAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body mintCollectibleUsernameAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
9
cmd/telesrv-admin/web/dist/assets/index-fn4QJaPB.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-fn4QJaPB.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
cmd/telesrv-admin/web/dist/index.html
vendored
2
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -23,7 +23,7 @@
|
|||
})();
|
||||
</script>
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-Bt9UBcEE.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-fn4QJaPB.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CQKJMNpu.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import type {
|
|||
ChannelListResponse,
|
||||
CollectibleUsernameDetail,
|
||||
CollectibleUsernameListResponse,
|
||||
ReservedUsernameListResponse,
|
||||
CommandResult,
|
||||
GroupMessageDetail,
|
||||
GroupMessageListResponse,
|
||||
|
|
@ -163,6 +164,8 @@ export const api = {
|
|||
request<CollectibleUsernameListResponse>(`/api/collectible-usernames?${params.toString()}`),
|
||||
collectibleUsername: (id: string) =>
|
||||
request<CollectibleUsernameDetail>(`/api/collectible-usernames/${encodeURIComponent(id)}`),
|
||||
reservedUsernames: (params: URLSearchParams) =>
|
||||
request<ReservedUsernameListResponse>(`/api/reserved-usernames?${params.toString()}`),
|
||||
dashboard: () => request<DashboardResponse>("/api/dashboard"),
|
||||
storageStats: () => request<StorageStatsResponse>("/api/storage/stats"),
|
||||
storageAccounts: (params: URLSearchParams) =>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
AtSign,
|
||||
BadgeCheck,
|
||||
Ban,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
Database,
|
||||
|
|
@ -99,6 +100,7 @@ export function Shell({
|
|||
<NavLink icon={<Stamp size={16} />} href="/bot-verification" route={route} navigate={navigate}>{"Third-party marks"}</NavLink>
|
||||
)}
|
||||
<NavLink icon={<AtSign size={16} />} href="/collectible-usernames" route={route} navigate={navigate}>{"NFT Usernames"}</NavLink>
|
||||
<NavLink icon={<Ban size={16} />} href="/reserved-usernames" route={route} navigate={navigate}>{"Reserved Usernames"}</NavLink>
|
||||
<NavLink icon={<Database size={16} />} href="/storage" route={route} navigate={navigate}>{"Storage"}</NavLink>
|
||||
<NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{"Stickers"}</NavLink>
|
||||
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{"Emoji"}</NavLink>
|
||||
|
|
|
|||
176
cmd/telesrv-admin/web/src/pages/ReservedUsernamesPage.tsx
Normal file
176
cmd/telesrv-admin/web/src/pages/ReservedUsernamesPage.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { Loader2, Plus, RefreshCw, Search, Trash2, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Alert, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { formatUnix } from "../lib/format";
|
||||
import type { ReservedUsernameRow } from "../types";
|
||||
|
||||
// Reserved usernames are a plain operator blocklist: a name listed here cannot be
|
||||
// taken as an editable username by any peer and cannot be minted as a
|
||||
// collectible. No owner, no price, no "bought on Fragment" badge - that is the
|
||||
// collectible tab's job.
|
||||
export function ReservedUsernamesPage() {
|
||||
const [q, setQ] = useState("");
|
||||
const [reserveOpen, setReserveOpen] = useState(false);
|
||||
const [rows, setRows] = useState<ReservedUsernameRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const params = new URLSearchParams({ limit: "200" });
|
||||
if (q.trim()) params.set("q", q.trim().replace(/^@/, ""));
|
||||
try {
|
||||
const result = await api.reservedUsernames(params);
|
||||
setRows(result.reserved ?? []);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={"Reserved usernames"}
|
||||
eyebrow={"Usernames / Blocklist"}
|
||||
actions={
|
||||
<>
|
||||
<button className="btn primary icon-text" type="button" onClick={() => setReserveOpen(true)}>
|
||||
<Plus size={15} /> {"Reserve username"}
|
||||
</button>
|
||||
<button className="btn icon-text" type="button" onClick={() => load()} disabled={loading}>
|
||||
<RefreshCw size={15} className={loading ? "spin" : ""} /> {"Refresh"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row">
|
||||
<Metric label={"Reserved names"} value={String(rows.length)} />
|
||||
</div>
|
||||
|
||||
<QueryPanel>
|
||||
<form
|
||||
className="toolbar"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void load();
|
||||
}}
|
||||
>
|
||||
<label className="searchbox">
|
||||
<Search size={15} />
|
||||
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Filter by prefix"} />
|
||||
</label>
|
||||
<button className="btn primary icon-text" type="submit" disabled={loading}>
|
||||
{loading ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
|
||||
</button>
|
||||
</form>
|
||||
</QueryPanel>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"Username"}</th>
|
||||
<th>{"Reason"}</th>
|
||||
<th>{"Reserved by"}</th>
|
||||
<th>{"Reserved (UTC)"}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.username}>
|
||||
<td><strong>{`@${row.username}`}</strong></td>
|
||||
<td>{row.reason || "-"}</td>
|
||||
<td>{row.actor || "-"}</td>
|
||||
<td>{formatUnix(row.created_at) || "-"}</td>
|
||||
<td>
|
||||
<ActionButton
|
||||
compact
|
||||
label={"Unreserve"}
|
||||
icon={<Trash2 size={13} />}
|
||||
tone="danger"
|
||||
path="/api/actions/unreserve-username"
|
||||
payload={() => ({ username: row.username })}
|
||||
onDone={() => void load()}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && <EmptyRow colSpan={5} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{reserveOpen && (
|
||||
<ReserveUsernameModal
|
||||
onClose={() => setReserveOpen(false)}
|
||||
onDone={() => {
|
||||
setReserveOpen(false);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// ReserveUsernameModal collects the name, then hands off to ActionButton for the
|
||||
// standard reason / dry-run / confirm flow - the same as every other admin
|
||||
// action. The name is read fresh from state on each ActionButton render.
|
||||
function ReserveUsernameModal({ onClose, onDone }: { onClose: () => void; onDone: () => void }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const clean = username.trim().replace(/^@/, "");
|
||||
|
||||
return createPortal(
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={"Reserve a username"}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<div className="eyebrow">{"Usernames"}</div>
|
||||
<h2>{"Reserve a username"}</h2>
|
||||
</div>
|
||||
<button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="command-body">
|
||||
<label className="form-field">
|
||||
<span>{"Username"}</span>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="support"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<p className="bot-create-note">
|
||||
{`No peer will be able to take @${clean || "…"} until it is unreserved. Nothing is shown to users.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" type="button" onClick={onClose}>{"Close"}</button>
|
||||
<ActionButton
|
||||
disabled={clean.length < 5}
|
||||
label={"Reserve username"}
|
||||
icon={<Plus size={15} />}
|
||||
tone="neutral"
|
||||
path="/api/actions/reserve-username"
|
||||
payload={() => ({ username: clean })}
|
||||
onDone={onDone}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { AccountsPage } from "./AccountsPage";
|
|||
import { SharedDevicesPage } from "./SharedDevicesPage";
|
||||
import { CollectibleUsernameDetailPage } from "./CollectibleUsernameDetailPage";
|
||||
import { CollectibleUsernamesPage } from "./CollectibleUsernamesPage";
|
||||
import { ReservedUsernamesPage } from "./ReservedUsernamesPage";
|
||||
import { ChannelDetailPage } from "./ChannelDetailPage";
|
||||
import { ChannelsPage } from "./ChannelsPage";
|
||||
import { BotDetailPage } from "./BotDetailPage";
|
||||
|
|
@ -82,6 +83,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
|||
if (route.path === "/collectible-usernames") {
|
||||
return <CollectibleUsernamesPage navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/reserved-usernames") {
|
||||
return <ReservedUsernamesPage />;
|
||||
}
|
||||
if (route.path === "/storage") {
|
||||
return <StoragePage navigate={navigate} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export function routeTitle(pathname: string): string {
|
|||
if (pathname.startsWith("/bot-verification")) return "Third-party verification";
|
||||
if (pathname.startsWith("/verification")) return "Official Verification";
|
||||
if (pathname.startsWith("/collectible-usernames")) return "Collectible Usernames";
|
||||
if (pathname.startsWith("/reserved-usernames")) return "Reserved Usernames";
|
||||
if (pathname.startsWith("/storage")) return "Storage";
|
||||
if (pathname.startsWith("/accounts/shared-devices")) return "Shared Devices";
|
||||
if (pathname.startsWith("/accounts")) return "Accounts";
|
||||
|
|
|
|||
|
|
@ -347,6 +347,17 @@ export type CollectibleUsernameDetail = {
|
|||
transfers: CollectibleUsernameTransferRow[] | null;
|
||||
};
|
||||
|
||||
export type ReservedUsernameRow = {
|
||||
username: string;
|
||||
reason: string;
|
||||
actor: string;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
export type ReservedUsernameListResponse = {
|
||||
reserved: ReservedUsernameRow[] | null;
|
||||
};
|
||||
|
||||
// Official platform verification. Every int64 the backend tags `,string` stays a
|
||||
// decimal string here: application ids, peer ids and the optimistic-locking
|
||||
// version all outgrow the exact range of a JSON number, and a rounded version
|
||||
|
|
|
|||
|
|
@ -1007,13 +1007,16 @@ func run(logger *zap.Logger) error {
|
|||
account.WithLoginEmailVerification(codeStore, loginEmailSender, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts, cfg.LoginEmailCodeLength))
|
||||
}
|
||||
accountService := account.NewService(passwordStore, accountOptions...)
|
||||
reservedUsernameStore := postgres.NewReservedUsernameStore(pool)
|
||||
botsService := botsapp.NewService(userStore, botStore, messageStore,
|
||||
botsapp.WithLogger(logger.Named("bots")),
|
||||
botsapp.WithBlockChecker(contactStore),
|
||||
botsapp.WithPublicChannelUsernameResolver(channelStore),
|
||||
botsapp.WithReservedUsernames(reservedUsernameStore),
|
||||
botsapp.WithUserCache(userCache),
|
||||
botsapp.WithStickerSetCreator(filesService),
|
||||
botsapp.WithGifCatalogSource(filesService),
|
||||
botsapp.WithBotAvatarStore(filesService),
|
||||
botsapp.WithUserStickerSets(accountService),
|
||||
botsapp.WithTelegramLogin(telegramLoginService),
|
||||
botsapp.WithDialogRateLimiter(rateLimiter, cfg.VerificationBotRateLimit, cfg.VerificationBotRateWindow),
|
||||
|
|
@ -1389,6 +1392,7 @@ func run(logger *zap.Logger) error {
|
|||
Emoji: filesService,
|
||||
Moderation: moderationService,
|
||||
Usernames: usernamesService,
|
||||
ReservedUsernames: reservedUsernameStore,
|
||||
Verification: verificationService,
|
||||
BotVerification: botVerificationService,
|
||||
Account: accountService,
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS public.reserved_usernames;
|
||||
17
deploy/migrations/20260909190000_reserved_usernames.up.sql
Normal file
17
deploy/migrations/20260909190000_reserved_usernames.up.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
-- Operator-maintained username blocklist. A name listed here cannot be taken as
|
||||
-- an editable username by any peer (account.updateUsername, channels.updateUsername,
|
||||
-- @BotFather /setusername, or the admin set-username actions). It is a plain
|
||||
-- blocklist: no owner, no price, no Fragment collectible badge.
|
||||
|
||||
CREATE TABLE public.reserved_usernames (
|
||||
username_lower text PRIMARY KEY CHECK (
|
||||
username_lower <> '' AND username_lower = lower(username_lower)
|
||||
),
|
||||
username text NOT NULL,
|
||||
reason text NOT NULL DEFAULT '' CHECK (octet_length(reason) <= 512),
|
||||
actor text NOT NULL DEFAULT '' CHECK (octet_length(actor) <= 256),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX reserved_usernames_created_at_idx
|
||||
ON public.reserved_usernames (created_at DESC, username_lower);
|
||||
286
docs/telegram-feature-comparison.md
Normal file
286
docs/telegram-feature-comparison.md
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
# OwpenGram Server vs. Telegram - Feature Comparison
|
||||
|
||||
This document compares what OwpenGram Server actually implements against the
|
||||
behaviour of Telegram's official server, as seen through the MTProto API
|
||||
(layer 228) and the official clients (Telegram Desktop is the primary target,
|
||||
with Android / iOS / Web compatibility paths).
|
||||
|
||||
It is written from the server code in this repository: ~633 canonical
|
||||
`registerRPC` handlers across `internal/rpc`, plus the domain services in
|
||||
`internal/app/*`. "Telegram" below means the closed official backend.
|
||||
|
||||
Legend:
|
||||
|
||||
- **Full** - implemented with real server-side state and semantics
|
||||
- **Partial** - core paths work; edges, scale features, or moderation depth missing
|
||||
- **Stub** - RPC is answered with a fixed/empty valid response so clients don't
|
||||
hang or crash, but there is no feature behind it
|
||||
- **None** - not registered / not implemented
|
||||
|
||||
---
|
||||
|
||||
## 1. Transport, auth keys, sessions
|
||||
|
||||
| Area | Telegram | OwpenGram | Status |
|
||||
|---|---|---|---|
|
||||
| MTProto 2.0 over TCP | Full | TCP transport, RSA key exchange, auth keys, salts, ack/resend, bad-msg notifications, RPC dispatch | Full |
|
||||
| Obfuscated / padded intermediate / other transports | Full (TCP, HTTP, WS, obfuscation) | intermediate / padded-intermediate focus | Partial |
|
||||
| Multiple data centers, CDN DCs, DC migration | Full (5+ DCs, `PHONE_MIGRATE`, CDN file DCs) | Single logical DC; `help.getConfig` advertises one; no CDN redirects | None (by design) |
|
||||
| Perfect-forward-secrecy temp auth keys (`auth.bindTempAuthKey`) | Full | Implemented | Full |
|
||||
| Takeout / data export sessions | Full (`account.initTakeoutSession`) | Not registered | None |
|
||||
| Web / bot authorizations listing & reset | Full | `account.getWebAuthorizations`, `resetWebAuthorization(s)` | Full |
|
||||
|
||||
## 2. Login and accounts
|
||||
|
||||
| Area | Telegram | OwpenGram | Status |
|
||||
|---|---|---|---|
|
||||
| Phone-number login with SMS/flash-call/app code | Full, global SMS delivery | Dev login code; external delivery via SMS webhook or SMTP; email as identity (no phone required) | Partial (delivery is operator-provided) |
|
||||
| QR-code login (`auth.exportLoginToken` / `acceptLoginToken`) | Full | Implemented | Full |
|
||||
| Cloud password / 2FA (SRP) | Full | `account.getPassword`, `updatePasswordSettings`, `auth.checkPassword`, recovery e-mail, `resetPassword` | Full |
|
||||
| Passkey / WebAuthn sign-in | Not in official server | `auth.initPasskeyLogin` / `finishPasskeyLogin`, `account.*Passkey*` | Extra (OwpenGram-only) |
|
||||
| "Login with Telegram" as an OIDC provider for 3rd-party sites | Not applicable | Self-hosted OpenID Connect provider (`internal/telegramloginhttp`) | Extra |
|
||||
| Sign-up, terms of service, delete account, account TTL | Full | Implemented incl. `account.setAccountTTL`, `deleteAccount` with reason | Full |
|
||||
| Login email as second factor | Full | Implemented | Full |
|
||||
| Active sessions / authorizations management | Full | `account.getAuthorizations`, `resetAuthorization`, TTL, `changeAuthorizationSettings` | Full |
|
||||
|
||||
## 3. Users, contacts, privacy
|
||||
|
||||
| Area | Telegram | OwpenGram | Status |
|
||||
|---|---|---|---|
|
||||
| Profiles, bios, profile photos, personal channel | Full | `users.getFullUser`, `photos.*`, `account.updatePersonalChannel` | Full |
|
||||
| Usernames + collectible/fragment usernames | Full | Mint / transfer / activate, `account.reorderUsernames`, `fragment.getCollectibleInfo` | Full |
|
||||
| Contact import / export / search / resolve phone | Full | `contacts.importContacts`, `resolvePhone`, `search`, close friends | Full |
|
||||
| Blocked list, privacy rules, "who can..." keys | Full (all privacy keys) | `account.getPrivacy` / `setPrivacy`, global privacy settings, blocked list | Partial (common keys; some newer keys may be defaulted) |
|
||||
| Presence / last seen | Full | `account.updateStatus`, `contacts.getStatuses`, presence fan-out | Full |
|
||||
| Birthdays, close friends, contact notes | Full | `contacts.getBirthdays`, `editCloseFriends`, `updateContactNote` | Full |
|
||||
| Global name search directory | Full | `contacts.search` over local users/chats | Partial (local instance only) |
|
||||
|
||||
## 4. Messaging (private chats)
|
||||
|
||||
| Area | Telegram | OwpenGram | Status |
|
||||
|---|---|---|---|
|
||||
| Send / edit / delete / forward / reply | Full | `messages.sendMessage` / `sendMedia` / `sendMultiMedia` / `editMessage` / `forwardMessages` | Full |
|
||||
| Rich entities, formatted text, link previews | Full | Entities, `messages.getWebPage` / `getWebPagePreview`, TDesktop rich messages | Full |
|
||||
| Albums / grouped media | Full | `album_group` grouping | Full |
|
||||
| Reactions (emoji + custom emoji), paid reactions | Full incl. paid (Stars) | `sendReaction`, available/recent/top/default reactions, tags; **no paid reactions** | Partial |
|
||||
| Scheduled messages | Full | `getScheduledHistory`, `sendScheduledMessages`, `deleteScheduledMessages` | Full |
|
||||
| Self-destruct / TTL, default history TTL | Full | `setHistoryTTL`, `setDefaultHistoryTTL`, TTL-oriented paths | Partial |
|
||||
| Read receipts, read date, "who read" in groups | Full | `readHistory`, `getOutboxReadDate`, `getMessageReadParticipants` | Full |
|
||||
| Drafts (incl. cloud drafts sync) | Full | `saveDraft`, `getAllDrafts`, `clearAllDrafts` | Full |
|
||||
| Saved Messages, saved dialogs, pinned saved | Full | `getSavedDialogs`, `getSavedHistory`, `toggleSavedDialogPin` | Full |
|
||||
| Quick replies (business) | Full | `getQuickReplies`, `sendQuickReplyMessages`, `editQuickReplyShortcut` | Full |
|
||||
| Translation (`messages.translateText`) | Full | Provider-backed batch translation, per-peer language, rate limits | Full |
|
||||
| Transcribe voice (`messages.transcribeAudio`) | Full (Premium) | Registered | Partial (provider-dependent) |
|
||||
| Fact-check / sponsored-message plumbing | Full | `getSponsoredMessages`, `viewSponsoredMessage`, `reportSponsoredMessage` | Partial (plumbing) |
|
||||
| To-do lists in messages | Full | `appendTodoList`, `toggleTodoCompleted` | Full |
|
||||
| Search: in-chat, global, by date, counters, calendar | Full | `messages.search`, `searchGlobal`, `getSearchCounters`, `getSearchResultsCalendar` / `Positions` | Full |
|
||||
|
||||
## 5. Groups, supergroups, channels
|
||||
|
||||
| Area | Telegram | OwpenGram | Status |
|
||||
|---|---|---|---|
|
||||
| Basic groups create / add / migrate to supergroup | Full | `messages.createChat`, `addChatUser`, `migrateChat` | Full |
|
||||
| Supergroups / channels create, join, leave, delete | Full | `channels.createChannel` / `joinChannel` / `leaveChannel` / `deleteChannel` | Full |
|
||||
| Admin rights, banned rights, default banned rights, ranks | Full | `channels.editAdmin` / `editBanned`, `editChatDefaultBannedRights`, `editChatParticipantRank` | Full |
|
||||
| Invite links (permanent, named, request-to-join, importers) | Full | `messages.exportChatInvite`, `getExportedChatInvites`, `hideChatJoinRequest`, importers | Full |
|
||||
| Participants list, admin log, hidden participants | Full | `channels.getParticipants`, `getAdminLog`, `toggleParticipantsHidden` | Full |
|
||||
| Forum topics | Full | `getForumTopics`, `editForumTopic`, pinned topics, view-as-messages | Full |
|
||||
| Linked discussion group | Full | `setDiscussionGroup`, `getGroupsForDiscussion`, `readDiscussion` | Full |
|
||||
| Slow mode, join-to-send, join-request, anti-spam, gigagroup | Full | `toggleSlowMode`, `toggleJoinToSend`, `toggleAntiSpam`, `convertToGigagroup` | Full |
|
||||
| Public username directory + previews for non-members | Full | `channels.searchPosts`, resolve, public landing pages | Full |
|
||||
| Boosts / boost level perks | Full | `premium.applyBoost`, `getBoostsStatus`, `getMyBoosts` | Partial (levels tracked; not all perks gated) |
|
||||
| Channel monetization, paid posts, suggested posts | Full (Stars/TON) | `service_suggested_post`, `toggleSuggestedPostApproval`, `updatePaidMessagesPrice` | Partial (no real payout ledger) |
|
||||
| Statistics (channel / megagroup / message / story) | Full | `stats.getBroadcastStats`, `getMegagroupStats`, `getMessageStats`, `loadAsyncGraph` | Partial (graphs from local data) |
|
||||
| Communities / peer links (layer 228) | Full | `communities.*` create / join / peer links / bans | Partial (new API) |
|
||||
|
||||
## 6. Media and files
|
||||
|
||||
| Area | Telegram | OwpenGram | Status |
|
||||
|---|---|---|---|
|
||||
| Upload / download, big-file parts, file hashes | Full | `upload.saveFilePart` / `saveBigFilePart` / `getFile` / `getFileHashes` | Full |
|
||||
| CDN-backed downloads, `upload.getCdnFile` | Full | None - always served from origin | None (by design) |
|
||||
| Storage backends | Google infra | Local disk **or** S3/MinIO-compatible, switchable per deployment; low-space guard; stale-media cleanup | Full (self-host) |
|
||||
| Web files / proxied external media (`upload.getWebFile`) | Full | Registered, external media fetch | Partial |
|
||||
| Photos, documents, thumbnails, GIFv conversion, video | Full | Implemented incl. canonical GIFv conversion | Full |
|
||||
| Web page previews, instant view | Full | Previews yes; **Instant View pages** no | Partial |
|
||||
| Map/venue tile cache | Full | Cache hooks only | Partial |
|
||||
|
||||
## 7. Stickers, emoji, GIFs
|
||||
|
||||
| Area | Telegram | OwpenGram | Status |
|
||||
|---|---|---|---|
|
||||
| Sticker sets install/archive/reorder, custom emoji, masks | Full | `messages.*StickerSet*`, `getCustomEmojiDocuments`, mask stickers | Full |
|
||||
| Create / edit own sticker set (`stickers.*`) | Full | `createStickerSet`, `addStickerToSet`, `renameStickerSet`, suggest short name | Full |
|
||||
| Featured / trending / recent / faved stickers | Full | `getFeaturedStickers`, `getRecentStickers`, `getFavedStickers` | Full |
|
||||
| Emoji keywords / groups / status | Full | `getEmojiKeywords*`, `getEmojiGroups`, emoji status incl. collectible | Full |
|
||||
| Saved GIFs + inline `@gif` catalog | Full | Admin-curated categorized `@gif` catalog, auto-save on send | Full (plus extras) |
|
||||
| Premium animated emoji / effects | Full | `messages.getAvailableEffects` | Partial |
|
||||
|
||||
## 8. Bots and mini apps
|
||||
|
||||
| Area | Telegram | OwpenGram | Status |
|
||||
|---|---|---|---|
|
||||
| Bot messaging, callbacks, inline mode | Full | `getInlineBotResults`, `sendInlineBotResult`, `getBotCallbackAnswer`, `setBotCallbackAnswer` | Full |
|
||||
| BotFather-style bot creation & config | Full | `bots.createBot`, `setBotInfo`, `setBotCommands`, menu button, usernames | Full |
|
||||
| Web apps / mini apps (`messages.requestWebView`, main/app/simple) | Full | `webViewRequest`, `mainWebViewRequest`, `appWebViewRequest`, `prolongWebView`, `sendWebViewData` | Full |
|
||||
| Attachment-menu bots | Full | `getAttachMenuBots`, `toggleBotInAttachMenu` | Full |
|
||||
| Bot API HTTP gateway (getUpdates / webhooks) | Full (`api.telegram.org`) | Minimal Bot API gateway in `internal/botapi`; persistent `getUpdates`, webhook delivery | Partial (subset of methods) |
|
||||
| Business connections / Business AI replies | Full | `account.getBotBusinessConnection`, connected bots, business automation, AI echo | Partial |
|
||||
| Bot payments (`payments.sendPaymentForm`, invoices) | Full | **Not registered** | None |
|
||||
| Star-ref / affiliate programs | Full | `bots.updateStarRefProgram` shell | Stub |
|
||||
| Games (`messages.setGameScore`, high scores) | Full | Registered incl. inline high scores | Full |
|
||||
|
||||
## 9. Calls and live streams
|
||||
|
||||
| Area | Telegram | OwpenGram | Status |
|
||||
|---|---|---|---|
|
||||
| 1:1 call signaling (DH, `phone.requestCall` ... `discardCall`) | Full | Full state machine with g_a hash commit, ring timeout, tombstones (`internal/app/phone`) | Full (signaling) |
|
||||
| Group calls / voice chats | Full | `phone.joinGroupCall`, participants, `editGroupCallParticipant`, titles, scheduled starts | Full (signaling/state) |
|
||||
| Conference calls (layer 228 chain blocks) | Full | `createConferenceCall`, `getGroupCallChainBlocks`, invite/decline | Partial |
|
||||
| Media relay | Global TURN + SFU fleet | SFU/TURN **building blocks** (`internal/sfu`, `internal/turnsrv`); operator must run relays | Partial |
|
||||
| RTMP live streaming into channels | Full | `phone.getGroupCallStreamRtmpUrl`, RTMP ingest, segmenter (`internal/app/livestream`) | Full |
|
||||
| Screen sharing / presentation | Full | `joinGroupCallPresentation` / `leaveGroupCallPresentation` | Full (signaling) |
|
||||
| Call debug / rating | Full | `saveCallDebug`, `setCallRating` | Full |
|
||||
|
||||
## 10. Stories
|
||||
|
||||
| Area | Telegram | OwpenGram | Status |
|
||||
|---|---|---|---|
|
||||
| Post / edit / delete stories, media | Full | `stories.sendStory`, `editStory`, `deleteStories`, `canSendStory` | Full |
|
||||
| Read state, views, viewers list, reactions | Full | `readStories`, `incrementStoryViews`, `getStoryViewsList`, `sendReaction` | Full |
|
||||
| Pinned stories / profile grid, archive | Full | `togglePinned`, `getPinnedStories`, `getStoriesArchive` | Full |
|
||||
| Story albums (layer 228) | Full | `createAlbum`, `getAlbumStories`, `reorderAlbums` | Full |
|
||||
| Stealth mode, hidden peers | Full | `activateStealthMode`, `toggleAllStoriesHidden`, `togglePeerStoriesHidden` | Full |
|
||||
| Channel stories + live stories | Full | Channel posting via access checks; `stories.startLive` | Partial |
|
||||
| Story boosts / repost / public forwards | Full | `stats.getStoryPublicForwards`, search posts | Partial |
|
||||
|
||||
## 11. Dialogs, sync, folders
|
||||
|
||||
| Area | Telegram | OwpenGram | Status |
|
||||
|---|---|---|---|
|
||||
| Dialog list, pinned, manual unread, archive folder | Full | `getDialogs`, `getPinnedDialogs`, `markDialogUnread`, `folders.editPeerFolders` | Full |
|
||||
| Chat folders / dialog filters, suggested filters | Full | `getDialogFilters`, `updateDialogFilter`, `getSuggestedDialogFilters`, filter tags | Full |
|
||||
| Shareable folders / chatlist invites | Full | `chatlists.exportChatlistInvite`, join/import, updates, revoked handling | Full |
|
||||
| Update sequencing: `updates.getState` / `getDifference` / `getChannelDifference` | Full | Implemented incl. durable updates, `pts`/`qts`/`seq`, offline difference recovery | Full |
|
||||
| Real-time push over the MTProto connection | Full | Online fan-out, reliable dispatch | Full |
|
||||
| **External push (APNs / FCM / GCM)** | Full | **None** - `account.registerDevice` only records the in-connection MTProto push session; other token types are ignored | None (by design) |
|
||||
| Notification settings / exceptions / reactions-notify | Full | `getNotifySettings`, `getNotifyExceptions`, `getReactionsNotifySettings` | Full |
|
||||
|
||||
## 12. Secret chats (E2E)
|
||||
|
||||
| Area | Telegram | OwpenGram | Status |
|
||||
|---|---|---|---|
|
||||
| DH handshake, `messages.requestEncryption` -> accept/discard | Full | State machine + id/access_hash allocation, blind g_a storage (`internal/app/secretchat`) | Full |
|
||||
| Encrypted message / file / service delivery, `qts` queue | Full | `sendEncrypted`, `sendEncryptedFile`, `sendEncryptedService`, `uploadEncryptedFile` | Full |
|
||||
| Encrypted typing, read history, spam report | Full | `setEncryptedTyping`, `readEncryptedHistory`, `reportEncryptedSpam` | Full |
|
||||
| Rekeying / perfect forward secrecy for long chats | Full | Handled at handshake layer; server is a blind relay | Partial |
|
||||
|
||||
## 13. Themes, wallpapers, appearance
|
||||
|
||||
| Area | Telegram | OwpenGram | Status |
|
||||
|---|---|---|---|
|
||||
| Wallpapers install/upload/reset, multi-wallpaper | Full | `account.getWallPapers`, `installWallPaper`, `getMultiWallPapers` | Full |
|
||||
| Custom themes create/install/update, chat themes | Full | `account.createTheme`, `installTheme`, `getChatThemes`, `messages.setChatTheme` | Full |
|
||||
| Peer colors, profile colors, name colors | Full | `help.getPeerColors`, `account.updateColor`, `channels.updateColor` | Full |
|
||||
| Unique-gift chat themes | Full | `account.getUniqueGiftChatThemes` (empty - no gifts) | Stub |
|
||||
| Ringtones | Full | `account.getSavedRingtones` | Full |
|
||||
|
||||
## 14. Payments, Stars, gifts, Premium - deliberately omitted
|
||||
|
||||
Telegram's economy layer is intentionally **not implemented**. OwpenGram keeps
|
||||
a handful of read-only RPCs answered with valid empty/zero responses purely so
|
||||
the official clients render "no Stars / no gifts" instead of hanging or
|
||||
retrying in a storm.
|
||||
|
||||
| Area | Telegram | OwpenGram |
|
||||
|---|---|---|
|
||||
| Invoice / payment form / shipping / checkout | Full (`payments.sendPaymentForm`, ...) | Not registered |
|
||||
| Telegram Stars balance, purchase, transactions | Full | `getStarsStatus` / `Subscriptions` / `Transactions` return zero balance, empty ledger |
|
||||
| Star gifts / collectible gifts / gift profiles | Full | `getStarGifts` / `getSavedStarGifts` return empty lists |
|
||||
| Google Play / App Store receipt verification | Full | `canPurchaseStore` returns `false`; `assignPlayMarketTransaction` -> `STORE_PAYMENT_UNAVAILABLE` |
|
||||
| Premium subscription, gift codes, giveaways | Full | `payments.getPremiumGiftCodeOptions` empty; Premium status can be granted server-side by the operator, not purchased |
|
||||
| TON / channel revenue withdrawal | Full | `getStarsRevenueStats` / `RevenueAdsAccountUrl` return fixed zero/compat values |
|
||||
| Business Stars / paid messages payout | Full | Price fields tracked; no real ledger or payout |
|
||||
|
||||
## 15. Moderation, admin, operations
|
||||
|
||||
| Area | Telegram | OwpenGram | Status |
|
||||
|---|---|---|---|
|
||||
| Report spam / peer / message / reaction / profile photo | Full | `messages.report`, `account.reportPeer`, `reportProfilePhoto`, `channels.reportSpam` | Full |
|
||||
| Global ban / spam-bot / account restrictions | Full (internal) | Per-account freeze (admin read-only restriction advertised via appConfig), moderation cases, appeal links (`internal/app/moderation`) | Partial |
|
||||
| Anti-spam service for groups | Full (`@GroupAnti-SpamBot`) | `channels.toggleAntiSpam`, `reportAntiSpamFalsePositive` | Partial |
|
||||
| Admin API + web UI | Not public | RBAC-scoped admin API tokens, web UI (`internal/adminapi`, `internal/web`) | Extra |
|
||||
| Broadcast / announcements to all users | Not public | Admin-panel broadcast from the official account to all or a picked list | Extra |
|
||||
| Shared-device detection across accounts | Internal | Implemented | Extra |
|
||||
| TUI server panel (setup wizard, start/stop, git-pull update, log tail, .env editor) | N/A | Bundled (`tui-panel`) | Extra |
|
||||
| Metrics, pprof, DB tracing, load-test harness | Internal | `internal/observability`, `internal/loadtest`, `internal/loadharness` | Extra |
|
||||
|
||||
## 16. Verification and badges
|
||||
|
||||
| Area | Telegram | OwpenGram | Status |
|
||||
|---|---|---|---|
|
||||
| Official blue checkmark | Full (manual) | `@verifybot` flow, verification worker | Full (self-host policy) |
|
||||
| Third-party bot verification mark (icon + label before a name) | Full (`bots.setCustomVerification`) | `@marksbot` mechanism - **experimental, hidden by default** | Partial |
|
||||
| Scam / fake labels | Full | Flag paths present | Partial |
|
||||
|
||||
## 17. Misc API surface
|
||||
|
||||
| Area | Telegram | OwpenGram | Status |
|
||||
|---|---|---|---|
|
||||
| `help.getConfig` / `getAppConfig` / `getCountriesList` / timezones / promo | Full | Implemented | Full |
|
||||
| Language packs (`langpack.*`) | Full | `getLangPack`, `getDifference`, `getStrings`, seeded packs | Full |
|
||||
| App update check (`help.getAppUpdate`) | Full | Registered | Partial (operator-fed) |
|
||||
| Deep-link info, t.me link resolution | Full | `help.getDeepLinkInfo`, public link landing pages | Full |
|
||||
| `contacts.getLocated` (nearby people/chats) | Full | Not registered | None |
|
||||
| Peer color / emoji-status catalogs | Full | Implemented | Full |
|
||||
| `smsjobs.*` (Android SMS relay income) | Full | Not registered | None |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Strong, near-complete parity:**
|
||||
|
||||
- MTProto edge, auth, sessions, PFS, 2FA/SRP
|
||||
- Private chat messaging: send/edit/delete/forward/reply/reactions/scheduled/
|
||||
drafts/search/translation/albums/rich text
|
||||
- Groups, supergroups, channels: admin model, invite links, forum topics,
|
||||
discussion groups, slow mode, anti-spam toggles, public search
|
||||
- Media pipeline with pluggable local-disk or S3 storage
|
||||
- Stickers / custom emoji / GIFs (with an extended `@gif` catalog)
|
||||
- Bots, inline mode, web apps / mini apps, games
|
||||
- Stories (incl. layer 228 albums, stealth mode)
|
||||
- Secret chats (E2E, server as blind relay)
|
||||
- Dialogs/folders/chatlists and the full update-difference sync machinery
|
||||
- Themes, wallpapers, peer colors, language packs
|
||||
|
||||
**Partial / compatibility-first:**
|
||||
|
||||
- Calls and live streams: signaling and state are complete; media relay needs
|
||||
an operator-run SFU/TURN
|
||||
- Boosts, channel/story statistics, communities, business features, conference
|
||||
calls: core flows present, some perks and edges not gated
|
||||
- Bot API HTTP gateway: a useful subset, not the full `api.telegram.org`
|
||||
- Moderation: instance-level tooling exists, no global trust-and-safety network
|
||||
|
||||
**Deliberately not implemented:**
|
||||
|
||||
- The entire payments/economy layer: invoices, Telegram Stars, star gifts,
|
||||
Premium purchase, TON/revenue withdrawal, Play/App Store billing. Read-only
|
||||
RPCs return valid empty responses so clients don't break.
|
||||
- External push notifications (APNs / FCM / GCM). Delivery is only over the
|
||||
live MTProto connection; `registerDevice` records the in-connection push
|
||||
session and ignores other token types.
|
||||
- Multi-DC / CDN infrastructure: one logical DC, files always served from
|
||||
origin, no DC-migration redirects.
|
||||
- `contacts.getLocated` (nearby), takeout sessions, `smsjobs.*`, Instant View.
|
||||
|
||||
**OwpenGram-only additions not in Telegram's server:**
|
||||
|
||||
- WebAuthn / passkey sign-in
|
||||
- Self-hosted "Login with Telegram" OpenID Connect provider
|
||||
- Admin API + web UI, RBAC admin tokens, admin broadcast/announcements
|
||||
- Shared-device detection
|
||||
- Bundled TUI server-operations panel
|
||||
- AI compose / rewrite with pluggable local or external providers
|
||||
- Pluggable S3/MinIO media backend with live switch
|
||||
2
go.mod
2
go.mod
|
|
@ -9,7 +9,7 @@ require (
|
|||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/gotd/ige v0.3.0
|
||||
github.com/gotd/log/logzap v0.1.1
|
||||
github.com/iamxvbaba/td v1.2.1
|
||||
github.com/iamxvbaba/td v1.3.2
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/lestrrat-go/jwx/v3 v3.1.1
|
||||
|
|
|
|||
4
go.sum
4
go.sum
|
|
@ -83,8 +83,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
|
|||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.0 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g4TbElacI=
|
||||
github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
|
||||
github.com/iamxvbaba/td v1.2.1 h1:5+Ji1F/tdrN8zUxeeEbTPHBQGSnTDE+UAH+8pQi7O1Y=
|
||||
github.com/iamxvbaba/td v1.2.1/go.mod h1:INkZJi18XbXtVOrldDnPtmQCJvIXhgDZdbo9MV/NY7M=
|
||||
github.com/iamxvbaba/td v1.3.2 h1:/EwvDU0oiArAof16WDDGfUXg/w02m2MqzDskCnXakZA=
|
||||
github.com/iamxvbaba/td v1.3.2/go.mod h1:INkZJi18XbXtVOrldDnPtmQCJvIXhgDZdbo9MV/NY7M=
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
|
|
|
|||
|
|
@ -66,6 +66,9 @@ const (
|
|||
ActionTransferCollectibleUsername = "usernames.collectible.transfer"
|
||||
ActionRevokeCollectibleUsername = "usernames.collectible.revoke"
|
||||
ActionDeleteCollectibleUsername = "usernames.collectible.delete"
|
||||
// Operator username blocklist.
|
||||
ActionReserveUsername = "usernames.reserve"
|
||||
ActionUnreserveUsername = "usernames.unreserve"
|
||||
// Official platform verification review. Claim/approve/reject act on one
|
||||
// application; revoke acts on a target, because clearing a badge is not a
|
||||
// decision on the application that granted it.
|
||||
|
|
@ -363,6 +366,16 @@ type CollectibleUsernamesService interface {
|
|||
Transfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error)
|
||||
}
|
||||
|
||||
// ReservedUsernamesService is the operator username blocklist: a plain list of
|
||||
// names no peer may take. Separate from the collectible lifecycle - a reservation
|
||||
// has no owner, no price and no Fragment badge.
|
||||
type ReservedUsernamesService interface {
|
||||
IsReserved(ctx context.Context, usernameLower string) (bool, error)
|
||||
ReserveUsername(ctx context.Context, username, reason, actor string) (created bool, err error)
|
||||
UnreserveUsername(ctx context.Context, username string) (removed bool, err error)
|
||||
ReservedUsernames(ctx context.Context, filter domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error)
|
||||
}
|
||||
|
||||
// collectibleUsernameByIDLookup is the optional by-identity read. Stores that
|
||||
// expose it answer a detail request in one round trip; the keyset fallback in
|
||||
// CollectibleUsernameByID keeps a service without it correct.
|
||||
|
|
@ -389,6 +402,7 @@ type Dependencies struct {
|
|||
Emoji EmojiService
|
||||
Moderation ModerationService
|
||||
Usernames CollectibleUsernamesService
|
||||
ReservedUsernames ReservedUsernamesService
|
||||
Verification VerificationService
|
||||
// BotVerification is the third-party mechanism, wired separately from
|
||||
// Verification: the two never read each other's state.
|
||||
|
|
@ -420,6 +434,7 @@ type Service struct {
|
|||
emoji EmojiService
|
||||
moderation ModerationService
|
||||
usernames CollectibleUsernamesService
|
||||
reservedUsernames ReservedUsernamesService
|
||||
verification VerificationService
|
||||
botVerification BotVerificationService
|
||||
account AccountService
|
||||
|
|
@ -487,6 +502,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
|
|||
if deps.Usernames != nil {
|
||||
s.usernames = deps.Usernames
|
||||
}
|
||||
if deps.ReservedUsernames != nil {
|
||||
s.reservedUsernames = deps.ReservedUsernames
|
||||
}
|
||||
if deps.Verification != nil {
|
||||
s.verification = deps.Verification
|
||||
}
|
||||
|
|
@ -2059,6 +2077,91 @@ func (s *Service) DeleteCollectibleUsername(ctx context.Context, req DeleteColle
|
|||
})
|
||||
}
|
||||
|
||||
// ReserveUsernameRequest / UnreserveUsernameRequest add or remove a blocklist
|
||||
// entry. reservedUsernameFromRequest normalises the name; the reason is a free
|
||||
// operator note.
|
||||
type ReserveUsernameRequest struct {
|
||||
CommandMeta
|
||||
Username string
|
||||
}
|
||||
|
||||
type UnreserveUsernameRequest struct {
|
||||
CommandMeta
|
||||
Username string
|
||||
}
|
||||
|
||||
// ReserveUsername adds a name to the operator blocklist. Journalled and
|
||||
// replay-safe like every other command.
|
||||
func (s *Service) ReserveUsername(ctx context.Context, req ReserveUsernameRequest) (CommandResult, error) {
|
||||
if s == nil || s.reservedUsernames == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin reserved username dependency is not configured")
|
||||
}
|
||||
req.Username = domain.NormalizeUsername(req.Username)
|
||||
if !domain.ValidCollectibleUsername(req.Username) {
|
||||
return CommandResult{}, codedError(CodeUsernameInvalid, domain.ErrUsernameInvalid)
|
||||
}
|
||||
if len(req.Reason) > domain.MaxReservedUsernameReasonLength {
|
||||
return CommandResult{}, fmt.Errorf("reason must be <= %d bytes", domain.MaxReservedUsernameReasonLength)
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionReserveUsername, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"username": req.Username}
|
||||
if s.usernames != nil {
|
||||
if asset, err := s.usernames.Collectible(ctx, req.Username); err == nil {
|
||||
details["existing_collectible_id"] = strconv.FormatInt(asset.ID, 10)
|
||||
return CommandResult{Details: details}, codedError(CodeUsernameOccupied, domain.ErrUsernameOccupied)
|
||||
}
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "username reservation validated", Details: details}, nil
|
||||
}
|
||||
created, err := s.reservedUsernames.ReserveUsername(ctx, req.Username, req.Reason, req.Actor)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["created"] = created
|
||||
message := "username reserved"
|
||||
if !created {
|
||||
message = "username was already reserved"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// UnreserveUsername removes a name from the operator blocklist.
|
||||
func (s *Service) UnreserveUsername(ctx context.Context, req UnreserveUsernameRequest) (CommandResult, error) {
|
||||
if s == nil || s.reservedUsernames == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin reserved username dependency is not configured")
|
||||
}
|
||||
req.Username = domain.NormalizeUsername(req.Username)
|
||||
if strings.TrimSpace(req.Username) == "" {
|
||||
return CommandResult{}, codedError(CodeUsernameInvalid, domain.ErrUsernameInvalid)
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionUnreserveUsername, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"username": req.Username}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "username unreservation validated", Details: details}, nil
|
||||
}
|
||||
removed, err := s.reservedUsernames.UnreserveUsername(ctx, req.Username)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["removed"] = removed
|
||||
message := "username unreserved"
|
||||
if !removed {
|
||||
message = "username was not reserved"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ReservedUsernames is the admin listing read for the blocklist.
|
||||
func (s *Service) ReservedUsernames(ctx context.Context, filter domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error) {
|
||||
if s == nil || s.reservedUsernames == nil {
|
||||
return nil, fmt.Errorf("reserved username dependency is not configured")
|
||||
}
|
||||
return s.reservedUsernames.ReservedUsernames(ctx, filter)
|
||||
}
|
||||
|
||||
func collectibleOwnerPeer(userID, channelID int64) (domain.Peer, error) {
|
||||
if userID < 0 || channelID < 0 {
|
||||
return domain.Peer{}, fmt.Errorf("owner id must be positive")
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
|
||||
usernamesapp "telesrv/internal/app/usernames"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// Compile-time proof that the shipped use-case services satisfy the admin ports.
|
||||
|
|
@ -1427,3 +1428,76 @@ func TestDeleteCollectibleUsernameCommand(t *testing.T) {
|
|||
t.Fatalf("delete of invalid name = nil error, want rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReserveAndUnreserveUsername(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reserved := memory.NewReservedUsernameStore()
|
||||
svc := NewService(Dependencies{
|
||||
Commands: newMemoryCommandRepo(),
|
||||
ReservedUsernames: reserved,
|
||||
Now: fixedNow,
|
||||
})
|
||||
|
||||
dry, err := svc.ReserveUsername(ctx, ReserveUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "rv-dry", Actor: "ops", Reason: "official handle", DryRun: true},
|
||||
Username: "@Support",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run reserve: %v", err)
|
||||
}
|
||||
if got, _ := reserved.IsReserved(ctx, "support"); got {
|
||||
t.Fatal("dry-run reserved the name")
|
||||
}
|
||||
if dry.Details["username"] != "Support" {
|
||||
t.Fatalf("dry-run details = %+v", dry.Details)
|
||||
}
|
||||
|
||||
if _, err := svc.ReserveUsername(ctx, ReserveUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "rv-exec", Actor: "ops", Reason: "official handle"},
|
||||
Username: "support",
|
||||
}); err != nil {
|
||||
t.Fatalf("reserve: %v", err)
|
||||
}
|
||||
if got, _ := reserved.IsReserved(ctx, "support"); !got {
|
||||
t.Fatal("name not reserved after exec")
|
||||
}
|
||||
|
||||
if _, err := svc.UnreserveUsername(ctx, UnreserveUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "urv-exec", Actor: "ops", Reason: "no longer needed"},
|
||||
Username: "SUPPORT",
|
||||
}); err != nil {
|
||||
t.Fatalf("unreserve: %v", err)
|
||||
}
|
||||
if got, _ := reserved.IsReserved(ctx, "support"); got {
|
||||
t.Fatal("name still reserved after unreserve")
|
||||
}
|
||||
|
||||
if _, err := svc.ReserveUsername(ctx, ReserveUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "bad", Actor: "ops", Reason: "x"},
|
||||
Username: "ab",
|
||||
}); err == nil {
|
||||
t.Fatal("reserve of a too-short name = nil error, want rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryRegistryRefusesReservedName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reserved := memory.NewReservedUsernameStore()
|
||||
if _, err := reserved.ReserveUsername(ctx, "support", "", "ops"); err != nil {
|
||||
t.Fatalf("seed reserve: %v", err)
|
||||
}
|
||||
registry := memory.NewCollectibleUsernameStore().WithReservedUsernames(reserved)
|
||||
|
||||
if _, err := registry.SetEditableUsername(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: 1}, "support"); !errors.Is(err, domain.ErrUsernameOccupied) {
|
||||
t.Fatalf("SetEditableUsername(reserved) err = %v, want ErrUsernameOccupied", err)
|
||||
}
|
||||
if _, _, err := registry.MintCollectibleUsername(ctx, domain.MintCollectibleUsernameRequest{
|
||||
Username: "support", Currency: domain.CollectibleCurrencyUSD, Amount: 0, CommandKey: "k1",
|
||||
}); !errors.Is(err, domain.ErrUsernameOccupied) {
|
||||
t.Fatalf("Mint(reserved) err = %v, want ErrUsernameOccupied", err)
|
||||
}
|
||||
// A different name is unaffected.
|
||||
if _, err := registry.SetEditableUsername(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: 1}, "freename"); err != nil {
|
||||
t.Fatalf("SetEditableUsername(free) err = %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,9 @@ type Service interface {
|
|||
CollectibleUsernames(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error)
|
||||
CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error)
|
||||
CollectibleUsernameTransfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error)
|
||||
ReserveUsername(ctx context.Context, req admin.ReserveUsernameRequest) (admin.CommandResult, error)
|
||||
UnreserveUsername(ctx context.Context, req admin.UnreserveUsernameRequest) (admin.CommandResult, error)
|
||||
ReservedUsernames(ctx context.Context, filter domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error)
|
||||
ClaimVerification(ctx context.Context, req admin.ClaimVerificationRequest) (admin.CommandResult, error)
|
||||
ApproveVerification(ctx context.Context, req admin.ApproveVerificationRequest) (admin.CommandResult, error)
|
||||
RejectVerification(ctx context.Context, req admin.RejectVerificationRequest) (admin.CommandResult, error)
|
||||
|
|
@ -234,6 +237,9 @@ func (s *Server) routes() http.Handler {
|
|||
mux.HandleFunc("POST /v1/collectible-usernames/delete", s.authenticated(s.handleDeleteCollectibleUsername))
|
||||
mux.HandleFunc("GET /v1/collectible-usernames", s.authenticated(s.handleCollectibleUsernames))
|
||||
mux.HandleFunc("GET /v1/collectible-usernames/{id}", s.authenticated(s.handleCollectibleUsername))
|
||||
mux.HandleFunc("POST /v1/reserved-usernames/reserve", s.authenticated(s.handleReserveUsername))
|
||||
mux.HandleFunc("POST /v1/reserved-usernames/unreserve", s.authenticated(s.handleUnreserveUsername))
|
||||
mux.HandleFunc("GET /v1/reserved-usernames", s.authenticated(s.handleReservedUsernames))
|
||||
// Official platform verification. Unlike every route above, these carry a
|
||||
// named permission, so a scoped token can be given the review surface and
|
||||
// nothing else. Revocation additionally requires verification.revoke.
|
||||
|
|
@ -1186,6 +1192,54 @@ func (s *Server) handleDeleteCollectibleUsername(w http.ResponseWriter, r *http.
|
|||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleReserveUsername(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.ReserveUsernameRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.ReserveUsername(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleUnreserveUsername(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.UnreserveUsernameRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.UnreserveUsername(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleReservedUsernames(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
filter := domain.ReservedUsernameFilter{Query: query.Get("q")}
|
||||
limit, ok := optionalQueryInt(w, query, "limit")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filter.Limit = limit
|
||||
offset, ok := optionalQueryInt(w, query, "offset")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filter.Offset = offset
|
||||
items, err := s.svc.ReservedUsernames(r.Context(), filter)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, map[string]any{
|
||||
"username": item.Username,
|
||||
"reason": item.Reason,
|
||||
"actor": item.Actor,
|
||||
"created_at": item.CreatedAt.Unix(),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"reserved": out})
|
||||
}
|
||||
|
||||
func (s *Server) handleCollectibleUsernames(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
filter := domain.CollectibleUsernameFilter{
|
||||
|
|
|
|||
|
|
@ -514,10 +514,28 @@ type captureCollectibleUsernameService struct {
|
|||
transfer admin.TransferCollectibleUsernameRequest
|
||||
revoke admin.RevokeCollectibleUsernameRequest
|
||||
del admin.DeleteCollectibleUsernameRequest
|
||||
reserve admin.ReserveUsernameRequest
|
||||
unreserve admin.UnreserveUsernameRequest
|
||||
resFilter domain.ReservedUsernameFilter
|
||||
filter domain.CollectibleUsernameFilter
|
||||
assetID int64
|
||||
}
|
||||
|
||||
func (s *captureCollectibleUsernameService) ReserveUsername(_ context.Context, req admin.ReserveUsernameRequest) (admin.CommandResult, error) {
|
||||
s.reserve = req
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (s *captureCollectibleUsernameService) UnreserveUsername(_ context.Context, req admin.UnreserveUsernameRequest) (admin.CommandResult, error) {
|
||||
s.unreserve = req
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (s *captureCollectibleUsernameService) ReservedUsernames(_ context.Context, filter domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error) {
|
||||
s.resFilter = filter
|
||||
return []domain.ReservedUsername{{Username: "support", Reason: "official", Actor: "ops"}}, nil
|
||||
}
|
||||
|
||||
func (s *captureCollectibleUsernameService) MintCollectibleUsername(_ context.Context, req admin.MintCollectibleUsernameRequest) (admin.CommandResult, error) {
|
||||
s.mint = req
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
|
|
@ -731,3 +749,49 @@ func (fakeService) CollectibleUsernameByID(context.Context, int64) (domain.Colle
|
|||
func (fakeService) CollectibleUsernameTransfers(context.Context, int64, int) ([]domain.CollectibleUsernameTransfer, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) ReserveUsername(_ context.Context, req admin.ReserveUsernameRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) UnreserveUsername(_ context.Context, req admin.UnreserveUsernameRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) ReservedUsernames(context.Context, domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestAdminAPIReservedUsernames(t *testing.T) {
|
||||
svc := &captureCollectibleUsernameService{}
|
||||
srv := &Server{token: "secret", svc: svc}
|
||||
|
||||
reserve := httptest.NewRequest(http.MethodPost, "/v1/reserved-usernames/reserve", strings.NewReader(
|
||||
`{"command_id":"r-1","actor":"ops","reason":"official","username":"support"}`))
|
||||
reserve.Header.Set("Authorization", "Bearer secret")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, reserve)
|
||||
if rec.Code != http.StatusOK || svc.reserve.Username != "support" || svc.reserve.CommandID != "r-1" {
|
||||
t.Fatalf("reserve status=%d req=%+v", rec.Code, svc.reserve)
|
||||
}
|
||||
|
||||
unreserve := httptest.NewRequest(http.MethodPost, "/v1/reserved-usernames/unreserve", strings.NewReader(
|
||||
`{"command_id":"u-1","actor":"ops","reason":"done","username":"support"}`))
|
||||
unreserve.Header.Set("Authorization", "Bearer secret")
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, unreserve)
|
||||
if rec.Code != http.StatusOK || svc.unreserve.Username != "support" {
|
||||
t.Fatalf("unreserve status=%d req=%+v", rec.Code, svc.unreserve)
|
||||
}
|
||||
|
||||
list := httptest.NewRequest(http.MethodGet, "/v1/reserved-usernames?q=sup&limit=10", nil)
|
||||
list.Header.Set("Authorization", "Bearer secret")
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, list)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"username":"support"`) {
|
||||
t.Fatalf("list status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if svc.resFilter.Query != "sup" || svc.resFilter.Limit != 10 {
|
||||
t.Fatalf("list filter = %+v", svc.resFilter)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ func (s *Service) DeleteAccount(ctx context.Context, userID int64, authKeyID [8]
|
|||
}
|
||||
executeAt := now.Add(accountDeletionDelay)
|
||||
message := fmt.Sprintf(
|
||||
"A request was made to delete your "+branding.ProductName+" account. If this wasn't you, cancel the request: tg://confirmphone?phone=%s&hash=%s",
|
||||
"A request was made to delete your "+branding.ProductName()+" account. If this wasn't you, cancel the request: tg://confirmphone?phone=%s&hash=%s",
|
||||
url.QueryEscape(snapshot.User.Phone), url.QueryEscape(rawToken),
|
||||
)
|
||||
pending, _, err := s.lifecycle.ScheduleAccountDeletion(ctx, domain.ScheduleAccountDeletion{
|
||||
|
|
|
|||
|
|
@ -271,19 +271,27 @@ func (s *Service) GetPassword(ctx context.Context, userID int64) (domain.Passwor
|
|||
return defaultPasswordSettings(), nil
|
||||
}
|
||||
settings = normalizePasswordSettings(settings)
|
||||
if settings.HasPassword {
|
||||
// Only mint a fresh SRP challenge (secret/B/SRPID together) when none is
|
||||
// outstanding yet. Regenerating B on every read while leaving SRPID
|
||||
// untouched let any two account.getPassword calls silently invalidate each
|
||||
// other's B without a signal the client could detect (SRPID unchanged) --
|
||||
// a client that fetched the password state, had a second screen/dialog
|
||||
// refresh it again, then submitted against the first B it saw got a false
|
||||
// PASSWORD_HASH_INVALID even with the correct password. The challenge must
|
||||
// stay stable across reads and only rotate when it's actually consumed by
|
||||
// a password change (UpdatePasswordSettings/RecoverPassword already mint
|
||||
// their own fresh challenge there).
|
||||
if settings.HasPassword && (len(settings.SRPBSecret) == 0 || len(settings.SRPB) == 0 || settings.SRPID == 0) {
|
||||
secret, b, err := makeSRPChallenge(settings.SRPVerifier)
|
||||
if err != nil {
|
||||
return domain.PasswordSettings{}, err
|
||||
}
|
||||
settings.SRPBSecret = secret
|
||||
settings.SRPB = b
|
||||
if settings.SRPID == 0 {
|
||||
settings.SRPID, err = randomInt64()
|
||||
if err != nil {
|
||||
return domain.PasswordSettings{}, err
|
||||
}
|
||||
}
|
||||
if err := s.passwords.Save(ctx, userID, settings); err != nil {
|
||||
return domain.PasswordSettings{}, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,54 @@ func TestPasswordSRPRoundTrip(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestGetPasswordChallengeStableAcrossReads guards against a real production
|
||||
// bug: GetPassword used to mint a brand-new random SRP server secret/B on
|
||||
// every single call while leaving SRPID untouched. Two account.getPassword
|
||||
// calls in a row (e.g. a settings screen and, moments later, the transfer-
|
||||
// ownership dialog's own cloudPassword().reload()) would silently invalidate
|
||||
// each other's B with no signal the client could detect (SRPID unchanged),
|
||||
// so a password check built from the first response's B failed with
|
||||
// PASSWORD_HASH_INVALID even though the typed password was correct. The
|
||||
// challenge must stay identical across reads until a real password change
|
||||
// consumes it.
|
||||
func TestGetPasswordChallengeStableAcrossReads(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const userID int64 = 1003
|
||||
svc := NewService(memory.NewPasswordStore())
|
||||
|
||||
initial, err := svc.GetPassword(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPassword initial: %v", err)
|
||||
}
|
||||
algo := initial.NewAlgo
|
||||
algo.Salt1 = append(append([]byte(nil), algo.Salt1...), bytes.Repeat([]byte{0x11}, 32)...)
|
||||
if err := svc.UpdatePasswordSettings(ctx, userID, domain.PasswordCheck{Empty: true}, domain.PasswordInputSettings{
|
||||
NewAlgo: &algo,
|
||||
NewPasswordHash: verifierForPassword(algo, []byte("correct horse")),
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdatePasswordSettings set password: %v", err)
|
||||
}
|
||||
|
||||
first, err := svc.GetPassword(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPassword first: %v", err)
|
||||
}
|
||||
// Simulate a second, unrelated screen/dialog refreshing the same cloud
|
||||
// password state before the user submits their check.
|
||||
second, err := svc.GetPassword(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPassword second: %v", err)
|
||||
}
|
||||
if first.SRPID != second.SRPID || !bytes.Equal(first.SRPB, second.SRPB) {
|
||||
t.Fatalf("challenge changed across reads: first=%+v second=%+v, want identical SRPID/SRPB", first, second)
|
||||
}
|
||||
|
||||
check := clientPasswordCheck(t, first, []byte("correct horse"))
|
||||
if err := svc.CheckPassword(ctx, userID, check); err != nil {
|
||||
t.Fatalf("CheckPassword against first-read challenge after an intervening read: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverPasswordClearsTwoFactorPassword(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const userID int64 = 1002
|
||||
|
|
|
|||
|
|
@ -1535,9 +1535,9 @@ func (s *Service) passwordNeeded(ctx context.Context, userID int64) (bool, error
|
|||
return found && settings.HasPassword, nil
|
||||
}
|
||||
|
||||
const loginMessageTpl = `Login code: %s. Do not give this code to anyone, even if they say they are from ` + branding.ProductName + `!
|
||||
var loginMessageTpl = `Login code: %s. Do not give this code to anyone, even if they say they are from ` + branding.ProductName() + `!
|
||||
|
||||
This code can be used to log in to your ` + branding.ProductName + ` account. We never ask it for anything else.
|
||||
This code can be used to log in to your ` + branding.ProductName() + ` account. We never ask it for anything else.
|
||||
|
||||
If you didn't request this code by trying to log in on another device, simply ignore this message.`
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const (
|
|||
botFatherCmdToken = "token"
|
||||
botFatherCmdRevoke = "revoke"
|
||||
botFatherCmdSetName = "setname"
|
||||
botFatherCmdSetBotpic = "setbotpic"
|
||||
botFatherCmdSetDescription = "setdescription"
|
||||
botFatherCmdSetAbout = "setabouttext"
|
||||
botFatherCmdSetCommands = "setcommands"
|
||||
|
|
@ -50,7 +51,7 @@ const (
|
|||
maxTelegramLoginCommandsPerMessage = 32
|
||||
)
|
||||
|
||||
const botFatherHelpText = `I can help you create and manage ` + branding.ProductName + ` bots.
|
||||
var botFatherHelpText = `I can help you create and manage ` + branding.ProductName() + ` bots.
|
||||
|
||||
You can control me by sending these commands:
|
||||
|
||||
|
|
@ -115,7 +116,7 @@ func (s *Service) OnPrivateMessage(ctx context.Context, botUserID int64, msg dom
|
|||
}
|
||||
switch botUserID {
|
||||
case domain.BotFatherUserID:
|
||||
go s.respondAsBotFather(userID, msg.Body)
|
||||
go s.respondAsBotFather(userID, msg)
|
||||
case domain.StickersBotUserID:
|
||||
go s.respondAsStickers(userID, msg)
|
||||
case domain.ChatBotUserID:
|
||||
|
|
@ -130,14 +131,14 @@ func (s *Service) OnPrivateMessage(ctx context.Context, botUserID int64, msg dom
|
|||
// respondAsBotFather 生成并写入 BotFather 回复(OnPrivateMessage 在 goroutine 内调用)。
|
||||
// 按用户取条带锁串行:状态机 Get→modify→Upsert/Delete 的 RMW 因此原子、回复保序,
|
||||
// 不同用户并发不受影响。ctx 用 Background(脱离已返回的用户 RPC),限较长超时。
|
||||
func (s *Service) respondAsBotFather(userID int64, body string) {
|
||||
func (s *Service) respondAsBotFather(userID int64, msg domain.Message) {
|
||||
mu := s.serviceBotReplyLock(domain.BotFatherUserID, userID)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
reply := s.handleBotFather(ctx, userID, body)
|
||||
reply := s.handleBotFather(ctx, userID, msg)
|
||||
s.sendServiceBotReply(ctx, domain.BotFatherUserID, userID, reply)
|
||||
}
|
||||
|
||||
|
|
@ -194,6 +195,45 @@ func (s *Service) sendServiceBotReplyResult(ctx context.Context, botUserID, user
|
|||
return res, true
|
||||
}
|
||||
|
||||
// editServiceBotMessage 就地改写一条内置 bot 自己发出的消息(正文 + inline keyboard)。
|
||||
// 用于按钮式菜单(@BotFather /mybots)在点击后原地翻页/下钻,而不是刷屏新消息。
|
||||
// OwnerUserID 取 bot 自身:store 的 authorEdit 判定要求 sender==from==owner,bot 发出的
|
||||
// 那一份 outbox 拷贝正好满足。ErrMessageNotModified 视为成功(点了同一个按钮两次)。
|
||||
func (s *Service) editServiceBotMessage(ctx context.Context, botUserID, userID int64, messageID int, reply botReply) bool {
|
||||
if s == nil || s.messages == nil || messageID <= 0 || reply.Text == "" {
|
||||
return false
|
||||
}
|
||||
markup := reply.ReplyMarkup
|
||||
if err := domain.ValidateReplyMarkup(markup); err != nil {
|
||||
s.log.Error("service bot: invalid reply markup for edit",
|
||||
zap.Int64("bot_user_id", botUserID), zap.Int64("user_id", userID), zap.Error(err))
|
||||
markup = nil
|
||||
}
|
||||
if markup.IsZero() {
|
||||
markup = nil
|
||||
}
|
||||
_, err := s.messages.EditMessage(ctx, domain.EditMessageRequest{
|
||||
OwnerUserID: botUserID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
|
||||
ID: messageID,
|
||||
Message: reply.Text,
|
||||
Entities: serviceBotReplyEntities(reply.Text, reply.Entities),
|
||||
EditDate: int(s.now().Unix()),
|
||||
HideEdited: true,
|
||||
SetReplyMarkup: true,
|
||||
ReplyMarkup: markup,
|
||||
})
|
||||
if errors.Is(err, domain.ErrMessageNotModified) {
|
||||
return true
|
||||
}
|
||||
if err != nil {
|
||||
s.log.Error("service bot: edit reply",
|
||||
zap.Int64("bot_user_id", botUserID), zap.Int64("user_id", userID), zap.Error(err))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// botReplyRandomID 为服务端回复构造非零幂等键((sender, random_id) 唯一索引)。
|
||||
// 所有服务 bot 回复按各自 sender 命名空间唯一——用
|
||||
// crypto/rand 取 64 位随机数(碰撞概率可忽略),熵源失败时退化为纳秒+单调序列。
|
||||
|
|
@ -222,8 +262,8 @@ var botFatherGlobalCommands = map[string]bool{
|
|||
botFatherCmdSetLogin: true, botFatherCmdLoginInfo: true, botFatherCmdResetLogin: true,
|
||||
}
|
||||
|
||||
func (s *Service) handleBotFather(ctx context.Context, userID int64, text string) botReply {
|
||||
text = strings.TrimSpace(text)
|
||||
func (s *Service) handleBotFather(ctx context.Context, userID int64, msg domain.Message) botReply {
|
||||
text := strings.TrimSpace(msg.Body)
|
||||
state, found, err := s.bots.GetBotChatState(ctx, domain.BotFatherUserID, userID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: get chat state", zap.Int64("user_id", userID), zap.Error(err))
|
||||
|
|
@ -234,12 +274,24 @@ func (s *Service) handleBotFather(ctx context.Context, userID int64, text string
|
|||
if cmd, ok := parseBotCommand(text); ok {
|
||||
inValueStep := found && state.Step == botFatherStepValue
|
||||
if !inValueStep || botFatherGlobalCommands[cmd] {
|
||||
// "/start <bot>" (the "Manage Bot" deep link) jumps straight to that
|
||||
// bot's menu, like /mybots then tapping the bot.
|
||||
if cmd == "start" {
|
||||
if arg := botCommandArg(text); arg != "" {
|
||||
return s.handleBotFatherStart(ctx, userID, arg)
|
||||
}
|
||||
}
|
||||
return s.handleBotFatherCommand(ctx, userID, cmd)
|
||||
}
|
||||
}
|
||||
if text == "" {
|
||||
// 空白文本 / 贴纸 / 无 caption 媒体:有活动状态时回当前步骤提示,
|
||||
// 无状态保持沉默(避免对任意非文本消息刷屏)。
|
||||
if found && state.Step == botFatherStepValue && msg.Media != nil {
|
||||
// A photo for /setbotpic arrives with no caption: hand the message to
|
||||
// the value step instead of replaying the prompt.
|
||||
return s.handleSetValue(ctx, state, msg)
|
||||
}
|
||||
if !found {
|
||||
return botReply{}
|
||||
}
|
||||
|
|
@ -256,7 +308,9 @@ func (s *Service) handleBotFather(ctx context.Context, userID int64, text string
|
|||
case state.Step == botFatherStepChoose:
|
||||
return s.handleChooseBot(ctx, state, text)
|
||||
case state.Step == botFatherStepValue:
|
||||
return s.handleSetValue(ctx, state, text)
|
||||
return s.handleSetValue(ctx, state, msg)
|
||||
case state.Command == mybotsCommand:
|
||||
return botReply{Text: "Please use the buttons in my message above, or send /mybots to open the list again."}
|
||||
default:
|
||||
// 不可达的脏状态:清掉重来,避免用户被卡死。
|
||||
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID)
|
||||
|
|
@ -314,11 +368,48 @@ func (s *Service) stepPrompt(state domain.BotChatState) botReply {
|
|||
return botReply{Text: "Please send the username of one of your bots, or /cancel."}
|
||||
case state.Step == botFatherStepValue:
|
||||
return botReply{Text: valuePrompt(state.Command, state.Draft[botFatherDraftBotUsername])}
|
||||
case state.Command == mybotsCommand:
|
||||
return botReply{Text: "Please use the buttons in my message above, or send /mybots to open the list again."}
|
||||
default:
|
||||
return botReply{Text: "Send /help for a list of commands."}
|
||||
}
|
||||
}
|
||||
|
||||
// handleBotFatherStart answers "/start <arg>". When <arg> names one of the
|
||||
// user's own bots (by username or numeric id) it opens that bot's menu - the
|
||||
// same "What do you want to do?" screen as /mybots then tapping the bot, which
|
||||
// is what the "Manage Bot" button on a bot's profile links to. An empty or
|
||||
// unknown arg falls back to the plain greeting.
|
||||
func (s *Service) handleBotFatherStart(ctx context.Context, userID int64, arg string) botReply {
|
||||
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID)
|
||||
want := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(arg), "@"))
|
||||
if want == "" {
|
||||
return botReply{Text: botFatherHelpText}
|
||||
}
|
||||
owned, err := s.ownedBots(ctx, userID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: list bots for start payload", zap.Int64("user_id", userID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
for _, b := range owned {
|
||||
if strings.EqualFold(b.user.Username, want) || strconv.FormatInt(b.user.ID, 10) == want {
|
||||
state := domain.BotChatState{
|
||||
BotUserID: domain.BotFatherUserID,
|
||||
UserID: userID,
|
||||
Command: mybotsCommand,
|
||||
Step: mybotsStepMenu,
|
||||
Draft: map[string]string{},
|
||||
}
|
||||
reply := s.myBotsBotMenu(&state, b)
|
||||
if !s.saveMyBotsState(ctx, state) {
|
||||
return internalReply()
|
||||
}
|
||||
return reply
|
||||
}
|
||||
}
|
||||
return botReply{Text: botFatherHelpText}
|
||||
}
|
||||
|
||||
func (s *Service) handleBotFatherCommand(ctx context.Context, userID int64, cmd string) botReply {
|
||||
switch cmd {
|
||||
case "start", "help":
|
||||
|
|
@ -363,15 +454,7 @@ func (s *Service) handleBotFatherCommand(ctx context.Context, userID int64, cmd
|
|||
}
|
||||
return botReply{Text: "Alright, a new bot. How are we going to call it? Please choose a name for your bot."}
|
||||
case "mybots":
|
||||
usernames, err := s.ownedBotUsernames(ctx, userID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: list bots", zap.Int64("user_id", userID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
if len(usernames) == 0 {
|
||||
return botReply{Text: "You don't have any bots yet. Use /newbot to create one."}
|
||||
}
|
||||
return botReply{Text: "Here are your bots:\n\n@" + strings.Join(usernames, "\n@")}
|
||||
return s.startMyBots(ctx, userID)
|
||||
case botFatherCmdToken, botFatherCmdRevoke,
|
||||
botFatherCmdSetName, botFatherCmdSetDescription, botFatherCmdSetAbout,
|
||||
botFatherCmdSetCommands, botFatherCmdSetInline, botFatherCmdSetInlineGeo,
|
||||
|
|
@ -391,6 +474,8 @@ func valuePrompt(cmd, username string) string {
|
|||
switch cmd {
|
||||
case botFatherCmdSetName:
|
||||
return fmt.Sprintf("OK. Send me the new name for @%s.", username)
|
||||
case botFatherCmdSetBotpic:
|
||||
return fmt.Sprintf("OK. Send me the new profile picture for @%s. Send it as a photo.", username)
|
||||
case botFatherCmdSetDescription:
|
||||
return fmt.Sprintf("OK. Send me the new description for @%s. People will see it on the bot's profile page, before they start a chat with it.", username)
|
||||
case botFatherCmdSetAbout:
|
||||
|
|
@ -577,8 +662,10 @@ func (s *Service) handleChooseBot(ctx context.Context, state domain.BotChatState
|
|||
}
|
||||
}
|
||||
|
||||
// handleSetValue 处理选中 bot 后的收值步骤(/setname 等)。
|
||||
func (s *Service) handleSetValue(ctx context.Context, state domain.BotChatState, text string) botReply {
|
||||
// handleSetValue 处理选中 bot 后的收值步骤(/setname 等)。除 /setbotpic 收一张
|
||||
// 照片外都是纯文本;照片经 msg.Media 传入。
|
||||
func (s *Service) handleSetValue(ctx context.Context, state domain.BotChatState, msg domain.Message) botReply {
|
||||
text := strings.TrimSpace(msg.Body)
|
||||
botID, _ := strconv.ParseInt(state.Draft[botFatherDraftBotID], 10, 64)
|
||||
username := state.Draft[botFatherDraftBotUsername]
|
||||
if botID == 0 {
|
||||
|
|
@ -618,6 +705,8 @@ func (s *Service) handleSetValue(ctx context.Context, state domain.BotChatState,
|
|||
reply, err = s.applyToggle(ctx, botID, text, true)
|
||||
case botFatherCmdSetPrivacy:
|
||||
reply, err = s.applyToggle(ctx, botID, text, false)
|
||||
case botFatherCmdSetBotpic:
|
||||
reply, err = s.applySetBotpic(ctx, botID, username, msg)
|
||||
case botFatherCmdSetLogin:
|
||||
return s.handleTelegramLoginConfigurationInput(ctx, state, botID, username, text)
|
||||
default:
|
||||
|
|
@ -631,6 +720,11 @@ func (s *Service) handleSetValue(ctx context.Context, state domain.BotChatState,
|
|||
}
|
||||
return reply
|
||||
}
|
||||
if state.Draft[mybotsDraftReturn] == "1" {
|
||||
// Opened from the /mybots menu: land back on the Edit Bot menu (fresh
|
||||
// message, working buttons) rather than ending the dialog.
|
||||
return s.myBotsReturnToEditMenu(ctx, state.UserID, botID, state.Draft[mybotsDraftPage], reply.Text)
|
||||
}
|
||||
s.clearState(ctx, state.UserID)
|
||||
return reply
|
||||
}
|
||||
|
|
@ -956,6 +1050,25 @@ func (s *Service) applyTelegramLoginConfiguration(ctx context.Context, botID int
|
|||
return botReply{Text: telegramLoginConfigurationPrompt(username)}, domain.ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
|
||||
// applySetBotpic 把用户发给 BotFather 的一张照片设为 bot 头像。只收 photo(不收
|
||||
// 文件/贴纸);文件层重渲染成头像尺寸集。
|
||||
func (s *Service) applySetBotpic(ctx context.Context, botID int64, username string, msg domain.Message) (botReply, error) {
|
||||
if msg.Media == nil || msg.Media.Kind != domain.MessageMediaKindPhoto || msg.Media.Photo == nil || msg.Media.Photo.ID == 0 {
|
||||
return botReply{Text: "Please send a photo (as a photo, not a file), or /cancel."}, domain.ErrPhotoInvalid
|
||||
}
|
||||
err := s.SetBotUserpic(ctx, botID, msg.Media.Photo.ID)
|
||||
switch {
|
||||
case errors.Is(err, ErrBotUserpicUnsupported):
|
||||
return botReply{Text: "Setting a profile picture isn't available on this server."}, err
|
||||
case errors.Is(err, domain.ErrPhotoInvalid):
|
||||
return botReply{Text: "Sorry, I couldn't use that image. Send a JPEG or PNG photo, or /cancel."}, err
|
||||
case err != nil:
|
||||
s.log.Error("botfather: set botpic", zap.Int64("bot_user_id", botID), zap.Error(err))
|
||||
return botReply{}, err
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Success! Profile picture updated for @%s.", username)}, nil
|
||||
}
|
||||
|
||||
// applyToggle 解析 enable/disable 并设置 joingroups(join=true)或 privacy(join=false)。
|
||||
func (s *Service) applyToggle(ctx context.Context, botID int64, text string, join bool) (botReply, error) {
|
||||
var on bool
|
||||
|
|
@ -1086,3 +1199,16 @@ func parseBotCommand(text string) (string, bool) {
|
|||
}
|
||||
return strings.ToLower(cmd), true
|
||||
}
|
||||
|
||||
// botCommandArg returns the trimmed argument after a leading "/cmd", e.g.
|
||||
// "/start my_bot" -> "my_bot". Empty when there is no argument.
|
||||
func botCommandArg(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if !strings.HasPrefix(text, "/") {
|
||||
return ""
|
||||
}
|
||||
if i := strings.IndexAny(text, " \t\n"); i >= 0 {
|
||||
return strings.TrimSpace(text[i+1:])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
|
|||
823
internal/app/bots/mybots.go
Normal file
823
internal/app/bots/mybots.go
Normal file
|
|
@ -0,0 +1,823 @@
|
|||
package bots
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// @BotFather 的按钮式 /mybots:列出 owner 的 bot(每行一个 + 上一页/下一页),点进
|
||||
// 单个 bot 后是 API Token / Edit Bot / Bot Settings / Delete Bot 四个入口。除了
|
||||
// "Edit Bot" 下的文本字段(改名/简介/描述/命令列表,交回既有 handleSetValue 收值
|
||||
// 流程)之外,全部翻页与下钻都在同一条消息上就地 EditMessage,不刷屏。
|
||||
//
|
||||
// 会话状态 domain.BotChatState(Command="mybots"),与其余 @BotFather 流程一样在
|
||||
// per-user serviceBotReplyLock 下 Get→改→Upsert,回调按用户串行、保序。
|
||||
//
|
||||
// 安全:回调 data 只带一个随机 token,映射到 *该用户自己* BotChatState.Draft 里
|
||||
// 记下的 choice——data 里那个 bot id 是服务端渲染键盘时写进去的,不是客户端传的。
|
||||
// 即便如此,每个动作执行前仍重新按 owner 解析一次(myBotForUser 只返回 owner 名下
|
||||
// 的 bot),伪造/重放的 token 顶多命中一个不在自己表里的 key,被直接拒绝。
|
||||
|
||||
const (
|
||||
mybotsCommand = "mybots"
|
||||
|
||||
mybotsStepMenu = "menu"
|
||||
|
||||
mybotsDraftGeneration = "gen"
|
||||
mybotsDraftPage = "page"
|
||||
mybotsDraftOptionPrefix = "opt:"
|
||||
// mybotsDraftReturn marks a botFatherStepValue state that was opened from the
|
||||
// /mybots menu, so a successful field edit lands back on the Edit Bot menu
|
||||
// (a fresh message with working buttons) instead of just ending the dialog.
|
||||
mybotsDraftReturn = "mb_ret"
|
||||
|
||||
// mybotsCallbackDataPrefix tags this menu's callback data. It carries no
|
||||
// information beyond "this is a @BotFather /mybots button".
|
||||
mybotsCallbackDataPrefix = "bf:"
|
||||
mybotsOptionTokenBytes = 6
|
||||
mybotsOptionTokenMaxLen = 32
|
||||
// mybotsTokenGenerations is how many renders' worth of button tokens stay
|
||||
// resolvable: the current render plus the two before it, so a second press of
|
||||
// the same button (or a button on the message just above) still resolves while
|
||||
// the table stays bounded.
|
||||
mybotsTokenGenerations = 3
|
||||
|
||||
// mybotsPageSize bounds one page of the bot list. MaxMarkupButtonsPerRow is 8
|
||||
// and one bot is one row, so this stays well inside the keyboard limits with
|
||||
// room for the navigation row.
|
||||
mybotsPageSize = 10
|
||||
)
|
||||
|
||||
// Opaque button choices as stored in the per-user token table. These strings
|
||||
// never travel over the wire; only the random token that maps to them does.
|
||||
const (
|
||||
mybotsChoiceListPrefix = "list:" // list:<page>
|
||||
mybotsChoiceBotPrefix = "bot:" // bot:<botID> -> per-bot menu
|
||||
mybotsChoiceTokenPrefix = "tok:" // tok:<botID> -> API token screen
|
||||
mybotsChoiceRevokePrefix = "rvk:" // rvk:<botID> -> revoke confirm
|
||||
mybotsChoiceRevokeGoPrefix = "rvkgo:" // rvkgo:<botID> -> do revoke
|
||||
mybotsChoiceEditPrefix = "edit:" // edit:<botID> -> Edit Bot menu
|
||||
mybotsChoiceSetNamePrefix = "setname:" // setname:<botID>
|
||||
mybotsChoiceSetAboutPrefix = "setabout:" // setabout:<botID>
|
||||
mybotsChoiceSetDescPrefix = "setdesc:" // setdesc:<botID>
|
||||
mybotsChoiceSetCmdsPrefix = "setcmds:" // setcmds:<botID>
|
||||
mybotsChoiceBotpicPrefix = "botpic:" // botpic:<botID> -> phase 2, alert for now
|
||||
mybotsChoiceCfgPrefix = "cfg:" // cfg:<botID> -> Bot Settings screen
|
||||
mybotsChoiceCfgInline = "cfginl:" // cfginl:<botID> -> toggle inline mode
|
||||
mybotsChoiceCfgGroups = "cfggrp:" // cfggrp:<botID> -> toggle allow groups
|
||||
mybotsChoiceCfgPrivacy = "cfgprv:" // cfgprv:<botID> -> toggle group privacy
|
||||
mybotsChoiceDeletePrefix = "del:" // del:<botID> -> delete confirm
|
||||
mybotsChoiceDeleteGoPrefix = "delgo:" // delgo:<botID> -> do delete
|
||||
)
|
||||
|
||||
const (
|
||||
mybotsExpiredButtonText = "That button is no longer active. Send /mybots to open the list again."
|
||||
mybotsGoneBotText = "That bot is no longer available. Send /mybots to open the list again."
|
||||
mybotsNoBotsText = "You don't have any bots yet. Use /newbot to create one."
|
||||
)
|
||||
|
||||
// mybotsOption is one inline button before its token is minted.
|
||||
type mybotsOption struct {
|
||||
text string
|
||||
choice string
|
||||
style domain.MarkupButtonStyle
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry: /mybots
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// startMyBots answers /mybots (and the /mybots button path). It sends a fresh
|
||||
// message carrying the first page of the picker; every later press edits that
|
||||
// message in place.
|
||||
func (s *Service) startMyBots(ctx context.Context, userID int64) botReply {
|
||||
owned, err := s.ownedBots(ctx, userID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: list bots", zap.Int64("user_id", userID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
if len(owned) == 0 {
|
||||
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID)
|
||||
return botReply{Text: mybotsNoBotsText}
|
||||
}
|
||||
state := domain.BotChatState{
|
||||
BotUserID: domain.BotFatherUserID,
|
||||
UserID: userID,
|
||||
Command: mybotsCommand,
|
||||
Step: mybotsStepMenu,
|
||||
Draft: map[string]string{},
|
||||
}
|
||||
reply, ok := s.myBotsListScreen(ctx, &state, owned, 0)
|
||||
if !ok {
|
||||
return internalReply()
|
||||
}
|
||||
if !s.saveMyBotsState(ctx, state) {
|
||||
return internalReply()
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Callback entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// onBotFatherCallback answers one inline-button click on a @BotFather /mybots
|
||||
// message. It is reached from OnCallbackQuery (verifybot.go).
|
||||
func (s *Service) onBotFatherCallback(ctx context.Context, query domain.BotCallbackQuery) (domain.BotCallbackAnswer, bool, error) {
|
||||
userID := query.UserID
|
||||
mu := s.serviceBotReplyLock(domain.BotFatherUserID, userID)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
state, found, err := s.bots.GetBotChatState(ctx, domain.BotFatherUserID, userID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: get chat state for callback", zap.Int64("user_id", userID), zap.Error(err))
|
||||
return domain.BotCallbackAnswer{}, true, err
|
||||
}
|
||||
if !found || state.Command != mybotsCommand {
|
||||
return mybotsAlert(mybotsExpiredButtonText), true, nil
|
||||
}
|
||||
choice, ok := mybotsResolveOption(state, query.Data)
|
||||
if !ok {
|
||||
// Callback data absent from this user's own token table: an expired
|
||||
// generation, or replayed/fabricated data. All refused identically, and
|
||||
// nothing changes.
|
||||
return mybotsAlert(mybotsExpiredButtonText), true, nil
|
||||
}
|
||||
// The follow-up write is on a context detached from the caller's RPC: the
|
||||
// answer unblocks the click, and the edit must survive the client hanging up
|
||||
// straight afterwards.
|
||||
sendCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||
defer cancel()
|
||||
return s.applyMyBotsChoice(sendCtx, state, choice, query), true, nil
|
||||
}
|
||||
|
||||
// applyMyBotsChoice executes one resolved button choice. Each branch owns its own
|
||||
// messaging: an in-place edit of query.MessageID for navigation, a fresh message
|
||||
// when handing off to the text-input flow, or an alert for a refusal.
|
||||
func (s *Service) applyMyBotsChoice(ctx context.Context, state domain.BotChatState, choice string, query domain.BotCallbackQuery) domain.BotCallbackAnswer {
|
||||
edit := func(reply botReply) domain.BotCallbackAnswer {
|
||||
if reply.Text == "" {
|
||||
return domain.BotCallbackAnswer{}
|
||||
}
|
||||
if !s.saveMyBotsState(ctx, state) {
|
||||
return mybotsAlert(mybotsExpiredButtonText)
|
||||
}
|
||||
s.editServiceBotMessage(ctx, domain.BotFatherUserID, state.UserID, query.MessageID, reply)
|
||||
return domain.BotCallbackAnswer{}
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(choice, mybotsChoiceListPrefix):
|
||||
page, _ := strconv.Atoi(strings.TrimPrefix(choice, mybotsChoiceListPrefix))
|
||||
owned, err := s.ownedBots(ctx, state.UserID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: list bots", zap.Int64("user_id", state.UserID), zap.Error(err))
|
||||
return mybotsAlert(mybotsExpiredButtonText)
|
||||
}
|
||||
if len(owned) == 0 {
|
||||
return edit(s.myBotsEmptyScreen(&state))
|
||||
}
|
||||
reply, ok := s.myBotsListScreen(ctx, &state, owned, page)
|
||||
if !ok {
|
||||
return mybotsAlert(mybotsExpiredButtonText)
|
||||
}
|
||||
return edit(reply)
|
||||
|
||||
case strings.HasPrefix(choice, mybotsChoiceBotPrefix):
|
||||
return s.mybotsWithBot(ctx, &state, choice, mybotsChoiceBotPrefix, edit, func(b ownedBot) botReply {
|
||||
return s.myBotsBotMenu(&state, b)
|
||||
})
|
||||
|
||||
case strings.HasPrefix(choice, mybotsChoiceTokenPrefix):
|
||||
return s.mybotsWithBot(ctx, &state, choice, mybotsChoiceTokenPrefix, edit, func(b ownedBot) botReply {
|
||||
return s.myBotsTokenScreen(ctx, &state, b, "")
|
||||
})
|
||||
|
||||
case strings.HasPrefix(choice, mybotsChoiceRevokePrefix):
|
||||
return s.mybotsWithBot(ctx, &state, choice, mybotsChoiceRevokePrefix, edit, func(b ownedBot) botReply {
|
||||
return s.myBotsRevokeConfirm(&state, b)
|
||||
})
|
||||
|
||||
case strings.HasPrefix(choice, mybotsChoiceRevokeGoPrefix):
|
||||
return s.mybotsWithBot(ctx, &state, choice, mybotsChoiceRevokeGoPrefix, edit, func(b ownedBot) botReply {
|
||||
token, err := s.RevokeBotToken(ctx, state.UserID, b.user.ID)
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrBotSessionsNotRevoked):
|
||||
return s.myBotsTokenScreen(ctx, &state, b,
|
||||
fmt.Sprintf("Token for @%s changed, but I couldn't cut off sessions that are already logged in - tap Revoke again to be sure.", b.user.Username))
|
||||
case err != nil:
|
||||
s.log.Error("botfather: revoke token", zap.Int64("bot_user_id", b.user.ID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
return s.myBotsTokenScreenWithToken(&state, b, token,
|
||||
fmt.Sprintf("Token for @%s has been revoked. The old one stops working immediately.", b.user.Username))
|
||||
})
|
||||
|
||||
case strings.HasPrefix(choice, mybotsChoiceEditPrefix):
|
||||
return s.mybotsWithBot(ctx, &state, choice, mybotsChoiceEditPrefix, edit, func(b ownedBot) botReply {
|
||||
return s.myBotsEditMenu(ctx, &state, b)
|
||||
})
|
||||
|
||||
case strings.HasPrefix(choice, mybotsChoiceCfgPrefix) && !strings.HasPrefix(choice, mybotsChoiceCfgInline) &&
|
||||
!strings.HasPrefix(choice, mybotsChoiceCfgGroups) && !strings.HasPrefix(choice, mybotsChoiceCfgPrivacy):
|
||||
return s.mybotsWithBot(ctx, &state, choice, mybotsChoiceCfgPrefix, edit, func(b ownedBot) botReply {
|
||||
return s.myBotsSettingsScreen(&state, b, "")
|
||||
})
|
||||
|
||||
case strings.HasPrefix(choice, mybotsChoiceCfgInline):
|
||||
return s.mybotsToggleSetting(ctx, &state, choice, mybotsChoiceCfgInline, edit, "inline")
|
||||
case strings.HasPrefix(choice, mybotsChoiceCfgGroups):
|
||||
return s.mybotsToggleSetting(ctx, &state, choice, mybotsChoiceCfgGroups, edit, "groups")
|
||||
case strings.HasPrefix(choice, mybotsChoiceCfgPrivacy):
|
||||
return s.mybotsToggleSetting(ctx, &state, choice, mybotsChoiceCfgPrivacy, edit, "privacy")
|
||||
|
||||
case strings.HasPrefix(choice, mybotsChoiceDeletePrefix):
|
||||
return s.mybotsWithBot(ctx, &state, choice, mybotsChoiceDeletePrefix, edit, func(b ownedBot) botReply {
|
||||
return s.myBotsDeleteConfirm(&state, b)
|
||||
})
|
||||
|
||||
case strings.HasPrefix(choice, mybotsChoiceDeleteGoPrefix):
|
||||
return s.mybotsWithBot(ctx, &state, choice, mybotsChoiceDeleteGoPrefix, edit, func(b ownedBot) botReply {
|
||||
if _, err := s.DeleteBot(ctx, b.user.ID); err != nil {
|
||||
if errors.Is(err, domain.ErrBotSessionsNotRevoked) {
|
||||
return botReply{Text: fmt.Sprintf("I couldn't safely delete @%s right now (its sessions are still active). Please try again in a moment.", b.user.Username)}
|
||||
}
|
||||
s.log.Error("botfather: delete bot", zap.Int64("bot_user_id", b.user.ID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
owned, err := s.ownedBots(ctx, state.UserID)
|
||||
if err != nil || len(owned) == 0 {
|
||||
return s.myBotsEmptyScreen(&state)
|
||||
}
|
||||
reply, ok := s.myBotsListScreen(ctx, &state, owned, 0)
|
||||
if !ok {
|
||||
return internalReply()
|
||||
}
|
||||
reply.Text = fmt.Sprintf("Deleted @%s.\n\n", b.user.Username) + reply.Text
|
||||
return reply
|
||||
})
|
||||
|
||||
case strings.HasPrefix(choice, mybotsChoiceBotpicPrefix):
|
||||
return s.mybotsBeginValueInput(ctx, state, choice, mybotsChoiceBotpicPrefix, botFatherCmdSetBotpic)
|
||||
|
||||
case strings.HasPrefix(choice, mybotsChoiceSetNamePrefix):
|
||||
return s.mybotsBeginValueInput(ctx, state, choice, mybotsChoiceSetNamePrefix, botFatherCmdSetName)
|
||||
case strings.HasPrefix(choice, mybotsChoiceSetAboutPrefix):
|
||||
return s.mybotsBeginValueInput(ctx, state, choice, mybotsChoiceSetAboutPrefix, botFatherCmdSetAbout)
|
||||
case strings.HasPrefix(choice, mybotsChoiceSetDescPrefix):
|
||||
return s.mybotsBeginValueInput(ctx, state, choice, mybotsChoiceSetDescPrefix, botFatherCmdSetDescription)
|
||||
case strings.HasPrefix(choice, mybotsChoiceSetCmdsPrefix):
|
||||
return s.mybotsBeginValueInput(ctx, state, choice, mybotsChoiceSetCmdsPrefix, botFatherCmdSetCommands)
|
||||
|
||||
default:
|
||||
s.log.Warn("botfather: unknown mybots choice", zap.Int64("user_id", state.UserID), zap.String("choice", choice))
|
||||
return mybotsAlert(mybotsExpiredButtonText)
|
||||
}
|
||||
}
|
||||
|
||||
// mybotsWithBot resolves the bot named by a choice against the caller's owned
|
||||
// set, then hands it to render. A bot that is not (any longer) theirs falls back
|
||||
// to the list.
|
||||
func (s *Service) mybotsWithBot(
|
||||
ctx context.Context,
|
||||
state *domain.BotChatState,
|
||||
choice, prefix string,
|
||||
edit func(botReply) domain.BotCallbackAnswer,
|
||||
render func(ownedBot) botReply,
|
||||
) domain.BotCallbackAnswer {
|
||||
botID, _ := strconv.ParseInt(strings.TrimPrefix(choice, prefix), 10, 64)
|
||||
b, ok, err := s.myBotForUser(ctx, state.UserID, botID)
|
||||
if err != nil {
|
||||
return mybotsAlert(mybotsExpiredButtonText)
|
||||
}
|
||||
if !ok {
|
||||
owned, listErr := s.ownedBots(ctx, state.UserID)
|
||||
if listErr != nil || len(owned) == 0 {
|
||||
return edit(s.myBotsEmptyScreen(state))
|
||||
}
|
||||
reply, built := s.myBotsListScreen(ctx, state, owned, 0)
|
||||
if !built {
|
||||
return mybotsAlert(mybotsExpiredButtonText)
|
||||
}
|
||||
reply.Text = mybotsGoneBotText + "\n\n" + reply.Text
|
||||
return edit(reply)
|
||||
}
|
||||
return edit(render(b))
|
||||
}
|
||||
|
||||
// mybotsToggleSetting flips one of the three per-bot settings and re-renders the
|
||||
// settings screen with fresh labels.
|
||||
func (s *Service) mybotsToggleSetting(
|
||||
ctx context.Context,
|
||||
state *domain.BotChatState,
|
||||
choice, prefix string,
|
||||
edit func(botReply) domain.BotCallbackAnswer,
|
||||
which string,
|
||||
) domain.BotCallbackAnswer {
|
||||
return s.mybotsWithBot(ctx, state, choice, prefix, edit, func(b ownedBot) botReply {
|
||||
var (
|
||||
err error
|
||||
verb string
|
||||
)
|
||||
switch which {
|
||||
case "inline":
|
||||
// "Just inline" per the menu spec: on = a minimal placeholder, off =
|
||||
// disabled. /setinline still owns the placeholder text.
|
||||
if b.profile.InlinePlaceholder == "" {
|
||||
_, err = s.SetInlinePlaceholder(ctx, b.user.ID, "Search")
|
||||
} else {
|
||||
_, err = s.SetInlinePlaceholder(ctx, b.user.ID, "")
|
||||
}
|
||||
verb = "inline mode"
|
||||
case "groups":
|
||||
_, err = s.SetJoinGroups(ctx, b.user.ID, b.profile.Nochats /* was disallowed -> allow */)
|
||||
verb = "group joining"
|
||||
case "privacy":
|
||||
// ChatHistory == privacy OFF. Toggle: enable privacy when it is off.
|
||||
_, err = s.SetPrivacy(ctx, b.user.ID, b.profile.ChatHistory)
|
||||
verb = "group privacy"
|
||||
}
|
||||
if err != nil {
|
||||
s.log.Error("botfather: toggle "+verb, zap.Int64("bot_user_id", b.user.ID), zap.Error(err))
|
||||
return s.myBotsSettingsScreen(state, b, "That didn't go through. Try again.")
|
||||
}
|
||||
fresh, ok, err := s.myBotForUser(ctx, state.UserID, b.user.ID)
|
||||
if err != nil || !ok {
|
||||
return s.myBotsSettingsScreen(state, b, "")
|
||||
}
|
||||
return s.myBotsSettingsScreen(state, fresh, "Updated "+verb+".")
|
||||
})
|
||||
}
|
||||
|
||||
// mybotsBeginValueInput hands off to the shared text-collection flow used by
|
||||
// /setname & friends: it writes a botFatherStepValue state for that command and
|
||||
// sends a fresh prompt message (a value cannot be collected on a button).
|
||||
func (s *Service) mybotsBeginValueInput(ctx context.Context, state domain.BotChatState, choice, prefix, cmd string) domain.BotCallbackAnswer {
|
||||
botID, _ := strconv.ParseInt(strings.TrimPrefix(choice, prefix), 10, 64)
|
||||
b, ok, err := s.myBotForUser(ctx, state.UserID, botID)
|
||||
if err != nil {
|
||||
return mybotsAlert(mybotsExpiredButtonText)
|
||||
}
|
||||
if !ok {
|
||||
return mybotsAlert(mybotsGoneBotText)
|
||||
}
|
||||
next := domain.BotChatState{
|
||||
BotUserID: domain.BotFatherUserID,
|
||||
UserID: state.UserID,
|
||||
Command: cmd,
|
||||
Step: botFatherStepValue,
|
||||
Draft: map[string]string{
|
||||
botFatherDraftBotID: strconv.FormatInt(b.user.ID, 10),
|
||||
botFatherDraftBotUsername: b.user.Username,
|
||||
mybotsDraftReturn: "1",
|
||||
mybotsDraftPage: state.Draft[mybotsDraftPage],
|
||||
},
|
||||
}
|
||||
if err := s.bots.UpsertBotChatState(ctx, next); err != nil {
|
||||
s.log.Error("botfather: save mybots value state", zap.Int64("user_id", state.UserID), zap.Error(err))
|
||||
return mybotsAlert(mybotsExpiredButtonText)
|
||||
}
|
||||
s.sendServiceBotReply(ctx, domain.BotFatherUserID, state.UserID, botReply{
|
||||
Text: valuePrompt(cmd, b.user.Username) + "\n\nOr send /cancel.",
|
||||
})
|
||||
return domain.BotCallbackAnswer{}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Screens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (s *Service) myBotsEmptyScreen(state *domain.BotChatState) botReply {
|
||||
// Nothing left to pick: drop the dialog so a stray text does not land on the
|
||||
// "use the buttons" reminder.
|
||||
state.Draft = map[string]string{}
|
||||
return botReply{Text: mybotsNoBotsText}
|
||||
}
|
||||
|
||||
// myBotsListScreen renders one page of the bot picker and records its buttons.
|
||||
func (s *Service) myBotsListScreen(ctx context.Context, state *domain.BotChatState, owned []ownedBot, page int) (botReply, bool) {
|
||||
pages := (len(owned) + mybotsPageSize - 1) / mybotsPageSize
|
||||
if pages == 0 {
|
||||
return s.myBotsEmptyScreen(state), true
|
||||
}
|
||||
if page < 0 {
|
||||
page = 0
|
||||
}
|
||||
if page >= pages {
|
||||
page = pages - 1
|
||||
}
|
||||
start := page * mybotsPageSize
|
||||
end := start + mybotsPageSize
|
||||
if end > len(owned) {
|
||||
end = len(owned)
|
||||
}
|
||||
|
||||
rows := make([][]mybotsOption, 0, mybotsPageSize+1)
|
||||
for _, b := range owned[start:end] {
|
||||
rows = append(rows, []mybotsOption{{
|
||||
text: mybotsBotButtonLabel(b),
|
||||
choice: mybotsChoiceBotPrefix + strconv.FormatInt(b.user.ID, 10),
|
||||
}})
|
||||
}
|
||||
if pages > 1 {
|
||||
var nav []mybotsOption
|
||||
if page > 0 {
|
||||
nav = append(nav, mybotsOption{text: "‹ Prev", choice: mybotsChoiceListPrefix + strconv.Itoa(page-1)})
|
||||
}
|
||||
if page < pages-1 {
|
||||
nav = append(nav, mybotsOption{text: "Next ›", choice: mybotsChoiceListPrefix + strconv.Itoa(page+1)})
|
||||
}
|
||||
if len(nav) > 0 {
|
||||
rows = append(rows, nav)
|
||||
}
|
||||
}
|
||||
|
||||
state.Step = mybotsStepMenu
|
||||
state.Draft[mybotsDraftPage] = strconv.Itoa(page)
|
||||
markup := s.mybotsKeyboard(state, rows)
|
||||
text := "Choose a bot from the list below."
|
||||
if pages > 1 {
|
||||
text = fmt.Sprintf("Choose a bot from the list below.\n\nPage %d of %d.", page+1, pages)
|
||||
}
|
||||
_ = ctx
|
||||
return botReply{Text: text, ReplyMarkup: markup}, markup != nil
|
||||
}
|
||||
|
||||
func (s *Service) myBotsBotMenu(state *domain.BotChatState, b ownedBot) botReply {
|
||||
page := mybotsDraftInt(*state, mybotsDraftPage)
|
||||
rows := [][]mybotsOption{
|
||||
{{text: "API Token", choice: mybotsChoiceTokenPrefix + botID64(b)}},
|
||||
{{text: "Edit Bot", choice: mybotsChoiceEditPrefix + botID64(b)}},
|
||||
{{text: "Bot Settings", choice: mybotsChoiceCfgPrefix + botID64(b)}},
|
||||
{{text: "Delete Bot", choice: mybotsChoiceDeletePrefix + botID64(b), style: domain.MarkupButtonStyleDanger}},
|
||||
{{text: "‹ Back to bots", choice: mybotsChoiceListPrefix + strconv.FormatInt(page, 10)}},
|
||||
}
|
||||
markup := s.mybotsKeyboard(state, rows)
|
||||
return botReply{
|
||||
Text: fmt.Sprintf("@%s\n\nWhat do you want to do?", b.user.Username),
|
||||
ReplyMarkup: markup,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) myBotsTokenScreen(ctx context.Context, state *domain.BotChatState, b ownedBot, lead string) botReply {
|
||||
_ = ctx
|
||||
profile, found, err := s.bots.GetBot(ctx, b.user.ID)
|
||||
if err != nil || !found || profile.TokenSecret == "" {
|
||||
if err != nil {
|
||||
s.log.Error("botfather: get bot for token", zap.Int64("bot_user_id", b.user.ID), zap.Error(err))
|
||||
}
|
||||
return internalReply()
|
||||
}
|
||||
return s.myBotsTokenScreenWithToken(state, b, domain.FormatBotToken(b.user.ID, profile.TokenSecret), lead)
|
||||
}
|
||||
|
||||
func (s *Service) myBotsTokenScreenWithToken(state *domain.BotChatState, b ownedBot, token, lead string) botReply {
|
||||
head := lead
|
||||
if head != "" {
|
||||
head += "\n\n"
|
||||
}
|
||||
head += fmt.Sprintf("Token for @%s:\n", b.user.Username)
|
||||
reply := tokenReply(head, token, "\n\nKeep it secret - anyone with this token controls the bot.")
|
||||
rows := [][]mybotsOption{
|
||||
{{text: "Revoke current token", choice: mybotsChoiceRevokePrefix + botID64(b), style: domain.MarkupButtonStyleDanger}},
|
||||
{{text: "‹ Back", choice: mybotsChoiceBotPrefix + botID64(b)}},
|
||||
}
|
||||
reply.ReplyMarkup = s.mybotsKeyboard(state, rows)
|
||||
return reply
|
||||
}
|
||||
|
||||
func (s *Service) myBotsRevokeConfirm(state *domain.BotChatState, b ownedBot) botReply {
|
||||
rows := [][]mybotsOption{
|
||||
{{text: fmt.Sprintf("Yes, revoke @%s's token", b.user.Username), choice: mybotsChoiceRevokeGoPrefix + botID64(b), style: domain.MarkupButtonStyleDanger}},
|
||||
{{text: "‹ Keep it", choice: mybotsChoiceTokenPrefix + botID64(b)}},
|
||||
}
|
||||
return botReply{
|
||||
Text: fmt.Sprintf("Revoke the current token for @%s?\n\nThe old token stops working immediately and a new one is generated. Anything using the old token will need updating.", b.user.Username),
|
||||
ReplyMarkup: s.mybotsKeyboard(state, rows),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) myBotsEditMenu(ctx context.Context, state *domain.BotChatState, b ownedBot) botReply {
|
||||
name, about, description, err := s.GetBotInfo(ctx, b.user.ID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: get bot info for edit menu", zap.Int64("bot_user_id", b.user.ID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
commands, err := s.GetBotCommands(ctx, b.user.ID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: get bot commands for edit menu", zap.Int64("bot_user_id", b.user.ID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
page := mybotsDraftInt(*state, mybotsDraftPage)
|
||||
rows := [][]mybotsOption{
|
||||
{{text: "Edit Name", choice: mybotsChoiceSetNamePrefix + botID64(b)}},
|
||||
{{text: "Edit About", choice: mybotsChoiceSetAboutPrefix + botID64(b)}},
|
||||
{{text: "Edit Description", choice: mybotsChoiceSetDescPrefix + botID64(b)}},
|
||||
{{text: "Edit Botpic", choice: mybotsChoiceBotpicPrefix + botID64(b)}},
|
||||
{{text: "Edit Commands", choice: mybotsChoiceSetCmdsPrefix + botID64(b)}},
|
||||
{
|
||||
{text: "‹ Back to bot", choice: mybotsChoiceBotPrefix + botID64(b)},
|
||||
{text: "‹‹ Bots list", choice: mybotsChoiceListPrefix + strconv.FormatInt(page, 10)},
|
||||
},
|
||||
}
|
||||
return botReply{
|
||||
Text: myBotsEditSummary(b.user.Username, name, about, description, commands, s.botHasAvatar(ctx, b.user.ID)),
|
||||
ReplyMarkup: s.mybotsKeyboard(state, rows),
|
||||
}
|
||||
}
|
||||
|
||||
// myBotsEditSummary renders the "Edit @bot info" screen: the current value of
|
||||
// every editable field, mirroring what BotFather shows.
|
||||
func myBotsEditSummary(username, name, about, description string, commands []domain.BotCommand, hasBotpic bool) string {
|
||||
orNone := func(v string) string {
|
||||
v = strings.ReplaceAll(strings.TrimSpace(v), "\n", " ")
|
||||
if v == "" {
|
||||
return "🚫"
|
||||
}
|
||||
if r := []rune(v); len(r) > 120 {
|
||||
v = string(r[:117]) + "..."
|
||||
}
|
||||
return v
|
||||
}
|
||||
cmds := "no commands yet"
|
||||
if n := len(commands); n == 1 {
|
||||
cmds = "1 command"
|
||||
} else if n > 1 {
|
||||
cmds = fmt.Sprintf("%d commands", n)
|
||||
}
|
||||
botpic := "🚫 no botpic"
|
||||
if hasBotpic {
|
||||
botpic = "🖼 has a botpic"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"Edit @%s info.\n\nName: %s\nAbout: %s\nDescription: %s\nDescription picture: 🚫 no description picture\nBotpic: %s\nCommands: %s\nPrivacy Policy: 🚫",
|
||||
username, orNone(name), orNone(about), orNone(description), botpic, cmds,
|
||||
)
|
||||
}
|
||||
|
||||
// botHasAvatar reports whether the bot currently has a profile photo. Without a
|
||||
// file layer wired it answers false.
|
||||
func (s *Service) botHasAvatar(ctx context.Context, botUserID int64) bool {
|
||||
if s.botAvatar == nil {
|
||||
return false
|
||||
}
|
||||
ok, err := s.botAvatar.PeerHasAvatar(ctx, domain.PeerTypeUser, botUserID)
|
||||
if err != nil {
|
||||
s.log.Warn("botfather: check bot avatar", zap.Int64("bot_user_id", botUserID), zap.Error(err))
|
||||
return false
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// myBotsReturnToEditMenu rebuilds a fresh /mybots dialog on the Edit Bot menu
|
||||
// after a field was edited through the shared value-input flow, so the follow-up
|
||||
// message carries working "Back to bot" / "Bots list" buttons instead of the
|
||||
// dialog just ending.
|
||||
func (s *Service) myBotsReturnToEditMenu(ctx context.Context, userID, botID int64, page, lead string) botReply {
|
||||
b, ok, err := s.myBotForUser(ctx, userID, botID)
|
||||
if err != nil || !ok {
|
||||
s.clearState(ctx, userID)
|
||||
return botReply{Text: strings.TrimSpace(lead)}
|
||||
}
|
||||
st := domain.BotChatState{
|
||||
BotUserID: domain.BotFatherUserID,
|
||||
UserID: userID,
|
||||
Command: mybotsCommand,
|
||||
Step: mybotsStepMenu,
|
||||
Draft: map[string]string{},
|
||||
}
|
||||
if p := strings.TrimSpace(page); p != "" {
|
||||
st.Draft[mybotsDraftPage] = p
|
||||
}
|
||||
menu := s.myBotsEditMenu(ctx, &st, b)
|
||||
if menu.ReplyMarkup == nil || !s.saveMyBotsState(ctx, st) {
|
||||
s.clearState(ctx, userID)
|
||||
return botReply{Text: strings.TrimSpace(lead)}
|
||||
}
|
||||
if lead = strings.TrimSpace(lead); lead != "" {
|
||||
menu.Text = lead + "\n\n" + menu.Text
|
||||
}
|
||||
return menu
|
||||
}
|
||||
|
||||
func (s *Service) myBotsSettingsScreen(state *domain.BotChatState, b ownedBot, lead string) botReply {
|
||||
inlineOn := b.profile.InlinePlaceholder != ""
|
||||
groupsOn := !b.profile.Nochats
|
||||
privacyOn := !b.profile.ChatHistory
|
||||
|
||||
rows := [][]mybotsOption{
|
||||
{{text: "Inline Mode: " + onOff(inlineOn), choice: mybotsChoiceCfgInline + botID64(b)}},
|
||||
{{text: "Allow Groups: " + onOff(groupsOn), choice: mybotsChoiceCfgGroups + botID64(b)}},
|
||||
{{text: "Group Privacy: " + onOff(privacyOn), choice: mybotsChoiceCfgPrivacy + botID64(b)}},
|
||||
{{text: "‹ Back", choice: mybotsChoiceBotPrefix + botID64(b)}},
|
||||
}
|
||||
body := fmt.Sprintf("Settings for @%s. Tap a row to flip it.\n\n"+
|
||||
"- Inline Mode: %s\n"+
|
||||
"- Allow Groups: %s (can the bot be added to groups)\n"+
|
||||
"- Group Privacy: %s (on = only sees commands and replies in groups)",
|
||||
b.user.Username, onOff(inlineOn), onOff(groupsOn), onOff(privacyOn))
|
||||
if lead != "" {
|
||||
body = lead + "\n\n" + body
|
||||
}
|
||||
return botReply{Text: body, ReplyMarkup: s.mybotsKeyboard(state, rows)}
|
||||
}
|
||||
|
||||
func (s *Service) myBotsDeleteConfirm(state *domain.BotChatState, b ownedBot) botReply {
|
||||
rows := [][]mybotsOption{
|
||||
{{text: fmt.Sprintf("Yes, delete @%s", b.user.Username), choice: mybotsChoiceDeleteGoPrefix + botID64(b), style: domain.MarkupButtonStyleDanger}},
|
||||
{{text: "‹ Cancel", choice: mybotsChoiceBotPrefix + botID64(b)}},
|
||||
}
|
||||
return botReply{
|
||||
Text: fmt.Sprintf("Delete @%s for good?\n\nThis cannot be undone. The bot's token stops working, its sessions are cut off, and the username is released.", b.user.Username),
|
||||
ReplyMarkup: s.mybotsKeyboard(state, rows),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lookups
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// myBotForUser returns the owned bot named by id. ok=false means it is not (any
|
||||
// longer) one of this user's bots, which is also the authorisation check.
|
||||
func (s *Service) myBotForUser(ctx context.Context, userID, botID int64) (ownedBot, bool, error) {
|
||||
if botID <= 0 {
|
||||
return ownedBot{}, false, nil
|
||||
}
|
||||
owned, err := s.ownedBots(ctx, userID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: list bots", zap.Int64("user_id", userID), zap.Error(err))
|
||||
return ownedBot{}, false, err
|
||||
}
|
||||
for _, b := range owned {
|
||||
if b.user.ID == botID {
|
||||
return b, true, nil
|
||||
}
|
||||
}
|
||||
return ownedBot{}, false, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Button tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// mybotsKeyboard renders one inline keyboard and records its buttons in the
|
||||
// per-user token table. Same contract as verifyOptionKeyboard: callback data is
|
||||
// only ever the prefix plus a random token that keys into *this* user's own
|
||||
// BotChatState.Draft, tokens are minted per render and kept for
|
||||
// mybotsTokenGenerations renders.
|
||||
func (s *Service) mybotsKeyboard(state *domain.BotChatState, rows [][]mybotsOption) *domain.MessageReplyMarkup {
|
||||
if state == nil || len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
if state.Draft == nil {
|
||||
state.Draft = map[string]string{}
|
||||
}
|
||||
generation := mybotsDraftInt(*state, mybotsDraftGeneration) + 1
|
||||
state.Draft[mybotsDraftGeneration] = strconv.FormatInt(generation, 10)
|
||||
mybotsPruneOptions(state, generation)
|
||||
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline}
|
||||
for _, row := range rows {
|
||||
buttons := make([]domain.MarkupButton, 0, len(row))
|
||||
for _, option := range row {
|
||||
if option.text == "" || option.choice == "" {
|
||||
continue
|
||||
}
|
||||
token := s.mybotsOptionToken()
|
||||
state.Draft[mybotsDraftOptionPrefix+token] = strconv.FormatInt(generation, 10) + "|" + option.choice
|
||||
buttons = append(buttons, domain.MarkupButton{
|
||||
Type: domain.MarkupButtonCallback,
|
||||
Text: option.text,
|
||||
Style: option.style,
|
||||
Data: []byte(mybotsCallbackDataPrefix + token),
|
||||
})
|
||||
}
|
||||
if len(buttons) > 0 {
|
||||
markup.Inline = append(markup.Inline, buttons)
|
||||
}
|
||||
}
|
||||
if len(markup.Inline) == 0 {
|
||||
return nil
|
||||
}
|
||||
return markup
|
||||
}
|
||||
|
||||
func (s *Service) mybotsOptionToken() string {
|
||||
var buf [mybotsOptionTokenBytes]byte
|
||||
if _, err := rand.Read(buf[:]); err == nil {
|
||||
return hex.EncodeToString(buf[:])
|
||||
}
|
||||
// A crypto/rand failure must not brick the menu. Unpredictability is defence
|
||||
// in depth: a token is only ever resolved against the caller's own chat state,
|
||||
// and the RPC edge already requires the data to appear in a keyboard of a
|
||||
// message in the caller's own box.
|
||||
return strconv.FormatInt(s.now().UnixNano()+s.replySeq.Add(1), 36)
|
||||
}
|
||||
|
||||
// mybotsResolveOption maps callback data back onto the choice recorded for it.
|
||||
// Anything not in this user's own table is refused.
|
||||
func mybotsResolveOption(state domain.BotChatState, data []byte) (string, bool) {
|
||||
raw := string(data)
|
||||
if !strings.HasPrefix(raw, mybotsCallbackDataPrefix) {
|
||||
return "", false
|
||||
}
|
||||
token := raw[len(mybotsCallbackDataPrefix):]
|
||||
if token == "" || len(token) > mybotsOptionTokenMaxLen {
|
||||
return "", false
|
||||
}
|
||||
value, found := state.Draft[mybotsDraftOptionPrefix+token]
|
||||
if !found {
|
||||
return "", false
|
||||
}
|
||||
_, choice, split := strings.Cut(value, "|")
|
||||
if !split || choice == "" {
|
||||
return "", false
|
||||
}
|
||||
return choice, true
|
||||
}
|
||||
|
||||
func mybotsPruneOptions(state *domain.BotChatState, generation int64) {
|
||||
oldest := generation - mybotsTokenGenerations + 1
|
||||
for key, value := range state.Draft {
|
||||
if !strings.HasPrefix(key, mybotsDraftOptionPrefix) {
|
||||
continue
|
||||
}
|
||||
rawGen, _, _ := strings.Cut(value, "|")
|
||||
gen, err := strconv.ParseInt(rawGen, 10, 64)
|
||||
if err != nil || gen < oldest {
|
||||
delete(state.Draft, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State + small helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (s *Service) saveMyBotsState(ctx context.Context, state domain.BotChatState) bool {
|
||||
state.BotUserID = domain.BotFatherUserID
|
||||
state.Command = mybotsCommand
|
||||
if state.Step == "" {
|
||||
state.Step = mybotsStepMenu
|
||||
}
|
||||
clone := domain.BotChatState{
|
||||
BotUserID: state.BotUserID,
|
||||
UserID: state.UserID,
|
||||
Command: state.Command,
|
||||
Step: state.Step,
|
||||
Draft: make(map[string]string, len(state.Draft)),
|
||||
}
|
||||
for key, value := range state.Draft {
|
||||
clone.Draft[key] = value
|
||||
}
|
||||
if err := s.bots.UpsertBotChatState(ctx, clone); err != nil {
|
||||
s.log.Error("botfather: save mybots state", zap.Int64("user_id", state.UserID), zap.Error(err))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func mybotsDraftInt(state domain.BotChatState, key string) int64 {
|
||||
value, err := strconv.ParseInt(strings.TrimSpace(state.Draft[key]), 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func mybotsAlert(text string) domain.BotCallbackAnswer {
|
||||
if len([]rune(text)) > domain.MaxBotCallbackAnswerLen {
|
||||
text = string([]rune(text)[:domain.MaxBotCallbackAnswerLen])
|
||||
}
|
||||
return domain.BotCallbackAnswer{Alert: true, Message: text}
|
||||
}
|
||||
|
||||
func botID64(b ownedBot) string { return strconv.FormatInt(b.user.ID, 10) }
|
||||
|
||||
func onOff(on bool) string {
|
||||
if on {
|
||||
return "on"
|
||||
}
|
||||
return "off"
|
||||
}
|
||||
|
||||
func mybotsBotButtonLabel(b ownedBot) string {
|
||||
if b.user.Username != "" {
|
||||
return "@" + b.user.Username
|
||||
}
|
||||
name := strings.TrimSpace(b.user.FirstName)
|
||||
if name == "" {
|
||||
name = "bot " + strconv.FormatInt(b.user.ID, 10)
|
||||
}
|
||||
return name
|
||||
}
|
||||
128
internal/app/bots/mybots_botpic_test.go
Normal file
128
internal/app/bots/mybots_botpic_test.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
package bots
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type fakeBotAvatar struct {
|
||||
ownerType domain.PeerType
|
||||
ownerID int64
|
||||
sourcePhotoID int64
|
||||
calls int
|
||||
err error
|
||||
hasAvatar bool
|
||||
}
|
||||
|
||||
func (f *fakeBotAvatar) PeerHasAvatar(_ context.Context, _ domain.PeerType, _ int64) (bool, error) {
|
||||
return f.hasAvatar, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAvatar) SetAvatarFromExistingPhoto(_ context.Context, ownerType domain.PeerType, ownerID, sourcePhotoID int64, _ int) (domain.Photo, error) {
|
||||
f.calls++
|
||||
f.ownerType, f.ownerID, f.sourcePhotoID = ownerType, ownerID, sourcePhotoID
|
||||
if f.err != nil {
|
||||
return domain.Photo{}, f.err
|
||||
}
|
||||
return domain.Photo{ID: 90210}, nil
|
||||
}
|
||||
|
||||
func botFatherPhotoMsg(userID, photoID int64) domain.Message {
|
||||
return domain.Message{
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.BotFatherUserID},
|
||||
Media: &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindPhoto,
|
||||
Photo: &domain.Photo{ID: photoID},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func openBotpicPrompt(t *testing.T, svc *Service, messages *memory.MessageStore, owner domain.User) {
|
||||
t.Helper()
|
||||
sendToBotFather(t, svc, messages, owner, "/mybots")
|
||||
pressBotFather(t, svc, messages, owner.ID, "@pic_mb_bot")
|
||||
pressBotFather(t, svc, messages, owner.ID, "Edit Bot")
|
||||
_, prompt := pressBotFather(t, svc, messages, owner.ID, "Edit Botpic")
|
||||
if !strings.Contains(prompt.Body, "profile picture") {
|
||||
t.Fatalf("botpic prompt = %q", prompt.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMyBotsEditBotpicSetsPhoto(t *testing.T) {
|
||||
users := memory.NewUserStore()
|
||||
bots := memory.NewBotStore(users)
|
||||
messages := memory.NewMessageStore(memory.NewDialogStore())
|
||||
avatar := &fakeBotAvatar{}
|
||||
svc := NewService(users, bots, messages, WithBotAvatarStore(avatar))
|
||||
owner := newOwner(t, users, "+2100")
|
||||
created, _, err := svc.CreateBot(context.Background(), owner.ID, "Pic Bot", "pic_mb_bot")
|
||||
if err != nil {
|
||||
t.Fatalf("create bot: %v", err)
|
||||
}
|
||||
|
||||
openBotpicPrompt(t, svc, messages, owner)
|
||||
|
||||
// A non-photo message keeps the step and asks again.
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "here you go"); !strings.Contains(reply, "send a photo") {
|
||||
t.Fatalf("text-instead-of-photo reply = %q", reply)
|
||||
}
|
||||
if avatar.calls != 0 {
|
||||
t.Fatalf("avatar setter called for a non-photo message")
|
||||
}
|
||||
|
||||
svc.respondAsBotFather(owner.ID, botFatherPhotoMsg(owner.ID, 7777))
|
||||
reply := botFatherUserReply(t, messages, owner.ID)
|
||||
if !strings.Contains(reply.Body, "Profile picture updated") {
|
||||
t.Fatalf("botpic success reply = %q", reply.Body)
|
||||
}
|
||||
if avatar.calls != 1 || avatar.ownerID != created.ID || avatar.sourcePhotoID != 7777 || avatar.ownerType != domain.PeerTypeUser {
|
||||
t.Fatalf("avatar setter got (calls=%d owner=%d photo=%d type=%s)", avatar.calls, avatar.ownerID, avatar.sourcePhotoID, avatar.ownerType)
|
||||
}
|
||||
// The dialog is done: a stray message no longer lands on the botpic step.
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "anything"); strings.Contains(reply, "profile picture") {
|
||||
t.Fatalf("botpic step still active after success: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMyBotsEditBotpicRejectsBadImage(t *testing.T) {
|
||||
users := memory.NewUserStore()
|
||||
bots := memory.NewBotStore(users)
|
||||
messages := memory.NewMessageStore(memory.NewDialogStore())
|
||||
avatar := &fakeBotAvatar{err: domain.ErrPhotoInvalid}
|
||||
svc := NewService(users, bots, messages, WithBotAvatarStore(avatar))
|
||||
owner := newOwner(t, users, "+2101")
|
||||
if _, _, err := svc.CreateBot(context.Background(), owner.ID, "Pic Bot", "pic_mb_bot"); err != nil {
|
||||
t.Fatalf("create bot: %v", err)
|
||||
}
|
||||
|
||||
openBotpicPrompt(t, svc, messages, owner)
|
||||
svc.respondAsBotFather(owner.ID, botFatherPhotoMsg(owner.ID, 7777))
|
||||
reply := botFatherUserReply(t, messages, owner.ID)
|
||||
if !strings.Contains(reply.Body, "couldn't use that image") {
|
||||
t.Fatalf("bad image reply = %q", reply.Body)
|
||||
}
|
||||
// Step is kept so the user can send another photo.
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "x"); !strings.Contains(reply, "send a photo") {
|
||||
t.Fatalf("after bad image, step not kept: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMyBotsEditBotpicUnsupported(t *testing.T) {
|
||||
svc, users, _, messages := newTestService(t) // no WithBotAvatarStore
|
||||
owner := newOwner(t, users, "+2102")
|
||||
if _, _, err := svc.CreateBot(context.Background(), owner.ID, "Pic Bot", "pic_mb_bot"); err != nil {
|
||||
t.Fatalf("create bot: %v", err)
|
||||
}
|
||||
|
||||
openBotpicPrompt(t, svc, messages, owner)
|
||||
svc.respondAsBotFather(owner.ID, botFatherPhotoMsg(owner.ID, 7777))
|
||||
reply := botFatherUserReply(t, messages, owner.ID)
|
||||
if !strings.Contains(reply.Body, "isn't available on this server") {
|
||||
t.Fatalf("unsupported reply = %q", reply.Body)
|
||||
}
|
||||
}
|
||||
475
internal/app/bots/mybots_test.go
Normal file
475
internal/app/bots/mybots_test.go
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
package bots
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func botFatherUserReply(t *testing.T, messages *memory.MessageStore, userID int64) domain.Message {
|
||||
t.Helper()
|
||||
list, err := messages.ListByUser(context.Background(), userID, domain.MessageFilter{
|
||||
HasPeer: true,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.BotFatherUserID},
|
||||
Limit: 200,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list user history: %v", err)
|
||||
}
|
||||
var latest domain.Message
|
||||
for _, msg := range list.Messages {
|
||||
if msg.From.ID == domain.BotFatherUserID && msg.ID >= latest.ID {
|
||||
latest = msg
|
||||
}
|
||||
}
|
||||
if latest.ID == 0 {
|
||||
t.Fatal("no @BotFather reply in the user's box")
|
||||
}
|
||||
if err := domain.ValidateReplyMarkup(latest.ReplyMarkup); err != nil {
|
||||
t.Fatalf("reply markup invalid: %v (%+v)", err, latest.ReplyMarkup)
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
// botFatherBotSideMessageID is the id of the bot's own copy of its latest reply
|
||||
// to userID -- what the RPC edge resolves query.MessageID to before calling the
|
||||
// responder, and what editServiceBotMessage addresses.
|
||||
func botFatherBotSideMessageID(t *testing.T, messages *memory.MessageStore, userID int64) int {
|
||||
t.Helper()
|
||||
list, err := messages.ListByUser(context.Background(), domain.BotFatherUserID, domain.MessageFilter{
|
||||
HasPeer: true,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
|
||||
Limit: 200,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list bot history: %v", err)
|
||||
}
|
||||
latest := 0
|
||||
for _, msg := range list.Messages {
|
||||
if msg.From.ID == domain.BotFatherUserID && msg.ID > latest {
|
||||
latest = msg.ID
|
||||
}
|
||||
}
|
||||
if latest == 0 {
|
||||
t.Fatal("no @BotFather copy of its own reply")
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
func mybotsButtonData(msg domain.Message, label string) ([]byte, bool) {
|
||||
if msg.ReplyMarkup == nil {
|
||||
return nil, false
|
||||
}
|
||||
for _, row := range msg.ReplyMarkup.Inline {
|
||||
for _, button := range row {
|
||||
if button.Type == domain.MarkupButtonCallback && strings.Contains(button.Text, label) {
|
||||
return append([]byte(nil), button.Data...), true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func mybotsHasButton(msg domain.Message, label string) bool {
|
||||
_, ok := mybotsButtonData(msg, label)
|
||||
return ok
|
||||
}
|
||||
|
||||
func hasMentionEntity(msg domain.Message, mention string) bool {
|
||||
for _, e := range msg.Entities {
|
||||
if e.Type == domain.MessageEntityMention && e.Length == len(mention) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pressBotFather clicks the button whose text contains label on the user's latest
|
||||
// @BotFather reply and returns the callback answer plus the user's new latest
|
||||
// reply (which, for an in-place edit, is the same message updated).
|
||||
func pressBotFather(t *testing.T, svc *Service, messages *memory.MessageStore, userID int64, label string) (domain.BotCallbackAnswer, domain.Message) {
|
||||
t.Helper()
|
||||
reply := botFatherUserReply(t, messages, userID)
|
||||
data, ok := mybotsButtonData(reply, label)
|
||||
if !ok {
|
||||
t.Fatalf("button %q not in keyboard: %+v", label, reply.ReplyMarkup)
|
||||
}
|
||||
return pressBotFatherData(t, svc, messages, userID, data)
|
||||
}
|
||||
|
||||
func pressBotFatherData(t *testing.T, svc *Service, messages *memory.MessageStore, userID int64, data []byte) (domain.BotCallbackAnswer, domain.Message) {
|
||||
t.Helper()
|
||||
answer, handled, err := svc.OnCallbackQuery(context.Background(), domain.BotCallbackQuery{
|
||||
ID: 1,
|
||||
BotUserID: domain.BotFatherUserID,
|
||||
UserID: userID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
|
||||
MessageID: botFatherBotSideMessageID(t, messages, userID),
|
||||
Data: data,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("callback query: %v", err)
|
||||
}
|
||||
if !handled {
|
||||
t.Fatal("callback reported unhandled for @BotFather")
|
||||
}
|
||||
return answer, botFatherUserReply(t, messages, userID)
|
||||
}
|
||||
|
||||
func makeBotsP(t *testing.T, svc *Service, ownerID int64, prefix string, n int) {
|
||||
t.Helper()
|
||||
for i := 0; i < n; i++ {
|
||||
if _, _, err := svc.CreateBot(context.Background(), ownerID, fmt.Sprintf("Bot %d", i), fmt.Sprintf("%s%d_bot", prefix, i)); err != nil {
|
||||
t.Fatalf("create bot %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeBots(t *testing.T, svc *Service, ownerID int64, n int) {
|
||||
t.Helper()
|
||||
makeBotsP(t, svc, ownerID, "mb", n)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMyBotsPickerPaginates(t *testing.T) {
|
||||
svc, users, _, messages := newTestService(t)
|
||||
owner := newOwner(t, users, "+2001")
|
||||
makeBots(t, svc, owner.ID, mybotsPageSize+3)
|
||||
|
||||
sendToBotFather(t, svc, messages, owner, "/mybots")
|
||||
page1 := botFatherUserReply(t, messages, owner.ID)
|
||||
if !strings.Contains(page1.Body, "Page 1 of 2") {
|
||||
t.Fatalf("page 1 body = %q", page1.Body)
|
||||
}
|
||||
if mybotsHasButton(page1, "‹ Prev") {
|
||||
t.Fatal("first page must not offer Prev")
|
||||
}
|
||||
if !mybotsHasButton(page1, "Next ›") {
|
||||
t.Fatal("first page must offer Next")
|
||||
}
|
||||
if !mybotsHasButton(page1, "@mb0_bot") {
|
||||
t.Fatalf("page 1 missing @mb0_bot: %+v", page1.ReplyMarkup)
|
||||
}
|
||||
|
||||
_, page2 := pressBotFather(t, svc, messages, owner.ID, "Next ›")
|
||||
if !strings.Contains(page2.Body, "Page 2 of 2") {
|
||||
t.Fatalf("page 2 body = %q", page2.Body)
|
||||
}
|
||||
if mybotsHasButton(page2, "Next ›") {
|
||||
t.Fatal("last page must not offer Next")
|
||||
}
|
||||
if !mybotsHasButton(page2, "‹ Prev") {
|
||||
t.Fatal("last page must offer Prev")
|
||||
}
|
||||
if !mybotsHasButton(page2, "@mb12_bot") {
|
||||
t.Fatalf("page 2 missing @mb12_bot: %+v", page2.ReplyMarkup)
|
||||
}
|
||||
// The pager edits one message in place rather than piling up new ones.
|
||||
if page2.ID != page1.ID {
|
||||
t.Fatalf("pager sent a new message (%d -> %d), want in-place edit", page1.ID, page2.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMyBotsBotMenuAndBack(t *testing.T) {
|
||||
svc, users, _, messages := newTestService(t)
|
||||
owner := newOwner(t, users, "+2002")
|
||||
makeBots(t, svc, owner.ID, 2)
|
||||
|
||||
sendToBotFather(t, svc, messages, owner, "/mybots")
|
||||
_, menu := pressBotFather(t, svc, messages, owner.ID, "@mb0_bot")
|
||||
if !strings.Contains(menu.Body, "@mb0_bot") {
|
||||
t.Fatalf("bot menu body = %q", menu.Body)
|
||||
}
|
||||
for _, want := range []string{"API Token", "Edit Bot", "Bot Settings", "Delete Bot", "Back to bots"} {
|
||||
if !mybotsHasButton(menu, want) {
|
||||
t.Fatalf("bot menu missing %q: %+v", want, menu.ReplyMarkup)
|
||||
}
|
||||
}
|
||||
_, back := pressBotFather(t, svc, messages, owner.ID, "Back to bots")
|
||||
if !strings.Contains(back.Body, "Choose a bot") {
|
||||
t.Fatalf("back reply = %q", back.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFatherStartWithBotOpensItsMenu(t *testing.T) {
|
||||
svc, users, _, messages := newTestService(t)
|
||||
owner := newOwner(t, users, "+2011")
|
||||
makeBots(t, svc, owner.ID, 2)
|
||||
|
||||
// "/start <bot>" is the "Manage Bot" deep link: it lands on the per-bot menu.
|
||||
body := sendToBotFather(t, svc, messages, owner, "/start mb0_bot")
|
||||
if !strings.Contains(body, "@mb0_bot") || !strings.Contains(body, "What do you want to do?") {
|
||||
t.Fatalf("/start mb0_bot reply = %q", body)
|
||||
}
|
||||
menu := botFatherUserReply(t, messages, owner.ID)
|
||||
for _, want := range []string{"API Token", "Edit Bot", "Bot Settings", "Delete Bot", "Back to bots"} {
|
||||
if !mybotsHasButton(menu, want) {
|
||||
t.Fatalf("start menu missing %q: %+v", want, menu.ReplyMarkup)
|
||||
}
|
||||
}
|
||||
// The buttons are live (state was saved), so Edit Bot works from here.
|
||||
_, edit := pressBotFather(t, svc, messages, owner.ID, "Edit Bot")
|
||||
if !strings.Contains(edit.Body, "@mb0_bot") {
|
||||
t.Fatalf("edit menu after /start = %q", edit.Body)
|
||||
}
|
||||
|
||||
// A leading @ and an unknown/foreign bot fall back to the greeting.
|
||||
if body := sendToBotFather(t, svc, messages, owner, "/start @mb1_bot"); !strings.Contains(body, "@mb1_bot") {
|
||||
t.Fatalf("/start @mb1_bot reply = %q", body)
|
||||
}
|
||||
if body := sendToBotFather(t, svc, messages, owner, "/start not_a_real_bot"); !strings.Contains(body, "create a new bot") {
|
||||
t.Fatalf("/start unknown reply = %q, want greeting", body)
|
||||
}
|
||||
if body := sendToBotFather(t, svc, messages, owner, "/start"); !strings.Contains(body, "create a new bot") {
|
||||
t.Fatalf("bare /start reply = %q, want greeting", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMyBotsTokenAndRevoke(t *testing.T) {
|
||||
svc, users, bots, messages := newTestService(t)
|
||||
owner := newOwner(t, users, "+2003")
|
||||
created, token, err := svc.CreateBot(context.Background(), owner.ID, "Tok Bot", "tok_mb_bot")
|
||||
if err != nil {
|
||||
t.Fatalf("create bot: %v", err)
|
||||
}
|
||||
|
||||
sendToBotFather(t, svc, messages, owner, "/mybots")
|
||||
pressBotFather(t, svc, messages, owner.ID, "@tok_mb_bot")
|
||||
_, tokenScreen := pressBotFather(t, svc, messages, owner.ID, "API Token")
|
||||
if !strings.Contains(tokenScreen.Body, token) {
|
||||
t.Fatalf("token screen %q missing token %q", tokenScreen.Body, token)
|
||||
}
|
||||
|
||||
pressBotFather(t, svc, messages, owner.ID, "Revoke current token")
|
||||
_, revoked := pressBotFather(t, svc, messages, owner.ID, "Yes, revoke")
|
||||
if strings.Contains(revoked.Body, token) {
|
||||
t.Fatalf("revoke screen still shows the old token: %q", revoked.Body)
|
||||
}
|
||||
if !strings.Contains(revoked.Body, "has been revoked") {
|
||||
t.Fatalf("revoke screen = %q", revoked.Body)
|
||||
}
|
||||
profile, _, err := bots.GetBot(context.Background(), created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get bot: %v", err)
|
||||
}
|
||||
if domain.FormatBotToken(created.ID, profile.TokenSecret) == token {
|
||||
t.Fatal("token was not rotated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMyBotsSettingsToggles(t *testing.T) {
|
||||
svc, users, bots, messages := newTestService(t)
|
||||
owner := newOwner(t, users, "+2004")
|
||||
created, _, err := svc.CreateBot(context.Background(), owner.ID, "Cfg Bot", "cfg_mb_bot")
|
||||
if err != nil {
|
||||
t.Fatalf("create bot: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
sendToBotFather(t, svc, messages, owner, "/mybots")
|
||||
pressBotFather(t, svc, messages, owner.ID, "@cfg_mb_bot")
|
||||
_, settings := pressBotFather(t, svc, messages, owner.ID, "Bot Settings")
|
||||
if !mybotsHasButton(settings, "Inline Mode: off") {
|
||||
t.Fatalf("settings screen: %+v", settings.ReplyMarkup)
|
||||
}
|
||||
|
||||
_, afterInline := pressBotFather(t, svc, messages, owner.ID, "Inline Mode:")
|
||||
if !mybotsHasButton(afterInline, "Inline Mode: on") {
|
||||
t.Fatalf("inline not toggled on: %+v", afterInline.ReplyMarkup)
|
||||
}
|
||||
profile, _, _ := bots.GetBot(ctx, created.ID)
|
||||
if profile.InlinePlaceholder == "" {
|
||||
t.Fatal("inline placeholder not set after toggle")
|
||||
}
|
||||
|
||||
privacyBefore := mybotsHasButton(afterInline, "Group Privacy: on")
|
||||
_, afterPrivacy := pressBotFather(t, svc, messages, owner.ID, "Group Privacy:")
|
||||
if mybotsHasButton(afterPrivacy, "Group Privacy: on") == privacyBefore {
|
||||
t.Fatalf("privacy not toggled: %+v", afterPrivacy.ReplyMarkup)
|
||||
}
|
||||
privProfile, _, _ := bots.GetBot(ctx, created.ID)
|
||||
if (!privProfile.ChatHistory) == privacyBefore {
|
||||
t.Fatal("privacy flag not flipped in the store")
|
||||
}
|
||||
|
||||
_, afterGroups := pressBotFather(t, svc, messages, owner.ID, "Allow Groups:")
|
||||
if !mybotsHasButton(afterGroups, "Allow Groups: off") {
|
||||
t.Fatalf("groups not toggled off: %+v", afterGroups.ReplyMarkup)
|
||||
}
|
||||
profile, _, _ = bots.GetBot(ctx, created.ID)
|
||||
if !profile.Nochats {
|
||||
t.Fatal("nochats not set after toggling groups off")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMyBotsEditNameHandsOffToValueInput(t *testing.T) {
|
||||
svc, users, _, messages := newTestService(t)
|
||||
owner := newOwner(t, users, "+2005")
|
||||
created, _, err := svc.CreateBot(context.Background(), owner.ID, "Old Name", "edit_mb_bot")
|
||||
if err != nil {
|
||||
t.Fatalf("create bot: %v", err)
|
||||
}
|
||||
|
||||
sendToBotFather(t, svc, messages, owner, "/mybots")
|
||||
pressBotFather(t, svc, messages, owner.ID, "@edit_mb_bot")
|
||||
pressBotFather(t, svc, messages, owner.ID, "Edit Bot")
|
||||
answer, prompt := pressBotFather(t, svc, messages, owner.ID, "Edit Name")
|
||||
if answer.Message != "" {
|
||||
t.Fatalf("edit-name click alerted: %q", answer.Message)
|
||||
}
|
||||
if !strings.Contains(prompt.Body, "new name") {
|
||||
t.Fatalf("edit-name prompt = %q", prompt.Body)
|
||||
}
|
||||
|
||||
reply := sendToBotFather(t, svc, messages, owner, "Shiny New Name")
|
||||
if !strings.Contains(reply, "Name updated") {
|
||||
t.Fatalf("set name reply = %q", reply)
|
||||
}
|
||||
name, _, _, err := svc.GetBotInfo(context.Background(), created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get bot info: %v", err)
|
||||
}
|
||||
if name != "Shiny New Name" {
|
||||
t.Fatalf("bot name = %q, want updated", name)
|
||||
}
|
||||
}
|
||||
|
||||
// deletableBotStore adds a real DeleteBotAccount (memory.BotStore has none) so
|
||||
// the /mybots delete flow can be exercised end to end.
|
||||
type deletableBotStore struct {
|
||||
*memory.BotStore
|
||||
deleted map[int64]bool
|
||||
}
|
||||
|
||||
func (d *deletableBotStore) DeleteBotAccount(ctx context.Context, botUserID int64) (domain.User, error) {
|
||||
d.deleted[botUserID] = true
|
||||
return domain.User{ID: botUserID, Bot: true, Deleted: true}, nil
|
||||
}
|
||||
|
||||
func (d *deletableBotStore) ListBotsByOwner(ctx context.Context, ownerUserID int64) ([]domain.BotProfile, error) {
|
||||
profiles, err := d.BotStore.ListBotsByOwner(ctx, ownerUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := profiles[:0]
|
||||
for _, p := range profiles {
|
||||
if !d.deleted[p.BotUserID] {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestMyBotsEditBotShowsSummaryAndReturnsAfterEdit(t *testing.T) {
|
||||
svc, users, _, messages := newTestService(t)
|
||||
owner := newOwner(t, users, "+2010")
|
||||
if _, _, err := svc.CreateBot(context.Background(), owner.ID, "Enigma Network", "enigma_mb_bot"); err != nil {
|
||||
t.Fatalf("create bot: %v", err)
|
||||
}
|
||||
|
||||
sendToBotFather(t, svc, messages, owner, "/mybots")
|
||||
pressBotFather(t, svc, messages, owner.ID, "@enigma_mb_bot")
|
||||
_, edit := pressBotFather(t, svc, messages, owner.ID, "Edit Bot")
|
||||
for _, want := range []string{"Edit @enigma_mb_bot info.", "Name: Enigma Network", "About: 🚫", "Commands: no commands yet", "Botpic: 🚫"} {
|
||||
if !strings.Contains(edit.Body, want) {
|
||||
t.Fatalf("edit summary missing %q:\n%s", want, edit.Body)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"Edit Name", "Edit About", "Edit Botpic", "Back to bot", "Bots list"} {
|
||||
if !mybotsHasButton(edit, want) {
|
||||
t.Fatalf("edit menu missing button %q: %+v", want, edit.ReplyMarkup)
|
||||
}
|
||||
}
|
||||
// The @mention is a tappable entity, not plain text.
|
||||
if !hasMentionEntity(edit, "@enigma_mb_bot") {
|
||||
t.Fatalf("no mention entity for @enigma_mb_bot: %+v", edit.Entities)
|
||||
}
|
||||
|
||||
pressBotFather(t, svc, messages, owner.ID, "Edit About")
|
||||
sendToBotFather(t, svc, messages, owner, "we host things")
|
||||
after := botFatherUserReply(t, messages, owner.ID)
|
||||
if !strings.Contains(after.Body, "About section updated") || !strings.Contains(after.Body, "Edit @enigma_mb_bot info.") {
|
||||
t.Fatalf("post-edit reply is not the edit menu:\n%s", after.Body)
|
||||
}
|
||||
if !strings.Contains(after.Body, "About: we host things") {
|
||||
t.Fatalf("edit menu did not refresh the About value:\n%s", after.Body)
|
||||
}
|
||||
// The bug this guards: pressing another field on the refreshed menu must work,
|
||||
// not report the button as expired.
|
||||
answer, prompt := pressBotFather(t, svc, messages, owner.ID, "Edit Botpic")
|
||||
if answer.Alert {
|
||||
t.Fatalf("Edit Botpic on the refreshed menu alerted: %q", answer.Message)
|
||||
}
|
||||
if !strings.Contains(prompt.Body, "profile picture") {
|
||||
t.Fatalf("Edit Botpic prompt = %q", prompt.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMyBotsDeleteBot(t *testing.T) {
|
||||
users := memory.NewUserStore()
|
||||
store := &deletableBotStore{BotStore: memory.NewBotStore(users), deleted: map[int64]bool{}}
|
||||
messages := memory.NewMessageStore(memory.NewDialogStore())
|
||||
svc := NewService(users, store, messages)
|
||||
svc.SetRouterHooks(&captureRevoker{})
|
||||
owner := newOwner(t, users, "+2006")
|
||||
makeBots(t, svc, owner.ID, 2)
|
||||
|
||||
sendToBotFather(t, svc, messages, owner, "/mybots")
|
||||
pressBotFather(t, svc, messages, owner.ID, "@mb0_bot")
|
||||
pressBotFather(t, svc, messages, owner.ID, "Delete Bot")
|
||||
_, afterDelete := pressBotFather(t, svc, messages, owner.ID, "Yes, delete")
|
||||
if !strings.Contains(afterDelete.Body, "Deleted @mb0_bot") {
|
||||
t.Fatalf("after delete body = %q", afterDelete.Body)
|
||||
}
|
||||
if mybotsHasButton(afterDelete, "@mb0_bot") {
|
||||
t.Fatal("deleted bot still listed")
|
||||
}
|
||||
if !mybotsHasButton(afterDelete, "@mb1_bot") {
|
||||
t.Fatalf("surviving bot dropped: %+v", afterDelete.ReplyMarkup)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMyBotsForeignAndStaleTokensRefused(t *testing.T) {
|
||||
svc, users, _, messages := newTestService(t)
|
||||
alice := newOwner(t, users, "+2007")
|
||||
bob := newOwner(t, users, "+2008")
|
||||
makeBotsP(t, svc, alice.ID, "alice", 1)
|
||||
makeBotsP(t, svc, bob.ID, "bob", 1)
|
||||
|
||||
// Alice opens her menu; Bob replays one of her tokens against his own dialog.
|
||||
sendToBotFather(t, svc, messages, alice, "/mybots")
|
||||
aliceMenu := botFatherUserReply(t, messages, alice.ID)
|
||||
var aliceToken []byte
|
||||
for _, row := range aliceMenu.ReplyMarkup.Inline {
|
||||
for _, b := range row {
|
||||
if len(b.Data) > 0 {
|
||||
aliceToken = append([]byte(nil), b.Data...)
|
||||
}
|
||||
}
|
||||
}
|
||||
sendToBotFather(t, svc, messages, bob, "/mybots")
|
||||
answer, _ := pressBotFatherData(t, svc, messages, bob.ID, aliceToken)
|
||||
if !answer.Alert || answer.Message == "" {
|
||||
t.Fatalf("replayed foreign token answer = %+v, want expired-button alert", answer)
|
||||
}
|
||||
|
||||
// A stale generation of Alice's own buttons is refused too.
|
||||
sendToBotFather(t, svc, messages, alice, "/mybots") // gen 2
|
||||
sendToBotFather(t, svc, messages, alice, "/mybots") // gen 3
|
||||
sendToBotFather(t, svc, messages, alice, "/mybots") // gen 4 -> gen 1 pruned
|
||||
answer, _ = pressBotFatherData(t, svc, messages, alice.ID, aliceToken)
|
||||
if !answer.Alert {
|
||||
t.Fatalf("stale token answer = %+v, want expired-button alert", answer)
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ package bots
|
|||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
|
@ -76,6 +77,14 @@ type verificationApplications interface {
|
|||
Application(ctx context.Context, applicationID int64) (domain.VerificationApplication, error)
|
||||
}
|
||||
|
||||
// botAvatarStore renders an already-stored photo (one a user sent to @BotFather)
|
||||
// into a bot's profile photo. app/files.Service satisfies it. It is a narrow port
|
||||
// because a service bot must not reach into the file layer for anything else.
|
||||
type botAvatarStore interface {
|
||||
SetAvatarFromExistingPhoto(ctx context.Context, ownerType domain.PeerType, ownerID, sourcePhotoID int64, date int) (domain.Photo, error)
|
||||
PeerHasAvatar(ctx context.Context, ownerType domain.PeerType, ownerID int64) (bool, error)
|
||||
}
|
||||
|
||||
// The third-party verification ports live in verifierbot.go
|
||||
// (customVerifications, verifierBotTargets): they are the built-in @verifierbot's
|
||||
// only way to reach the feature, and are kept next to the dialog that uses them.
|
||||
|
|
@ -112,6 +121,7 @@ type Service struct {
|
|||
messages store.MessageStore
|
||||
blocker blockChecker
|
||||
channels publicChannelUsernameResolver
|
||||
reserved reservedUsernameChecker
|
||||
stickers stickerSetCreator
|
||||
installer userStickerSetInstaller
|
||||
aiChat aiChatGenerator
|
||||
|
|
@ -120,6 +130,7 @@ type Service struct {
|
|||
verifierTargets verifierBotTargets
|
||||
gifCatalog gifCatalogSource
|
||||
telegramLogin *telegramloginapp.Service
|
||||
botAvatar botAvatarStore
|
||||
hooks RouterHooks
|
||||
textDrafts TextDraftPusher
|
||||
userCache store.UserCache
|
||||
|
|
@ -176,8 +187,34 @@ func WithBlockChecker(c blockChecker) Option {
|
|||
}
|
||||
}
|
||||
|
||||
// WithBotAvatarStore injects the ability to set a bot's profile photo from a
|
||||
// photo a user sent to @BotFather ("Edit Botpic"). Absent it, that action reports
|
||||
// that it is unavailable on this server.
|
||||
func WithBotAvatarStore(a botAvatarStore) Option {
|
||||
return func(s *Service) {
|
||||
if a != nil {
|
||||
s.botAvatar = a
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithPublicChannelUsernameResolver 注入公开频道 username 查询能力,用于 bot
|
||||
// username 预检,避免 bot 与 public channel 产生同名可见入口。
|
||||
// reservedUsernameChecker reports whether a name is on the operator blocklist.
|
||||
type reservedUsernameChecker interface {
|
||||
IsReserved(ctx context.Context, usernameLower string) (bool, error)
|
||||
}
|
||||
|
||||
// WithReservedUsernames wires the operator username blocklist so CheckUsername
|
||||
// reports a reserved bot name as taken instead of available.
|
||||
func WithReservedUsernames(c reservedUsernameChecker) Option {
|
||||
return func(s *Service) {
|
||||
if c != nil {
|
||||
s.reserved = c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithPublicChannelUsernameResolver(c publicChannelUsernameResolver) Option {
|
||||
return func(s *Service) {
|
||||
if c != nil {
|
||||
|
|
@ -512,6 +549,13 @@ func (s *Service) CheckUsername(ctx context.Context, ownerUserID int64, username
|
|||
if !domain.ValidBotUsername(username) {
|
||||
return false, domain.ErrBotUsernameInvalid
|
||||
}
|
||||
if s.reserved != nil {
|
||||
if r, err := s.reserved.IsReserved(ctx, strings.ToLower(username)); err != nil {
|
||||
return false, err
|
||||
} else if r {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
if _, found, err := s.users.ByUsername(ctx, username); err != nil {
|
||||
return false, err
|
||||
} else if found {
|
||||
|
|
@ -902,6 +946,30 @@ func (s *Service) SetPrivacy(ctx context.Context, botUserID int64, enabled bool)
|
|||
return version, nil
|
||||
}
|
||||
|
||||
// ErrBotUserpicUnsupported reports that this server has no file layer wired for
|
||||
// setting a bot's profile photo (WithBotAvatarStore was not supplied).
|
||||
var ErrBotUserpicUnsupported = errors.New("bot profile photo is not supported by this server")
|
||||
|
||||
// SetBotUserpic makes an already-stored photo (one a user sent to @BotFather) the
|
||||
// bot's current profile photo. sourcePhotoID is a message photo id; the file
|
||||
// layer re-renders it into the avatar size set.
|
||||
func (s *Service) SetBotUserpic(ctx context.Context, botUserID, sourcePhotoID int64) error {
|
||||
if s == nil || botUserID == 0 {
|
||||
return domain.ErrBotNotFound
|
||||
}
|
||||
if s.botAvatar == nil {
|
||||
return ErrBotUserpicUnsupported
|
||||
}
|
||||
if sourcePhotoID <= 0 {
|
||||
return domain.ErrPhotoInvalid
|
||||
}
|
||||
if _, err := s.botAvatar.SetAvatarFromExistingPhoto(ctx, domain.PeerTypeUser, botUserID, sourcePhotoID, int(s.now().Unix())); err != nil {
|
||||
return err
|
||||
}
|
||||
s.invalidateBotReadCaches(ctx, botUserID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CanSendMessage reports whether botUserID has explicit permission to initiate
|
||||
// direct messages with userID.
|
||||
func (s *Service) CanSendMessage(ctx context.Context, userID, botUserID int64) (bool, error) {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,10 @@ func serviceBotReplyEntities(text string, explicit []domain.MessageEntity) []dom
|
|||
offset, length := utf16Range(text, span.start, span.end)
|
||||
appendEntity(domain.MessageEntity{Type: domain.MessageEntityBotCommand, Offset: offset, Length: length})
|
||||
}
|
||||
for _, span := range serviceBotMentionByteSpans(text) {
|
||||
offset, length := utf16Range(text, span.start, span.end)
|
||||
appendEntity(domain.MessageEntity{Type: domain.MessageEntityMention, Offset: offset, Length: length})
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Offset != out[j].Offset {
|
||||
return out[i].Offset < out[j].Offset
|
||||
|
|
@ -138,6 +142,42 @@ func serviceBotCommandByteSpans(text string) []serviceBotEntitySpan {
|
|||
return spans
|
||||
}
|
||||
|
||||
// serviceBotMentionByteSpans finds "@username" runs so a service bot's messages
|
||||
// render the mention as a tappable link. A username is 5-32 of [A-Za-z0-9_]; the
|
||||
// "@" must not sit right after a word character (so "you@example" is not a
|
||||
// mention) and the run must not be followed by one.
|
||||
func serviceBotMentionByteSpans(text string) []serviceBotEntitySpan {
|
||||
var spans []serviceBotEntitySpan
|
||||
for i := 0; i < len(text); {
|
||||
r, size := utf8.DecodeRuneInString(text[i:])
|
||||
if r != '@' || !serviceBotCommandStart(text, i) {
|
||||
i += size
|
||||
continue
|
||||
}
|
||||
start := i
|
||||
i += size
|
||||
nameStart := i
|
||||
for i < len(text) {
|
||||
c, csize := utf8.DecodeRuneInString(text[i:])
|
||||
if !serviceBotCommandChar(c) {
|
||||
break
|
||||
}
|
||||
i += csize
|
||||
}
|
||||
nameLen := i - nameStart
|
||||
if nameLen < 5 || nameLen > 32 {
|
||||
continue
|
||||
}
|
||||
if i < len(text) {
|
||||
if next, _ := utf8.DecodeRuneInString(text[i:]); serviceBotCommandChar(next) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
spans = append(spans, serviceBotEntitySpan{start: start, end: i})
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
func serviceBotCommandStart(text string, byteIndex int) bool {
|
||||
if byteIndex == 0 {
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -31,10 +31,18 @@ func newOwner(t *testing.T, users *memory.UserStore, phone string) domain.User {
|
|||
|
||||
// sendToBotFather 同步驱动 responder(绕过 OnPrivateMessage 的 goroutine 派发以
|
||||
// 保证单测确定性;异步派发由 mtprotoedge bot e2e 覆盖),返回 BotFather 最新回复文本。
|
||||
func botFatherMsg(userID int64, text string) domain.Message {
|
||||
return domain.Message{
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.BotFatherUserID},
|
||||
Body: text,
|
||||
}
|
||||
}
|
||||
|
||||
func sendToBotFather(t *testing.T, svc *Service, messages *memory.MessageStore, owner domain.User, text string) string {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
svc.respondAsBotFather(owner.ID, text)
|
||||
svc.respondAsBotFather(owner.ID, botFatherMsg(owner.ID, text))
|
||||
list, err := messages.ListByUser(ctx, owner.ID, domain.MessageFilter{
|
||||
HasPeer: true,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.BotFatherUserID},
|
||||
|
|
@ -332,8 +340,8 @@ func TestBotFatherMyBotsAndLimit(t *testing.T) {
|
|||
t.Fatalf("create bot %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "/mybots"); !strings.Contains(reply, "@limit0_bot") {
|
||||
t.Fatalf("/mybots reply = %q, want bot list", reply)
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "/mybots"); !strings.Contains(reply, "Choose a bot") {
|
||||
t.Fatalf("/mybots reply = %q, want bot picker", reply)
|
||||
}
|
||||
if _, _, err := svc.CreateBot(ctx, owner.ID, "One Too Many", "toomany_bot"); err != domain.ErrBotsTooMany {
|
||||
t.Fatalf("create over limit err = %v, want ErrBotsTooMany", err)
|
||||
|
|
@ -384,7 +392,7 @@ func TestBotFatherReplyRespectsBlock(t *testing.T) {
|
|||
owner := newOwner(t, users, "+1099")
|
||||
ctx := context.Background()
|
||||
|
||||
svc.respondAsBotFather(owner.ID, "/help")
|
||||
svc.respondAsBotFather(owner.ID, botFatherMsg(owner.ID, "/help"))
|
||||
|
||||
// IsBlocked 参数语义:owner(userID) 是否 block 了 BotFather(blockedUserID)。
|
||||
if blocker.gotUser != owner.ID || blocker.gotPeer != domain.BotFatherUserID {
|
||||
|
|
@ -408,7 +416,7 @@ func TestBotFatherReplyRespectsBlock(t *testing.T) {
|
|||
// 未 block:回复正常投递。
|
||||
blocker.blocked = false
|
||||
other := newOwner(t, users, "+1098")
|
||||
svc.respondAsBotFather(other.ID, "/help")
|
||||
svc.respondAsBotFather(other.ID, botFatherMsg(other.ID, "/help"))
|
||||
otherList, err := messages.ListByUser(ctx, other.ID, domain.MessageFilter{
|
||||
HasPeer: true,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.BotFatherUserID},
|
||||
|
|
|
|||
|
|
@ -194,13 +194,13 @@ const (
|
|||
|
||||
// verifierBotWhatText is the part of /start that is true whether or not an
|
||||
// operator has activated this bot, so it is said first and unconditionally.
|
||||
const verifierBotWhatText = `I hand out THIRD-PARTY verification.
|
||||
var verifierBotWhatText = `I hand out THIRD-PARTY verification.
|
||||
|
||||
A third-party mark is a verifier's own icon, shown right before the name of a bot, a channel or an account, plus one line of description in its profile. It means "this verifier vouches for this peer" -- nothing more.
|
||||
|
||||
It is NOT the official ` + branding.ProductName + ` checkmark. The platform badge is granted by the platform itself (@verifybot collects those applications); a third-party mark is granted by the company running a verifier bot. The two are stored, shown and taken away separately, and neither one implies the other.`
|
||||
It is NOT the official ` + branding.ProductName() + ` checkmark. The platform badge is granted by the platform itself (@verifybot collects those applications); a third-party mark is granted by the company running a verifier bot. The two are stored, shown and taken away separately, and neither one implies the other.`
|
||||
|
||||
const verifierBotHelpText = `I am a verifier bot. I grant third-party marks: my icon before the name of your bot, channel or account, plus a description in its profile. This is not the official ` + branding.ProductName + ` checkmark.
|
||||
var verifierBotHelpText = `I am a verifier bot. I grant third-party marks: my icon before the name of your bot, channel or account, plus a description in its profile. This is not the official ` + branding.ProductName() + ` checkmark.
|
||||
|
||||
/start - what a third-party mark is and who grants it
|
||||
/verify - apply for the mark
|
||||
|
|
@ -1121,7 +1121,7 @@ func verifierSummaryText(state domain.BotChatState, settings domain.BotVerifierS
|
|||
b.WriteString("\n\nWhy:\n")
|
||||
b.WriteString(state.Draft[verifierDraftReason])
|
||||
b.WriteString("\n\nThis is a third-party mark, not the official ")
|
||||
b.WriteString(branding.ProductName)
|
||||
b.WriteString(branding.ProductName())
|
||||
b.WriteString(" checkmark, and I do not decide: an operator reads the application and either grants the mark or refuses it. I will message you here either way.")
|
||||
return b.String()
|
||||
}
|
||||
|
|
@ -1170,7 +1170,7 @@ func verifierDecisionText(req domain.CustomVerificationRequest) (string, bool) {
|
|||
switch req.Status {
|
||||
case domain.CustomVerificationApproved:
|
||||
return fmt.Sprintf("Application #%d is approved: %s now carries my mark -- my icon before the name and my description in the profile.\n\nThis is a third-party mark, not the official %s checkmark. Send /revoke if you ever want it removed.",
|
||||
req.ID, label, branding.ProductName), true
|
||||
req.ID, label, branding.ProductName()), true
|
||||
case domain.CustomVerificationRejected:
|
||||
text := fmt.Sprintf("Application #%d for %s was not approved, so no mark was granted.", req.ID, label)
|
||||
if reason := strings.TrimSpace(req.DecisionReason); reason != "" {
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ const (
|
|||
verifyChoiceBlockedPrefix = "no:"
|
||||
)
|
||||
|
||||
const verifyBotStartText = `I collect applications for official ` + branding.ProductName + ` verification: the badge shown next to the name of a channel, supergroup or bot whose identity has been confirmed.
|
||||
var verifyBotStartText = `I collect applications for official ` + branding.ProductName() + ` verification: the badge shown next to the name of a channel, supergroup or bot whose identity has been confirmed.
|
||||
|
||||
Before you apply, check that the subject of the application:
|
||||
- is a channel, supergroup or bot with a public @username;
|
||||
|
|
@ -126,7 +126,7 @@ This badge is never sold and never granted automatically. A person reads every a
|
|||
|
||||
Tap the button below, or send /new, to start. Send /help for the full list of commands.`
|
||||
|
||||
const verifyBotHelpText = `I collect official ` + branding.ProductName + ` verification applications.
|
||||
var verifyBotHelpText = `I collect official ` + branding.ProductName() + ` verification applications.
|
||||
|
||||
/new - file a verification application
|
||||
/status - list your applications and their status
|
||||
|
|
@ -355,6 +355,10 @@ func (s *Service) OnCallbackQuery(ctx context.Context, query domain.BotCallbackQ
|
|||
// (verifierbot.go).
|
||||
return s.onVerifierCallback(ctx, query)
|
||||
}
|
||||
if query.BotUserID == domain.BotFatherUserID {
|
||||
// @BotFather's button-driven /mybots menu (mybots.go).
|
||||
return s.onBotFatherCallback(ctx, query)
|
||||
}
|
||||
if query.BotUserID != domain.VerifyBotUserID {
|
||||
// The other built-in bots never attach an inline keyboard, so there is
|
||||
// nothing to route. An empty answer still beats hanging the click for the
|
||||
|
|
|
|||
|
|
@ -869,12 +869,13 @@ func TestVerifyBotCallbackForForeignBotIsNotClaimed(t *testing.T) {
|
|||
}); handled || err != nil {
|
||||
t.Fatalf("foreign bot callback handled=%v err=%v, want (false, nil)", handled, err)
|
||||
}
|
||||
// A built-in bot with no keyboards is claimed but answered empty, so the click
|
||||
// cannot hang for the whole callback timeout.
|
||||
// @BotFather owns the /mybots menu now: a click with no active dialog is
|
||||
// claimed and answered with the expired-button alert, so it cannot hang for
|
||||
// the whole callback timeout.
|
||||
answer, handled, err := svc.OnCallbackQuery(context.Background(), domain.BotCallbackQuery{
|
||||
BotUserID: domain.BotFatherUserID, UserID: 900, Data: []byte("x"),
|
||||
BotUserID: domain.BotFatherUserID, UserID: 900, Data: []byte("bf:x"),
|
||||
})
|
||||
if !handled || err != nil || answer.Message != "" {
|
||||
if !handled || err != nil || !answer.Alert || answer.Message == "" {
|
||||
t.Fatalf("BotFather callback = (%+v, %v, %v)", answer, handled, err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ func (s *Service) SeedDefaultVerifier(ctx context.Context) (bool, error) {
|
|||
if _, err := s.GrantVerifier(ctx, domain.BotVerifierSettings{
|
||||
BotID: domain.VerifierBotUserID,
|
||||
IconDocumentID: icon.DocumentID,
|
||||
CompanyName: branding.ProductName,
|
||||
CompanyName: branding.ProductName(),
|
||||
DefaultDescription: "Bundled reference verifier -- auto-granted on first boot.",
|
||||
CanModifyCustomDescription: false,
|
||||
Enabled: true,
|
||||
|
|
|
|||
|
|
@ -64,9 +64,6 @@ func (c *participantsReadModelCache) invalidateChannel(channelID int64) {
|
|||
|
||||
func (s *Service) cachedParticipants(ctx context.Context, userID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) {
|
||||
filter, offset, limit = normalizeParticipantsRequest(filter, offset, limit)
|
||||
if s.participantCache == nil || s.versions == nil {
|
||||
return s.loadParticipants(ctx, userID, channelID, filter, offset, limit)
|
||||
}
|
||||
key := participantsCacheKey{
|
||||
userID: userID,
|
||||
channelID: channelID,
|
||||
|
|
@ -75,15 +72,23 @@ func (s *Service) cachedParticipants(ctx context.Context, userID, channelID int6
|
|||
offset: offset,
|
||||
limit: limit,
|
||||
}
|
||||
if s.participantCache == nil || s.versions == nil {
|
||||
return s.loadParticipantsWithContentHash(ctx, userID, channelID, filter, key)
|
||||
}
|
||||
hash, err := s.channelParticipantsHash(ctx, userID, channelID, key)
|
||||
if err != nil {
|
||||
return domain.ChannelParticipantList{}, err
|
||||
}
|
||||
if hash == 0 {
|
||||
return s.loadParticipants(ctx, userID, channelID, filter, offset, limit)
|
||||
// The read-model version hash is unavailable (e.g. a channel whose
|
||||
// read_model_versions rows were never seeded). Fall back to a stable
|
||||
// content hash so the RPC layer can still answer
|
||||
// channels.channelParticipantsNotModified. Without a non-zero, stable
|
||||
// Hash a client that polls the member list re-fetches it forever.
|
||||
return s.loadParticipantsWithContentHash(ctx, userID, channelID, filter, key)
|
||||
}
|
||||
return s.participantCache.getOrLoad(ctx, key, hash, func() (domain.ChannelParticipantList, error) {
|
||||
list, err := s.loadParticipants(ctx, userID, channelID, filter, offset, limit)
|
||||
list, err := s.loadParticipants(ctx, userID, channelID, filter, key.offset, key.limit)
|
||||
if err != nil {
|
||||
return domain.ChannelParticipantList{}, err
|
||||
}
|
||||
|
|
@ -92,6 +97,54 @@ func (s *Service) cachedParticipants(ctx context.Context, userID, channelID int6
|
|||
})
|
||||
}
|
||||
|
||||
// loadParticipantsWithContentHash loads a participants page and, when nothing has
|
||||
// assigned an opaque version hash, derives a deterministic one from the page's
|
||||
// own contents so identical results keep producing an identical Hash.
|
||||
func (s *Service) loadParticipantsWithContentHash(ctx context.Context, userID, channelID int64, filter domain.ChannelParticipantsFilter, key participantsCacheKey) (domain.ChannelParticipantList, error) {
|
||||
list, err := s.loadParticipants(ctx, userID, channelID, filter, key.offset, key.limit)
|
||||
if err != nil {
|
||||
return domain.ChannelParticipantList{}, err
|
||||
}
|
||||
if list.Hash == 0 {
|
||||
list.Hash = participantsContentHash(channelID, key, list)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// participantsContentHash is a stable fingerprint of a participants page: the
|
||||
// channel, the page key and every returned member's client-visible identity
|
||||
// (id, role, rank, status). Any change a client would render (a new member, a
|
||||
// promotion, a rank edit, a kick) changes the hash; an unchanged page does not.
|
||||
func participantsContentHash(channelID int64, key participantsCacheKey, list domain.ChannelParticipantList) int64 {
|
||||
h := fnv.New64a()
|
||||
var buf [8]byte
|
||||
writeUint := func(v uint64) {
|
||||
binary.LittleEndian.PutUint64(buf[:], v)
|
||||
_, _ = h.Write(buf[:])
|
||||
}
|
||||
writeStr := func(s string) {
|
||||
_, _ = h.Write([]byte(s))
|
||||
_, _ = h.Write([]byte{0})
|
||||
}
|
||||
writeUint(uint64(channelID))
|
||||
writeStr(string(key.kind))
|
||||
writeStr(key.query)
|
||||
writeUint(uint64(key.offset))
|
||||
writeUint(uint64(key.limit))
|
||||
writeUint(uint64(int64(list.Count)))
|
||||
for _, p := range list.Participants {
|
||||
writeUint(uint64(p.UserID))
|
||||
writeStr(string(p.Role))
|
||||
writeStr(string(p.Status))
|
||||
writeStr(p.Rank)
|
||||
}
|
||||
sum := int64(h.Sum64() & 0x7fffffffffffffff)
|
||||
if sum == 0 {
|
||||
return 1
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
func (s *Service) loadParticipants(ctx context.Context, userID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) {
|
||||
if filter.Kind == domain.ChannelParticipantsBots && s.bots != nil {
|
||||
return s.getBotParticipants(ctx, userID, channelID, offset, limit)
|
||||
|
|
|
|||
|
|
@ -814,6 +814,51 @@ func TestGetParticipantsCacheInvalidatesAfterAdminMutation(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetParticipantsFallsBackToContentHashWithoutReadModelVersions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
base := &countingChannelStore{ChannelStore: memory.NewChannelStore()}
|
||||
// No WithReadModelVersions: channelParticipantsHash can never build an opaque
|
||||
// version hash, so the service must derive a stable one from the page itself.
|
||||
service := NewService(base)
|
||||
created, err := service.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{
|
||||
Title: "Fallback Hash",
|
||||
Megagroup: true,
|
||||
MemberUserIDs: []int64{1002},
|
||||
Date: 1700004105,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel: %v", err)
|
||||
}
|
||||
filter := domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsRecent}
|
||||
|
||||
first, err := service.GetParticipants(ctx, ownerID, created.Channel.ID, filter, 0, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("first participants: %v", err)
|
||||
}
|
||||
if first.Hash == 0 {
|
||||
t.Fatalf("first participants hash = 0, want stable non-zero fallback")
|
||||
}
|
||||
second, err := service.GetParticipants(ctx, ownerID, created.Channel.ID, filter, 0, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("second participants: %v", err)
|
||||
}
|
||||
if second.Hash != first.Hash {
|
||||
t.Fatalf("second hash = %d, want stable %d", second.Hash, first.Hash)
|
||||
}
|
||||
|
||||
if _, err := service.InviteToChannel(ctx, ownerID, created.Channel.ID, []int64{1003}, 1700004106); err != nil {
|
||||
t.Fatalf("InviteToChannel: %v", err)
|
||||
}
|
||||
third, err := service.GetParticipants(ctx, ownerID, created.Channel.ID, filter, 0, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("third participants: %v", err)
|
||||
}
|
||||
if third.Hash == first.Hash {
|
||||
t.Fatalf("third hash = %d, want changed after a new member joined", third.Hash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullMegagroupAdminGrantFillsManageRanks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := NewService(memory.NewChannelStore())
|
||||
|
|
@ -2716,6 +2761,61 @@ func TestChannelUsernameAndSignatures(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestUpdateUsernameForcesPreHistoryVisible(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
service := NewService(memory.NewChannelStore())
|
||||
created, err := service.CreateMegagroupFromCreateChat(ctx, ownerID, domain.CreateChannelRequest{
|
||||
Title: "Private First",
|
||||
MemberUserIDs: []int64{1002},
|
||||
Date: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMegagroupFromCreateChat: %v", err)
|
||||
}
|
||||
|
||||
hidden, err := service.SetPreHistoryHidden(ctx, ownerID, created.Channel.ID, true)
|
||||
if err != nil {
|
||||
t.Fatalf("SetPreHistoryHidden: %v", err)
|
||||
}
|
||||
if !hidden.PreHistoryHidden {
|
||||
t.Fatalf("hidden channel = %+v, want pre-history hidden", hidden)
|
||||
}
|
||||
|
||||
// Assigning a public username must force pre-history back to visible.
|
||||
public, err := service.UpdateUsername(ctx, ownerID, domain.UpdateChannelUsernameRequest{
|
||||
ChannelID: created.Channel.ID,
|
||||
Username: "private_first_pub",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateUsername: %v", err)
|
||||
}
|
||||
if public.PreHistoryHidden {
|
||||
t.Fatalf("public channel = %+v, want pre-history visible after publish", public)
|
||||
}
|
||||
|
||||
// Removing the username leaves the flag alone (still visible).
|
||||
private, err := service.UpdateUsername(ctx, ownerID, domain.UpdateChannelUsernameRequest{
|
||||
ChannelID: created.Channel.ID,
|
||||
Username: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateUsername clear: %v", err)
|
||||
}
|
||||
if private.PreHistoryHidden {
|
||||
t.Fatalf("re-privated channel = %+v, want pre-history still visible", private)
|
||||
}
|
||||
|
||||
// ...and the creator can hide it again once private.
|
||||
rehidden, err := service.SetPreHistoryHidden(ctx, ownerID, created.Channel.ID, true)
|
||||
if err != nil {
|
||||
t.Fatalf("SetPreHistoryHidden after re-privating: %v", err)
|
||||
}
|
||||
if !rehidden.PreHistoryHidden {
|
||||
t.Fatalf("re-hidden channel = %+v, want pre-history hidden again", rehidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListStoryPostableChannelsFiltersPostStoryRights(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := NewService(memory.NewChannelStore())
|
||||
|
|
|
|||
84
internal/app/files/bot_avatar.go
Normal file
84
internal/app/files/bot_avatar.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// maxBotAvatarSourceBytes bounds the source image @BotFather turns into a bot's
|
||||
// profile photo. A profile photo a user sends in a chat is a server-rendered
|
||||
// message-size JPEG, comfortably under this.
|
||||
const maxBotAvatarSourceBytes = 12 << 20
|
||||
|
||||
// SetAvatarFromExistingPhoto renders an already-stored photo (typically one a
|
||||
// user just sent to a service bot) into the s/a/c avatar size set and makes it
|
||||
// the current profile photo of ownerType/ownerID.
|
||||
//
|
||||
// It is the path @BotFather's "Edit Botpic" uses: photos.uploadProfilePhoto#bot
|
||||
// is not accepted, and the source is a message photo rather than a fresh upload,
|
||||
// so the ordinary UploadProfilePhoto path does not apply.
|
||||
func (s *Service) SetAvatarFromExistingPhoto(ctx context.Context, ownerType domain.PeerType, ownerID, sourcePhotoID int64, date int) (domain.Photo, error) {
|
||||
if ownerID <= 0 || sourcePhotoID <= 0 {
|
||||
return domain.Photo{}, domain.ErrPhotoInvalid
|
||||
}
|
||||
source, found, err := s.media.GetPhoto(ctx, sourcePhotoID)
|
||||
if err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.Photo{}, domain.ErrPhotoInvalid
|
||||
}
|
||||
data, ok := s.photoSourceBytes(ctx, source)
|
||||
if !ok || !s.ValidateAvatarUpload(data) {
|
||||
return domain.Photo{}, domain.ErrPhotoInvalid
|
||||
}
|
||||
if date == 0 {
|
||||
date = int(time.Now().Unix())
|
||||
}
|
||||
photo, err := s.createAvatarPhoto(ctx, data, ownerID)
|
||||
if err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
if err := s.media.AddProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, photo.ID, date); err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
return photo, nil
|
||||
}
|
||||
|
||||
// PeerHasAvatar reports whether ownerType/ownerID has a current profile photo.
|
||||
func (s *Service) PeerHasAvatar(ctx context.Context, ownerType domain.PeerType, ownerID int64) (bool, error) {
|
||||
_, ok, err := s.CurrentProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile)
|
||||
return ok, err
|
||||
}
|
||||
|
||||
// photoSourceBytes reads the original image bytes behind a stored photo. Every
|
||||
// static size of a photo written by putPhotoStaticSizes points at the same
|
||||
// stored object, so any one size yields the full image.
|
||||
func (s *Service) photoSourceBytes(ctx context.Context, photo domain.Photo) ([]byte, bool) {
|
||||
for _, size := range photo.Sizes {
|
||||
if size.Type == "" {
|
||||
continue
|
||||
}
|
||||
key := fmt.Sprintf("photo:%d:%s", photo.ID, size.Type)
|
||||
blob, found, err := s.media.GetFileBlob(ctx, key)
|
||||
if err != nil || !found || blob.Size <= 0 || blob.Size > maxBotAvatarSourceBytes {
|
||||
continue
|
||||
}
|
||||
backend, err := s.backendFor(blob.Backend)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
body, total, err := backend.GetRange(ctx, blob.ObjectKey, 0, blob.Size)
|
||||
if err != nil || total != blob.Size || int64(len(body)) != blob.Size {
|
||||
s.log.Warn("read bot avatar source blob failed", zap.String("location_key", key), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
return body, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
|
@ -304,6 +304,10 @@ func (s *Service) prepareStickerSetDocument(ctx context.Context, doc domain.Docu
|
|||
|
||||
func (s *Service) ensureStickerMaterialShape(ctx context.Context, doc domain.Document) (domain.Document, error) {
|
||||
mimeType := canonicalStickerMaterialMime(doc.StickerSetMaterialMime())
|
||||
// A stored documentAttributeImageSize with a zero dimension is worse than a
|
||||
// missing one: clients divide by it and crash. Strip any such attribute here
|
||||
// so the branches below re-derive a real 512x512 (or decoded) size.
|
||||
doc.Attributes = dropZeroImageSizeAttributes(doc.Attributes)
|
||||
hasImageSize := false
|
||||
hasVideo := false
|
||||
for _, attr := range doc.Attributes {
|
||||
|
|
@ -311,9 +315,11 @@ func (s *Service) ensureStickerMaterialShape(ctx context.Context, doc domain.Doc
|
|||
case domain.DocAttrImageSize:
|
||||
hasImageSize = true
|
||||
case domain.DocAttrVideo:
|
||||
if attr.W > 0 && attr.H > 0 {
|
||||
hasVideo = true
|
||||
}
|
||||
}
|
||||
}
|
||||
switch mimeType {
|
||||
case stickerMaterialMimeJSON:
|
||||
data, ok := s.readStickerMaterialBlob(ctx, doc)
|
||||
|
|
@ -444,6 +450,20 @@ func (s *Service) rewriteStickerMaterialBlob(ctx context.Context, docID int64, d
|
|||
return nil
|
||||
}
|
||||
|
||||
// dropZeroImageSizeAttributes removes documentAttributeImageSize entries whose
|
||||
// width or height is not positive. Such an attribute reaches clients as
|
||||
// documentAttributeImageSize#0 and is divided by while sizing the render.
|
||||
func dropZeroImageSizeAttributes(attrs []domain.DocumentAttribute) []domain.DocumentAttribute {
|
||||
out := attrs[:0:0]
|
||||
for _, a := range attrs {
|
||||
if a.Kind == domain.DocAttrImageSize && (a.W <= 0 || a.H <= 0) {
|
||||
continue
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func replaceStickerMaterialFilename(attrs []domain.DocumentAttribute, fallback string) []domain.DocumentAttribute {
|
||||
out := append([]domain.DocumentAttribute(nil), attrs...)
|
||||
for i := range out {
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ func newServiceWithCacheLimits(packs store.LangPackStore, maxBytes int64, maxEnt
|
|||
packs: packs,
|
||||
packCache: newLangPackCache(maxBytes, maxEntries),
|
||||
languageCache: newLanguageListCache(languageEntries),
|
||||
publicBaseURL: branding.DefaultPublicURL,
|
||||
publicBaseURL: branding.PublicBaseURL(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ func NewService(creds store.PasskeyStore, challenges store.PasskeyChallengeStore
|
|||
creds: creds,
|
||||
challenges: challenges,
|
||||
rpID: rpID,
|
||||
rpName: branding.ProductName,
|
||||
rpName: branding.ProductName(),
|
||||
dcID: dcID,
|
||||
challengeTTL: defaultChallengeTTL,
|
||||
now: time.Now,
|
||||
|
|
|
|||
|
|
@ -769,7 +769,16 @@ func (s *Service) putCachedUsers(ctx context.Context, users ...domain.User) {
|
|||
// Collectible ownership may change inside the star-gift aggregate. Keep
|
||||
// these uncommon users on the authoritative store path so the database
|
||||
// lifecycle trigger can never be masked by a stale base-user cache entry.
|
||||
if user.ID != 0 && user.EmojiStatusCollectible.Empty() {
|
||||
//
|
||||
// redisstore.userBaseValue has no field for Deleted/DeletedAt/Status: caching
|
||||
// a deleted user silently resets Deleted to false (and Status to the zero
|
||||
// UserStatusUnknown) on every round trip, which never self-heals -- each
|
||||
// subsequent miss reloads the correctly tombstoned DB row and immediately
|
||||
// re-corrupts it on write. That regressed a deleted account back to looking
|
||||
// live (blank name, but "last seen recently" instead of "Deleted Account").
|
||||
// Keep deleted users off the cache so lookups always hit the authoritative
|
||||
// store, which encodes the tombstone correctly.
|
||||
if user.ID != 0 && user.EmojiStatusCollectible.Empty() && !user.Deleted {
|
||||
cacheable = append(cacheable, user)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -397,6 +397,69 @@ func TestServiceUsesBaseCacheWithoutCachingViewerOverlay(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// deletedOverrideUserStore reports one user id as an already-tombstoned
|
||||
// domain.User regardless of what the underlying memory store holds, so the
|
||||
// test doesn't need a memory.UserStore deletion helper to exercise the
|
||||
// base-user cache's handling of deleted users.
|
||||
type deletedOverrideUserStore struct {
|
||||
*memory.UserStore
|
||||
deletedID int64
|
||||
byIDsCalls int
|
||||
}
|
||||
|
||||
func (s *deletedOverrideUserStore) ByIDs(ctx context.Context, ids []int64) ([]domain.User, error) {
|
||||
s.byIDsCalls++
|
||||
users, err := s.UserStore.ByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i, u := range users {
|
||||
if u.ID == s.deletedID {
|
||||
users[i] = domain.User{ID: u.ID, Deleted: true, Status: domain.UserStatus{Kind: domain.UserStatusEmpty}}
|
||||
}
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// TestServiceNeverCachesDeletedUser guards the redisstore.UserCache cache
|
||||
// schema gap: userBaseValue carries no Deleted/DeletedAt/Status field, so
|
||||
// caching a deleted user silently resets Deleted to false (and Status to the
|
||||
// zero UserStatusUnknown) on every round trip -- a bug that never self-heals,
|
||||
// since each subsequent cache miss reloads the correctly tombstoned row and
|
||||
// immediately re-corrupts it on write. It regressed a deleted account back to
|
||||
// looking live: blank name (still blank, that part survives), but "last seen
|
||||
// recently" instead of "Deleted Account". The service must keep deleted users
|
||||
// off the base cache entirely so every lookup hits the authoritative store.
|
||||
func TestServiceNeverCachesDeletedUser(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewUserStore()
|
||||
owner, err := base.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000031", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
target, err := base.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000032", FirstName: "Target"})
|
||||
if err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
store := &deletedOverrideUserStore{UserStore: base, deletedID: target.ID}
|
||||
cache := newMemoryBaseUserCache()
|
||||
svc := NewService(store, WithBaseUserCache(cache))
|
||||
|
||||
got, found, err := svc.ByID(ctx, owner.ID, target.ID)
|
||||
if err != nil || !found || !got.Deleted {
|
||||
t.Fatalf("ByID = %+v found=%v err=%v, want a deleted user", got, found, err)
|
||||
}
|
||||
if _, cached := cache.users[target.ID]; cached {
|
||||
t.Fatalf("cache holds deleted user %d, want it kept off the cache entirely", target.ID)
|
||||
}
|
||||
if _, found, err := svc.ByID(ctx, owner.ID, target.ID); err != nil || !found {
|
||||
t.Fatalf("second ByID found=%v err=%v", found, err)
|
||||
}
|
||||
if store.byIDsCalls != 2 {
|
||||
t.Fatalf("store ByIDs calls = %d, want 2 (deleted user must never be served from cache)", store.byIDsCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRefreshesBaseCacheAfterProfileUpdate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewUserStore()
|
||||
|
|
|
|||
|
|
@ -356,6 +356,163 @@ func apiMediaUsesCaption(media map[string]any) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// apiChatFull projects a getChat result. The bot-api chat-id encoding is applied
|
||||
// here: users keep their positive id, channels/supergroups become
|
||||
// -1000000000000 - channelID.
|
||||
func apiChatFull(chat domain.BotAPIChat) map[string]any {
|
||||
id := chat.Peer.ID
|
||||
if chat.Peer.Type == domain.PeerTypeChannel {
|
||||
id = -1000000000000 - chat.Peer.ID
|
||||
}
|
||||
out := map[string]any{"id": id, "type": chat.Type}
|
||||
if chat.Title != "" {
|
||||
out["title"] = chat.Title
|
||||
}
|
||||
if chat.Username != "" {
|
||||
out["username"] = chat.Username
|
||||
}
|
||||
if chat.FirstName != "" {
|
||||
out["first_name"] = chat.FirstName
|
||||
}
|
||||
if chat.LastName != "" {
|
||||
out["last_name"] = chat.LastName
|
||||
}
|
||||
if chat.Description != "" {
|
||||
out["description"] = chat.Description
|
||||
}
|
||||
if chat.IsForum {
|
||||
out["is_forum"] = true
|
||||
}
|
||||
if chat.Verified {
|
||||
out["is_verified"] = true
|
||||
}
|
||||
if chat.Scam {
|
||||
out["is_scam"] = true
|
||||
}
|
||||
if chat.Fake {
|
||||
out["is_fake"] = true
|
||||
}
|
||||
if chat.SlowModeDelay > 0 {
|
||||
out["slow_mode_delay"] = chat.SlowModeDelay
|
||||
}
|
||||
if chat.LinkedChatID != 0 {
|
||||
out["linked_chat_id"] = -1000000000000 - chat.LinkedChatID
|
||||
}
|
||||
if chat.Permissions != nil {
|
||||
out["permissions"] = apiChatPermissions(*chat.Permissions)
|
||||
}
|
||||
if chat.PinnedMessage != nil {
|
||||
out["pinned_message"] = apiMessage(*chat.PinnedMessage, chat.PinnedMessageUsers)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// apiChatPermissions projects a channel's default restrictions as a Bot API
|
||||
// ChatPermissions object (a right is granted when the matching restriction is
|
||||
// off).
|
||||
func apiChatPermissions(b domain.ChannelBannedRights) map[string]any {
|
||||
text := !b.SendMessages && !b.SendPlain
|
||||
return map[string]any{
|
||||
"can_send_messages": text,
|
||||
"can_send_audios": !b.SendMedia && !b.SendAudios,
|
||||
"can_send_documents": !b.SendMedia && !b.SendDocs,
|
||||
"can_send_photos": !b.SendMedia && !b.SendPhotos,
|
||||
"can_send_videos": !b.SendMedia && !b.SendVideos,
|
||||
"can_send_video_notes": !b.SendMedia && !b.SendRoundvideos,
|
||||
"can_send_voice_notes": !b.SendMedia && !b.SendVoices,
|
||||
"can_send_polls": !b.SendPolls,
|
||||
"can_send_other_messages": !b.SendStickers && !b.SendGifs && !b.SendGames && !b.SendInline,
|
||||
"can_add_web_page_previews": !b.EmbedLinks,
|
||||
"can_change_info": !b.ChangeInfo,
|
||||
"can_invite_users": !b.InviteUsers,
|
||||
"can_pin_messages": !b.PinMessages,
|
||||
"can_manage_topics": !b.ManageTopics,
|
||||
}
|
||||
}
|
||||
|
||||
// apiChatMember projects a resolved member as a Bot API ChatMember object.
|
||||
func apiChatMember(m domain.BotAPIChatMember) map[string]any {
|
||||
out := map[string]any{
|
||||
"status": botAPIMemberStatus(m.Member),
|
||||
"user": apiUser(userOrPlaceholder(m.User, m.Member.UserID)),
|
||||
}
|
||||
switch out["status"] {
|
||||
case "creator":
|
||||
if m.Member.AdminRights.Anonymous {
|
||||
out["is_anonymous"] = true
|
||||
}
|
||||
if m.Member.Rank != "" {
|
||||
out["custom_title"] = m.Member.Rank
|
||||
}
|
||||
case "administrator":
|
||||
a := m.Member.AdminRights
|
||||
out["can_be_edited"] = false
|
||||
out["is_anonymous"] = a.Anonymous
|
||||
out["can_manage_chat"] = a.ManageChat
|
||||
out["can_delete_messages"] = a.DeleteMessages
|
||||
out["can_manage_video_chats"] = a.ManageCall
|
||||
out["can_restrict_members"] = a.BanUsers
|
||||
out["can_promote_members"] = a.AddAdmins
|
||||
out["can_change_info"] = a.ChangeInfo
|
||||
out["can_invite_users"] = a.InviteUsers
|
||||
out["can_post_messages"] = a.PostMessages
|
||||
out["can_edit_messages"] = a.EditMessages
|
||||
out["can_pin_messages"] = a.PinMessages
|
||||
out["can_manage_topics"] = a.ManageTopics
|
||||
out["can_post_stories"] = a.PostStories
|
||||
out["can_edit_stories"] = a.EditStories
|
||||
out["can_delete_stories"] = a.DeleteStories
|
||||
if m.Member.Rank != "" {
|
||||
out["custom_title"] = m.Member.Rank
|
||||
}
|
||||
case "restricted":
|
||||
b := m.Member.BannedRights
|
||||
out["is_member"] = m.Member.Status == domain.ChannelMemberActive
|
||||
for k, v := range apiChatPermissions(b) {
|
||||
out[k] = v
|
||||
}
|
||||
if b.UntilDate > 0 {
|
||||
out["until_date"] = b.UntilDate
|
||||
}
|
||||
case "kicked":
|
||||
if m.Member.BannedRights.UntilDate > 0 {
|
||||
out["until_date"] = m.Member.BannedRights.UntilDate
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func userOrPlaceholder(u domain.User, id int64) domain.User {
|
||||
if u.ID != 0 {
|
||||
return u
|
||||
}
|
||||
return domain.User{ID: id}
|
||||
}
|
||||
|
||||
func botAPIMemberStatus(m domain.ChannelMember) string {
|
||||
switch {
|
||||
case m.Role == domain.ChannelRoleCreator:
|
||||
return "creator"
|
||||
case m.Status == domain.ChannelMemberKicked, m.Status == domain.ChannelMemberBanned, m.BannedRights.ViewMessages:
|
||||
return "kicked"
|
||||
case m.Role == domain.ChannelRoleAdmin:
|
||||
return "administrator"
|
||||
case m.Status == domain.ChannelMemberLeft:
|
||||
return "left"
|
||||
case botAPIMemberRestricted(m.BannedRights):
|
||||
return "restricted"
|
||||
default:
|
||||
return "member"
|
||||
}
|
||||
}
|
||||
|
||||
func botAPIMemberRestricted(b domain.ChannelBannedRights) bool {
|
||||
return b.SendMessages || b.SendMedia || b.SendStickers || b.SendGifs || b.SendGames ||
|
||||
b.SendInline || b.EmbedLinks || b.SendPolls || b.ChangeInfo || b.InviteUsers ||
|
||||
b.PinMessages || b.ManageTopics || b.SendPhotos || b.SendVideos || b.SendRoundvideos ||
|
||||
b.SendAudios || b.SendVoices || b.SendDocs || b.SendPlain || b.SendReactions
|
||||
}
|
||||
|
||||
func apiChat(peer domain.Peer, users map[int64]domain.User) map[string]any {
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
|
|
|
|||
|
|
@ -41,6 +41,9 @@ type WebAppService interface {
|
|||
|
||||
type GatewayService interface {
|
||||
BotAPISelf(ctx context.Context, botID int64) (domain.User, error)
|
||||
BotAPIChat(ctx context.Context, botID, chatID int64) (domain.BotAPIChat, error)
|
||||
BotAPIChatMemberCount(ctx context.Context, botID, chatID int64) (int, error)
|
||||
BotAPIChatMember(ctx context.Context, botID, chatID, userID int64) (domain.BotAPIChatMember, error)
|
||||
BotAPIUpdates(ctx context.Context, botID int64, offset int64) ([]domain.UpdateEvent, error)
|
||||
BotAPISendMessage(ctx context.Context, botID, chatID int64, text string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview, silent bool, replyToMessageID int) (domain.Message, error)
|
||||
BotAPISendRichMessage(ctx context.Context, botID, chatID int64, rich domain.BotAPIRichMessageInput, replyMarkup *domain.MessageReplyMarkup, silent, noForwards bool, replyToMessageID int, effectID int64) (domain.Message, error)
|
||||
|
|
@ -197,6 +200,12 @@ func (h *handler) handle(w http.ResponseWriter, r *http.Request) {
|
|||
switch strings.ToLower(method) {
|
||||
case "getme":
|
||||
h.getMe(w, r, botID)
|
||||
case "getchat":
|
||||
h.getChat(w, r, botID)
|
||||
case "getchatmembercount", "getchatmemberscount":
|
||||
h.getChatMemberCount(w, r, botID)
|
||||
case "getchatmember":
|
||||
h.getChatMember(w, r, botID)
|
||||
case "setmycommands":
|
||||
h.setMyCommands(w, r, botID)
|
||||
case "deletemycommands":
|
||||
|
|
@ -310,6 +319,81 @@ func (h *handler) getMe(w http.ResponseWriter, r *http.Request, botID int64) {
|
|||
writeAPIOK(w, apiUser(u))
|
||||
}
|
||||
|
||||
func (h *handler) getChat(w http.ResponseWriter, r *http.Request, botID int64) {
|
||||
if h.gateway == nil {
|
||||
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
|
||||
return
|
||||
}
|
||||
values, err := requestValues(r)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
// Numeric chat_id only - no @username resolution.
|
||||
chatID, err := strconv.ParseInt(strings.TrimSpace(values["chat_id"]), 10, 64)
|
||||
if err != nil || chatID == 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID")
|
||||
return
|
||||
}
|
||||
chat, err := h.gateway.BotAPIChat(r.Context(), botID, chatID)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
|
||||
return
|
||||
}
|
||||
writeAPIOK(w, apiChatFull(chat))
|
||||
}
|
||||
|
||||
func (h *handler) getChatMemberCount(w http.ResponseWriter, r *http.Request, botID int64) {
|
||||
if h.gateway == nil {
|
||||
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
|
||||
return
|
||||
}
|
||||
values, err := requestValues(r)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
chatID, err := strconv.ParseInt(strings.TrimSpace(values["chat_id"]), 10, 64)
|
||||
if err != nil || chatID == 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID")
|
||||
return
|
||||
}
|
||||
count, err := h.gateway.BotAPIChatMemberCount(r.Context(), botID, chatID)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
|
||||
return
|
||||
}
|
||||
writeAPIOK(w, count)
|
||||
}
|
||||
|
||||
func (h *handler) getChatMember(w http.ResponseWriter, r *http.Request, botID int64) {
|
||||
if h.gateway == nil {
|
||||
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
|
||||
return
|
||||
}
|
||||
values, err := requestValues(r)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
chatID, err := strconv.ParseInt(strings.TrimSpace(values["chat_id"]), 10, 64)
|
||||
if err != nil || chatID == 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID")
|
||||
return
|
||||
}
|
||||
userID, err := strconv.ParseInt(strings.TrimSpace(values["user_id"]), 10, 64)
|
||||
if err != nil || userID <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "USER_ID_INVALID")
|
||||
return
|
||||
}
|
||||
member, err := h.gateway.BotAPIChatMember(r.Context(), botID, chatID, userID)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
|
||||
return
|
||||
}
|
||||
writeAPIOK(w, apiChatMember(member))
|
||||
}
|
||||
|
||||
func (h *handler) getUpdates(w http.ResponseWriter, r *http.Request, botID int64) {
|
||||
if h.gateway == nil {
|
||||
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
|
||||
|
|
@ -1457,6 +1541,7 @@ func apiErrorDescription(err error) string {
|
|||
"BUTTON_URL_INVALID",
|
||||
"BOT_INVALID",
|
||||
"CHAT_ID_INVALID",
|
||||
"CHAT_NOT_FOUND",
|
||||
"ENTITY_INVALID",
|
||||
"ENTITIES_TOO_LONG",
|
||||
"ENTITY_BOUNDS_INVALID",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
|
|
@ -161,6 +162,111 @@ func TestGetMeUsesGateway(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetChatUsesGateway(t *testing.T) {
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
gateway := &fakeBotAPIGateway{
|
||||
chat: domain.BotAPIChat{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 42},
|
||||
Type: "supergroup",
|
||||
Title: "Test",
|
||||
Username: "test1",
|
||||
Description: "a test group",
|
||||
IsForum: true,
|
||||
},
|
||||
}
|
||||
h := (&handler{bots: bots, gateway: gateway}).routes()
|
||||
|
||||
rec := performBotAPIRequest(t, h, bots.profile, "getChat", `{"chat_id":-1000000000042}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if gateway.chatChatID != -1000000000042 {
|
||||
t.Fatalf("gateway chat_id = %d, want -1000000000042", gateway.chatChatID)
|
||||
}
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Result struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Username string `json:"username"`
|
||||
Description string `json:"description"`
|
||||
IsForum bool `json:"is_forum"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !resp.OK || resp.Result.ID != -1000000000042 || resp.Result.Type != "supergroup" ||
|
||||
resp.Result.Username != "test1" || resp.Result.Description != "a test group" || !resp.Result.IsForum {
|
||||
t.Fatalf("response = %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// A private chat the bot cannot see comes back as chat not found.
|
||||
gateway.chatErr = errors.New("CHAT_NOT_FOUND")
|
||||
rec = performBotAPIRequest(t, h, bots.profile, "getChat", `{"chat_id":-1000000000099}`)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "CHAT_NOT_FOUND") {
|
||||
t.Fatalf("not-found response status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// @username is rejected before it reaches the gateway.
|
||||
rec = performBotAPIRequest(t, h, bots.profile, "getChat", `{"chat_id":"@test1"}`)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "CHAT_ID_INVALID") {
|
||||
t.Fatalf("username response status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetChatMemberCountAndMember(t *testing.T) {
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
gateway := &fakeBotAPIGateway{
|
||||
memberCount: 7,
|
||||
member: domain.BotAPIChatMember{
|
||||
User: domain.User{ID: 500, FirstName: "Ann"},
|
||||
Member: domain.ChannelMember{
|
||||
UserID: 500, Role: domain.ChannelRoleAdmin, Status: domain.ChannelMemberActive,
|
||||
AdminRights: domain.ChannelAdminRights{BanUsers: true, PinMessages: true},
|
||||
Rank: "mod",
|
||||
},
|
||||
},
|
||||
}
|
||||
h := (&handler{bots: bots, gateway: gateway}).routes()
|
||||
|
||||
rec := performBotAPIRequest(t, h, bots.profile, "getChatMemberCount", `{"chat_id":-1000000000042}`)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"result":7`) {
|
||||
t.Fatalf("getChatMemberCount status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = performBotAPIRequest(t, h, bots.profile, "getChatMember", `{"chat_id":-1000000000042,"user_id":500}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("getChatMember status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Result struct {
|
||||
Status string `json:"status"`
|
||||
CustomTitle string `json:"custom_title"`
|
||||
CanRestrict bool `json:"can_restrict_members"`
|
||||
CanPromote bool `json:"can_promote_members"`
|
||||
User struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"user"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !resp.OK || resp.Result.Status != "administrator" || resp.Result.User.ID != 500 ||
|
||||
resp.Result.CustomTitle != "mod" || !resp.Result.CanRestrict || resp.Result.CanPromote {
|
||||
t.Fatalf("getChatMember result = %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// user_id is required.
|
||||
rec = performBotAPIRequest(t, h, bots.profile, "getChatMember", `{"chat_id":-1000000000042}`)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "USER_ID_INVALID") {
|
||||
t.Fatalf("missing user_id status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotCommandsPreserveEphemeralFlag(t *testing.T) {
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
h := (&handler{bots: bots}).routes()
|
||||
|
|
@ -1442,6 +1548,13 @@ func (f *fakeWebAppService) SavePreparedInlineMessageFromBotAPI(_ context.Contex
|
|||
type fakeBotAPIGateway struct {
|
||||
self domain.User
|
||||
|
||||
chat domain.BotAPIChat
|
||||
chatErr error
|
||||
chatChatID int64
|
||||
memberCount int
|
||||
member domain.BotAPIChatMember
|
||||
memberErr error
|
||||
|
||||
updates []domain.UpdateEvent
|
||||
updateBotID int64
|
||||
updateOffset int64
|
||||
|
|
@ -1502,6 +1615,19 @@ func (f *fakeBotAPIGateway) BotAPISelf(context.Context, int64) (domain.User, err
|
|||
return f.self, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPIChat(_ context.Context, _ int64, chatID int64) (domain.BotAPIChat, error) {
|
||||
f.chatChatID = chatID
|
||||
return f.chat, f.chatErr
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPIChatMemberCount(context.Context, int64, int64) (int, error) {
|
||||
return f.memberCount, f.memberErr
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPIChatMember(context.Context, int64, int64, int64) (domain.BotAPIChatMember, error) {
|
||||
return f.member, f.memberErr
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPIUpdates(_ context.Context, botID int64, offset int64) ([]domain.UpdateEvent, error) {
|
||||
f.updateBotID = botID
|
||||
f.updateOffset = offset
|
||||
|
|
|
|||
|
|
@ -6,44 +6,133 @@
|
|||
package branding
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"unicode"
|
||||
|
||||
"telesrv/internal/links"
|
||||
)
|
||||
|
||||
const (
|
||||
ProductName = "OwpenGram"
|
||||
ProductUsername = "owpengram"
|
||||
DesktopAppName = "OwpenGram Desktop"
|
||||
AndroidAppName = "OwpenGram Android"
|
||||
IOSAppName = "OwpenGram iOS"
|
||||
MacOSAppName = "OwpenGram macOS"
|
||||
WebAAppName = "OwpenGram Web A"
|
||||
WebKAppName = "OwpenGram Web K"
|
||||
PremiumName = "OwpenGram Premium"
|
||||
StarsName = "OwpenGram Stars"
|
||||
DefaultPublicURL = "https://owpengram.org"
|
||||
// Config is the deployment-wide, user-visible product identity. It is loaded
|
||||
// once during process startup; protocol identifiers and client detection
|
||||
// tokens deliberately remain outside this structure.
|
||||
type Config struct {
|
||||
ProductName string
|
||||
ProductUsername string
|
||||
DesktopAppName string
|
||||
AndroidAppName string
|
||||
IOSAppName string
|
||||
MacOSAppName string
|
||||
WebAAppName string
|
||||
WebKAppName string
|
||||
PremiumName string
|
||||
StarsName string
|
||||
PublicBaseURL string
|
||||
}
|
||||
|
||||
var (
|
||||
defaultConfig = Config{
|
||||
ProductName: "OwpenGram",
|
||||
ProductUsername: "owpengram",
|
||||
DesktopAppName: "OwpenGram Desktop",
|
||||
AndroidAppName: "OwpenGram Android",
|
||||
IOSAppName: "OwpenGram iOS",
|
||||
MacOSAppName: "OwpenGram macOS",
|
||||
WebAAppName: "OwpenGram Web A",
|
||||
WebKAppName: "OwpenGram Web K",
|
||||
PremiumName: "OwpenGram Premium",
|
||||
StarsName: "OwpenGram Stars",
|
||||
PublicBaseURL: links.DefaultDownloadURL,
|
||||
}
|
||||
configured atomic.Pointer[Config]
|
||||
)
|
||||
|
||||
// DefaultConfig returns a copy of the default product identity.
|
||||
func DefaultConfig() Config { return defaultConfig }
|
||||
|
||||
// Validate normalizes and validates a product identity without installing it.
|
||||
func Validate(cfg Config) (Config, error) {
|
||||
for _, field := range []struct {
|
||||
name string
|
||||
value *string
|
||||
}{
|
||||
{name: "product name", value: &cfg.ProductName},
|
||||
{name: "desktop app name", value: &cfg.DesktopAppName},
|
||||
{name: "Android app name", value: &cfg.AndroidAppName},
|
||||
{name: "iOS app name", value: &cfg.IOSAppName},
|
||||
{name: "macOS app name", value: &cfg.MacOSAppName},
|
||||
{name: "Web A app name", value: &cfg.WebAAppName},
|
||||
{name: "Web K app name", value: &cfg.WebKAppName},
|
||||
{name: "Premium name", value: &cfg.PremiumName},
|
||||
{name: "Stars name", value: &cfg.StarsName},
|
||||
} {
|
||||
normalized, err := validateDisplayName(*field.value)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("%s: %w", field.name, err)
|
||||
}
|
||||
*field.value = normalized
|
||||
}
|
||||
cfg.ProductUsername = strings.TrimPrefix(strings.TrimSpace(cfg.ProductUsername), "@")
|
||||
if !validProductUsername(cfg.ProductUsername) {
|
||||
return Config{}, fmt.Errorf("product username must be 5-32 ASCII username characters and start with a letter")
|
||||
}
|
||||
cfg.ProductUsername = strings.ToLower(cfg.ProductUsername)
|
||||
var err error
|
||||
cfg.PublicBaseURL, err = links.ValidateBaseURL(cfg.PublicBaseURL)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("public base URL: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Configure installs the validated process-wide identity before services are
|
||||
// constructed. Readers only ever observe complete immutable snapshots.
|
||||
func Configure(cfg Config) error {
|
||||
normalized, err := Validate(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
configured.Store(&normalized)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Current returns a copy of the installed product identity.
|
||||
func Current() Config {
|
||||
if cfg := configured.Load(); cfg != nil {
|
||||
return *cfg
|
||||
}
|
||||
return defaultConfig
|
||||
}
|
||||
|
||||
func ProductName() string { return Current().ProductName }
|
||||
func ProductUsername() string { return Current().ProductUsername }
|
||||
func PremiumName() string { return Current().PremiumName }
|
||||
func StarsName() string { return Current().StarsName }
|
||||
func PublicBaseURL() string { return Current().PublicBaseURL }
|
||||
|
||||
// ClientAppName returns the branded display name for a stored client platform.
|
||||
// Stored detection tokens remain unchanged; this is only used at presentation
|
||||
// boundaries such as account.getAuthorizations.
|
||||
func ClientAppName(platform string) string {
|
||||
cfg := Current()
|
||||
switch strings.ToLower(strings.TrimSpace(platform)) {
|
||||
case "android":
|
||||
return AndroidAppName
|
||||
return cfg.AndroidAppName
|
||||
case "ios":
|
||||
return IOSAppName
|
||||
return cfg.IOSAppName
|
||||
case "macos":
|
||||
return MacOSAppName
|
||||
return cfg.MacOSAppName
|
||||
case "telegram-tt", "weba":
|
||||
return WebAAppName
|
||||
return cfg.WebAAppName
|
||||
case "tweb", "webk":
|
||||
return WebKAppName
|
||||
return cfg.WebKAppName
|
||||
case "tdesktop", "desktop", "windows":
|
||||
return DesktopAppName
|
||||
return cfg.DesktopAppName
|
||||
default:
|
||||
return ProductName
|
||||
return cfg.ProductName
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -78,18 +167,49 @@ func UserVisibleText(value, publicBaseURL string) string {
|
|||
if technicalIDRE.MatchString(value) {
|
||||
return value
|
||||
}
|
||||
return officialBrandRE.ReplaceAllString(value, ProductName)
|
||||
return officialBrandRE.ReplaceAllString(value, ProductName())
|
||||
}
|
||||
|
||||
func publicDestination(raw string) (string, string) {
|
||||
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
|
||||
if raw == "" {
|
||||
raw = DefaultPublicURL
|
||||
raw = PublicBaseURL()
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Hostname() == "" {
|
||||
raw = DefaultPublicURL
|
||||
raw = PublicBaseURL()
|
||||
parsed, _ = url.Parse(raw)
|
||||
}
|
||||
return raw, parsed.Host
|
||||
}
|
||||
|
||||
func validateDisplayName(raw string) (string, error) {
|
||||
name := strings.TrimSpace(raw)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("must not be empty")
|
||||
}
|
||||
if len([]rune(name)) > 64 {
|
||||
return "", fmt.Errorf("must not exceed 64 characters")
|
||||
}
|
||||
for _, r := range name {
|
||||
if unicode.IsControl(r) {
|
||||
return "", fmt.Errorf("must not contain control characters")
|
||||
}
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func validProductUsername(username string) bool {
|
||||
if len(username) < 5 || len(username) > 32 {
|
||||
return false
|
||||
}
|
||||
for i, r := range username {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z':
|
||||
case i > 0 && (r >= '0' && r <= '9' || r == '_'):
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,13 +46,14 @@ func TestUserVisibleTextRebrandsLocalizedProductNames(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestClientPresentationNames(t *testing.T) {
|
||||
cfg := Current()
|
||||
for platform, want := range map[string]string{
|
||||
"tdesktop": DesktopAppName,
|
||||
"android": AndroidAppName,
|
||||
"ios": IOSAppName,
|
||||
"macos": MacOSAppName,
|
||||
"telegram-tt": WebAAppName,
|
||||
"tweb": WebKAppName,
|
||||
"tdesktop": cfg.DesktopAppName,
|
||||
"android": cfg.AndroidAppName,
|
||||
"ios": cfg.IOSAppName,
|
||||
"macos": cfg.MacOSAppName,
|
||||
"telegram-tt": cfg.WebAAppName,
|
||||
"tweb": cfg.WebKAppName,
|
||||
} {
|
||||
if got := ClientAppName(platform); got != want {
|
||||
t.Fatalf("ClientAppName(%q) = %q, want %q", platform, got, want)
|
||||
|
|
@ -62,3 +63,55 @@ func TestClientPresentationNames(t *testing.T) {
|
|||
t.Fatalf("UserVisibleClientPlatform() = %q, want weba", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureInstallsCompleteBrandSnapshot(t *testing.T) {
|
||||
previous := Current()
|
||||
t.Cleanup(func() {
|
||||
if err := Configure(previous); err != nil {
|
||||
t.Fatalf("restore branding: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
cfg := Config{
|
||||
ProductName: "Example Chat",
|
||||
ProductUsername: "@Example_Chat",
|
||||
DesktopAppName: "Example Workstation",
|
||||
AndroidAppName: "Example Droid",
|
||||
IOSAppName: "Example Phone",
|
||||
MacOSAppName: "Example Mac",
|
||||
WebAAppName: "Example Web Alpha",
|
||||
WebKAppName: "Example Web Kappa",
|
||||
PremiumName: "Example Plus",
|
||||
StarsName: "Example Credits",
|
||||
PublicBaseURL: "https://links.example.test/root/",
|
||||
}
|
||||
if err := Configure(cfg); err != nil {
|
||||
t.Fatalf("Configure: %v", err)
|
||||
}
|
||||
if got := Current(); got.ProductUsername != "example_chat" || got.PublicBaseURL != "https://links.example.test/root" {
|
||||
t.Fatalf("Current() = %+v", got)
|
||||
}
|
||||
if got := ClientAppName("android"); got != "Example Droid" {
|
||||
t.Fatalf("ClientAppName(android) = %q", got)
|
||||
}
|
||||
if got := UserVisibleText("Telegram at t.me/example", ""); got != "Example Chat at links.example.test/example" {
|
||||
t.Fatalf("UserVisibleText() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsIncompleteOrUnsafeBranding(t *testing.T) {
|
||||
for name, mutate := range map[string]func(*Config){
|
||||
"blank product": func(cfg *Config) { cfg.ProductName = " " },
|
||||
"control": func(cfg *Config) { cfg.StarsName = "bad\nname" },
|
||||
"username": func(cfg *Config) { cfg.ProductUsername = "3bad" },
|
||||
"public URL": func(cfg *Config) { cfg.PublicBaseURL = "file:///tmp/brand" },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
mutate(&cfg)
|
||||
if _, err := Validate(cfg); err == nil {
|
||||
t.Fatal("Validate accepted invalid branding")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
|
||||
var (
|
||||
ErrPasswordHashInvalid = errors.New("password hash invalid")
|
||||
ErrPasswordMissing = errors.New("password missing")
|
||||
ErrSRPIDInvalid = errors.New("srp id invalid")
|
||||
ErrSRPPasswordChanged = errors.New("srp password changed")
|
||||
ErrNewSettingsInvalid = errors.New("new password settings invalid")
|
||||
|
|
|
|||
32
internal/domain/botapi_chat.go
Normal file
32
internal/domain/botapi_chat.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package domain
|
||||
|
||||
// BotAPIChat is a peer resolved for the Bot API getChat method. The bot need
|
||||
// not be a member: a public channel or supergroup resolves (projected as a
|
||||
// preview), while a private chat the bot has no access to resolves to an error.
|
||||
type BotAPIChat struct {
|
||||
Peer Peer // domain peer; the Bot API chat-id encoding is applied by the projection
|
||||
Type string // "private" | "group" | "supergroup" | "channel"
|
||||
Title string
|
||||
Username string
|
||||
FirstName string
|
||||
LastName string
|
||||
Description string // channel/supergroup "about"
|
||||
IsForum bool
|
||||
Verified bool
|
||||
Scam bool
|
||||
Fake bool
|
||||
|
||||
// Channel/supergroup only, from the full view.
|
||||
SlowModeDelay int
|
||||
LinkedChatID int64 // domain channel id; the projection applies the Bot API encoding
|
||||
Permissions *ChannelBannedRights // default restrictions; nil for a user chat
|
||||
PinnedMessage *Message
|
||||
PinnedMessageUsers []User
|
||||
}
|
||||
|
||||
// BotAPIChatMember is a resolved chat member for the Bot API getChatMember
|
||||
// method.
|
||||
type BotAPIChatMember struct {
|
||||
User User
|
||||
Member ChannelMember
|
||||
}
|
||||
|
|
@ -714,6 +714,23 @@ type ChannelMessage struct {
|
|||
Deleted bool
|
||||
}
|
||||
|
||||
// ForumReplyTopicID resolves the topic a reply to target belongs to inside a
|
||||
// forum. Every forum message lives in exactly one topic, and a reply inherits
|
||||
// the target's topic - never the target's own id. Using target.ID is
|
||||
// discussion-thread logic (comment threads on a broadcast post) and does not
|
||||
// apply to forums: it manufactures a topic reference that no channel_forum_topics
|
||||
// row backs, which strict clients cannot place. A target with no recorded topic
|
||||
// is in General.
|
||||
func ForumReplyTopicID(target ChannelMessage) int {
|
||||
if target.Action != nil && target.Action.Type == ChannelActionTopicCreate {
|
||||
return target.ID // the target itself is a topic root
|
||||
}
|
||||
if target.ReplyTo != nil && target.ReplyTo.TopMessageID > 0 {
|
||||
return target.ReplyTo.TopMessageID
|
||||
}
|
||||
return ForumGeneralTopicID
|
||||
}
|
||||
|
||||
// ProjectChannelHistoryClearMessage returns the owner-local service-message
|
||||
// projection for one channel history boundary. Identity fields from the shared
|
||||
// source are retained when available, while all user payload, media, reply,
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ import (
|
|||
"telesrv/internal/branding"
|
||||
)
|
||||
|
||||
const officialLoginCodeMessageTemplate = `Login code: %s. Do not give this code to anyone, even if they say they are from ` + branding.ProductName + `!
|
||||
var officialLoginCodeMessageTemplate = `Login code: %s. Do not give this code to anyone, even if they say they are from ` + branding.ProductName() + `!
|
||||
|
||||
This code can be used to log in to your ` + branding.ProductName + ` account. We never ask it for anything else.
|
||||
This code can be used to log in to your ` + branding.ProductName() + ` account. We never ask it for anything else.
|
||||
|
||||
If you didn't request this code by trying to log in on another device, simply ignore this message.`
|
||||
|
||||
|
|
|
|||
24
internal/domain/reserved_username.go
Normal file
24
internal/domain/reserved_username.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// MaxReservedUsernameReasonLength bounds the operator note on a reservation.
|
||||
const MaxReservedUsernameReasonLength = 512
|
||||
|
||||
// ReservedUsername is one entry in the operator username blocklist. A reserved
|
||||
// name cannot be taken as an editable username by any peer and cannot be minted
|
||||
// as a collectible.
|
||||
type ReservedUsername struct {
|
||||
Username string // display form (original case at reservation time)
|
||||
Reason string
|
||||
Actor string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ReservedUsernameFilter pages the blocklist. Query matches a username prefix
|
||||
// (case-insensitive); an empty query lists everything.
|
||||
type ReservedUsernameFilter struct {
|
||||
Query string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
|
@ -172,8 +172,8 @@ func OfficialSystemUser() User {
|
|||
ID: OfficialSystemUserID,
|
||||
AccessHash: 6599886787491911851,
|
||||
Phone: "42777",
|
||||
FirstName: branding.ProductName,
|
||||
Username: branding.ProductUsername,
|
||||
FirstName: branding.ProductName(),
|
||||
Username: branding.ProductUsername(),
|
||||
Verified: true,
|
||||
Support: true,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
const officialWelcomeMessageTemplate = "👋 Welcome to OwpenGram!\n\nYou just signed in via %s.\n\nIf this wasn't you, revoke this session from \"Settings > Privacy and Security > Active sessions\" immediately."
|
||||
// officialUpdatesChannelMention is the public @username of the updates channel
|
||||
// linked from the welcome message. It is carried both in the template text and
|
||||
// in a MessageEntityMention so clients render it as a tappable link.
|
||||
const officialUpdatesChannelMention = "@ziodotsh"
|
||||
|
||||
const officialWelcomeMessageTemplate = "👋 Welcome to OwpenGram!\n\nYou just signed in via %s.\n\nIf this wasn't you, revoke this session from \"Settings > Privacy and Security > Active sessions\" immediately.\n\nIf you haven't already, feel free to join " + officialUpdatesChannelMention + " for all the latest updates!"
|
||||
|
||||
// OfficialWelcomeMessage builds the account-visible incoming message sent
|
||||
// from the official system account on every completed sign-in (SignUp and
|
||||
|
|
@ -20,13 +25,43 @@ func OfficialWelcomeMessage(userID int64, method string, date int) (Message, err
|
|||
if userID <= 0 || IsSystemUserID(userID) || method == "" || date < 0 || date > math.MaxInt32 {
|
||||
return Message{}, fmt.Errorf("%w: user=%d method=%q date=%d", ErrLoginCodeDeliveryInvalid, userID, method, date)
|
||||
}
|
||||
return Message{
|
||||
body := fmt.Sprintf(officialWelcomeMessageTemplate, method)
|
||||
msg := Message{
|
||||
OwnerUserID: userID,
|
||||
Peer: Peer{Type: PeerTypeUser, ID: OfficialSystemUserID},
|
||||
From: Peer{Type: PeerTypeUser, ID: OfficialSystemUserID},
|
||||
Date: date,
|
||||
Body: fmt.Sprintf(officialWelcomeMessageTemplate, method),
|
||||
}, nil
|
||||
Body: body,
|
||||
}
|
||||
if ent, ok := usernameMentionEntity(body, officialUpdatesChannelMention); ok {
|
||||
msg.Entities = []MessageEntity{ent}
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// usernameMentionEntity locates mention ("@name") in text and returns a
|
||||
// MessageEntityMention spanning it, with the UTF-16 offset/length clients expect.
|
||||
func usernameMentionEntity(text, mention string) (MessageEntity, bool) {
|
||||
before, _, found := strings.Cut(text, mention)
|
||||
if !found {
|
||||
return MessageEntity{}, false
|
||||
}
|
||||
return MessageEntity{
|
||||
Type: MessageEntityMention,
|
||||
Offset: utf16Len(before),
|
||||
Length: utf16Len(mention),
|
||||
}, true
|
||||
}
|
||||
|
||||
func utf16Len(s string) int {
|
||||
n := 0
|
||||
for _, r := range s {
|
||||
n++
|
||||
if r > 0xffff {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// SignInMethodLabel returns the human-readable method name embedded in
|
||||
|
|
|
|||
29
internal/domain/welcome_message_test.go
Normal file
29
internal/domain/welcome_message_test.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
func TestOfficialWelcomeMessageLinksUpdatesChannel(t *testing.T) {
|
||||
for _, method := range []string{"phone number", "email"} {
|
||||
msg, err := OfficialWelcomeMessage(1780243200, method, 1_700_000_000)
|
||||
if err != nil {
|
||||
t.Fatalf("method %q: %v", method, err)
|
||||
}
|
||||
if len(msg.Entities) != 1 {
|
||||
t.Fatalf("method %q: entities = %+v, want one mention", method, msg.Entities)
|
||||
}
|
||||
ent := msg.Entities[0]
|
||||
if ent.Type != MessageEntityMention {
|
||||
t.Fatalf("entity type = %q, want mention", ent.Type)
|
||||
}
|
||||
units := utf16.Encode([]rune(msg.Body))
|
||||
if ent.Offset < 0 || ent.Length <= 0 || ent.Offset+ent.Length > len(units) {
|
||||
t.Fatalf("entity %+v out of bounds for body of %d utf16 units", ent, len(units))
|
||||
}
|
||||
if got := string(utf16.Decode(units[ent.Offset : ent.Offset+ent.Length])); got != officialUpdatesChannelMention {
|
||||
t.Fatalf("entity spans %q, want %q", got, officialUpdatesChannelMention)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -230,9 +230,9 @@ func TestBotInlineKeyboardCallbackFlow(t *testing.T) {
|
|||
return fmt.Errorf("bot getUsers(owner) = %d, want 1", len(got))
|
||||
}
|
||||
ownerSeen := got[0].(*tg.User)
|
||||
markup := &tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{
|
||||
&tg.KeyboardButtonCallback{Text: "Press", Data: callbackData},
|
||||
&tg.KeyboardButtonURL{Text: "Site", URL: "https://example.com/x"},
|
||||
markup := &tg.ReplyInlineMarkup{Rows: []tg.KeyboardInlineButtonRow{{Buttons: []tg.KeyboardInlineButton{
|
||||
{Text: "Press", Type: &tg.InlineButtonTypeCallback{Data: callbackData}},
|
||||
{Text: "Site", Type: &tg.InlineButtonTypeURL{URL: "https://example.com/x"}},
|
||||
}}}}
|
||||
req := &tg.MessagesSendMessageRequest{
|
||||
Peer: &tg.InputPeerUser{UserID: ownerSeen.ID, AccessHash: ownerSeen.AccessHash},
|
||||
|
|
@ -312,15 +312,15 @@ func TestBotInlineKeyboardCallbackFlow(t *testing.T) {
|
|||
if !ok || len(inline.Rows) != 1 || len(inline.Rows[0].Buttons) != 2 {
|
||||
t.Fatalf("unexpected markup shape: %#v", rm)
|
||||
}
|
||||
cbBtn, ok := inline.Rows[0].Buttons[0].(*tg.KeyboardButtonCallback)
|
||||
cbBtn, ok := inline.Rows[0].Buttons[0].Type.(*tg.InlineButtonTypeCallback)
|
||||
if !ok {
|
||||
t.Fatalf("first button not callback: %#v", inline.Rows[0].Buttons[0])
|
||||
t.Fatalf("first button not callback: %#v", inline.Rows[0].Buttons[0].Type)
|
||||
}
|
||||
if string(cbBtn.Data) != string(callbackData) {
|
||||
t.Fatalf("callback data round-trip mismatch: got %v want %v", cbBtn.Data, callbackData)
|
||||
}
|
||||
if _, ok := inline.Rows[0].Buttons[1].(*tg.KeyboardButtonURL); !ok {
|
||||
t.Fatalf("second button not url: %#v", inline.Rows[0].Buttons[1])
|
||||
if _, ok := inline.Rows[0].Buttons[1].Type.(*tg.InlineButtonTypeURL); !ok {
|
||||
t.Fatalf("second button not url: %#v", inline.Rows[0].Buttons[1].Type)
|
||||
}
|
||||
msgID = msg.ID
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ package smtp
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net"
|
||||
|
|
@ -146,6 +148,8 @@ func buildMessage(from, to, subject, body string) []byte {
|
|||
var b bytes.Buffer
|
||||
b.WriteString("From: " + from + "\r\n")
|
||||
b.WriteString("To: " + to + "\r\n")
|
||||
b.WriteString("Date: " + time.Now().Format(time.RFC1123Z) + "\r\n")
|
||||
b.WriteString("Message-ID: " + generateMessageID(from) + "\r\n")
|
||||
b.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", subject) + "\r\n")
|
||||
b.WriteString("MIME-Version: 1.0\r\n")
|
||||
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
||||
|
|
@ -155,6 +159,25 @@ func buildMessage(from, to, subject, body string) []byte {
|
|||
return b.Bytes()
|
||||
}
|
||||
|
||||
// generateMessageID builds a Message-ID header value (RFC 5322 3.6.4), using
|
||||
// the sending domain parsed out of the From address and a random token so
|
||||
// each message gets a unique id even under concurrent sends.
|
||||
func generateMessageID(from string) string {
|
||||
domain := "localhost"
|
||||
if addr, err := stdmail.ParseAddress(from); err == nil {
|
||||
if i := strings.LastIndex(addr.Address, "@"); i >= 0 {
|
||||
domain = addr.Address[i+1:]
|
||||
}
|
||||
}
|
||||
var raw [16]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
// crypto/rand failing is effectively unheard of, but fall back to a
|
||||
// time-based token rather than emit a non-unique Message-ID.
|
||||
return fmt.Sprintf("<%d@%s>", time.Now().UnixNano(), domain)
|
||||
}
|
||||
return fmt.Sprintf("<%s@%s>", hex.EncodeToString(raw[:]), domain)
|
||||
}
|
||||
|
||||
// emailContent builds the login-code email subject/body, branded with the
|
||||
// operator's configured product name (Config.AppName) instead of the
|
||||
// package's internal "telesrv" fallback.
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ func (r *Router) resolveAIComposeStyleWebPage(ctx context.Context, rawURL string
|
|||
Hash: aiComposeToneWebPageHash(tone),
|
||||
Date: int(now.Unix()),
|
||||
Type: aiComposeToneWebPageType,
|
||||
SiteName: branding.ProductName,
|
||||
SiteName: branding.ProductName(),
|
||||
Title: tone.Title,
|
||||
Description: tone.Prompt,
|
||||
ComposeToneEmojiID: tone.EmojiID,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,148 @@ func (r *Router) BotAPISelf(ctx context.Context, botID int64) (domain.User, erro
|
|||
return u, nil
|
||||
}
|
||||
|
||||
// BotAPIChat resolves a chat for the Bot API getChat method. chat_id is numeric
|
||||
// only (no @username). Public channels/supergroups resolve even when the bot is
|
||||
// not a member; a private chat the bot cannot access is CHAT_NOT_FOUND.
|
||||
func (r *Router) BotAPIChat(ctx context.Context, botID, chatID int64) (domain.BotAPIChat, error) {
|
||||
if r == nil || botID == 0 {
|
||||
return domain.BotAPIChat{}, errors.New("BOT_INVALID")
|
||||
}
|
||||
peer, ok := botAPIPeerFromChatID(chatID)
|
||||
if !ok {
|
||||
return domain.BotAPIChat{}, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
if r.deps.Users == nil {
|
||||
return domain.BotAPIChat{}, errors.New("CHAT_NOT_FOUND")
|
||||
}
|
||||
u, found, err := r.deps.Users.ByID(ctx, botID, peer.ID)
|
||||
if err != nil {
|
||||
return domain.BotAPIChat{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.BotAPIChat{}, errors.New("CHAT_NOT_FOUND")
|
||||
}
|
||||
return domain.BotAPIChat{
|
||||
Peer: peer,
|
||||
Type: "private",
|
||||
FirstName: u.FirstName,
|
||||
LastName: u.LastName,
|
||||
Username: u.Username,
|
||||
Verified: u.Verified,
|
||||
Scam: u.Scam,
|
||||
Fake: u.Fake,
|
||||
}, nil
|
||||
case domain.PeerTypeChannel:
|
||||
if r.deps.Channels == nil {
|
||||
return domain.BotAPIChat{}, errors.New("CHAT_NOT_FOUND")
|
||||
}
|
||||
view, err := r.deps.Channels.GetChannel(ctx, botID, peer.ID)
|
||||
if err != nil {
|
||||
return domain.BotAPIChat{}, botAPIChatErr(err)
|
||||
}
|
||||
ch := view.Channel
|
||||
typ := "supergroup"
|
||||
if ch.Broadcast && !ch.Megagroup {
|
||||
typ = "channel"
|
||||
}
|
||||
out := domain.BotAPIChat{
|
||||
Peer: peer,
|
||||
Type: typ,
|
||||
Title: ch.Title,
|
||||
Username: ch.Username,
|
||||
Description: ch.About,
|
||||
IsForum: ch.Forum,
|
||||
Verified: ch.Verified,
|
||||
Scam: ch.Scam,
|
||||
Fake: ch.Fake,
|
||||
SlowModeDelay: ch.SlowmodeSeconds,
|
||||
LinkedChatID: ch.LinkedChatID,
|
||||
}
|
||||
if typ == "supergroup" {
|
||||
perms := ch.DefaultBannedRights
|
||||
out.Permissions = &perms
|
||||
}
|
||||
if ch.PinnedMessageID > 0 {
|
||||
if hist, msgErr := r.deps.Channels.GetMessages(ctx, botID, peer.ID, []int{ch.PinnedMessageID}); msgErr == nil && len(hist.Messages) > 0 {
|
||||
pinned := botAPIMessageFromChannel(botID, hist.Messages[0])
|
||||
out.PinnedMessage = &pinned
|
||||
out.PinnedMessageUsers = hist.Users
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return domain.BotAPIChat{}, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
|
||||
// BotAPIChatMemberCount resolves getChatMemberCount. Channels/supergroups only.
|
||||
func (r *Router) BotAPIChatMemberCount(ctx context.Context, botID, chatID int64) (int, error) {
|
||||
if r == nil || botID == 0 {
|
||||
return 0, errors.New("BOT_INVALID")
|
||||
}
|
||||
peer, ok := botAPIPeerFromChatID(chatID)
|
||||
if !ok || peer.Type != domain.PeerTypeChannel {
|
||||
return 0, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
if r.deps.Channels == nil {
|
||||
return 0, errors.New("CHAT_NOT_FOUND")
|
||||
}
|
||||
view, err := r.deps.Channels.ResolveChannel(ctx, botID, peer.ID)
|
||||
if err != nil {
|
||||
return 0, botAPIChatErr(err)
|
||||
}
|
||||
return view.Channel.ParticipantsCount, nil
|
||||
}
|
||||
|
||||
// BotAPIChatMember resolves getChatMember. Channels/supergroups only.
|
||||
func (r *Router) BotAPIChatMember(ctx context.Context, botID, chatID, userID int64) (domain.BotAPIChatMember, error) {
|
||||
if r == nil || botID == 0 {
|
||||
return domain.BotAPIChatMember{}, errors.New("BOT_INVALID")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return domain.BotAPIChatMember{}, errors.New("USER_ID_INVALID")
|
||||
}
|
||||
peer, ok := botAPIPeerFromChatID(chatID)
|
||||
if !ok || peer.Type != domain.PeerTypeChannel {
|
||||
return domain.BotAPIChatMember{}, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
if r.deps.Channels == nil {
|
||||
return domain.BotAPIChatMember{}, errors.New("CHAT_NOT_FOUND")
|
||||
}
|
||||
member, err := r.deps.Channels.GetParticipant(ctx, botID, peer.ID, userID)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, domain.ErrUserNotParticipant):
|
||||
// Bot API returns a "left" member for a user who is simply not in the
|
||||
// chat, as long as the chat itself is accessible.
|
||||
member = domain.ChannelMember{ChannelID: peer.ID, UserID: userID, Role: domain.ChannelRoleMember, Status: domain.ChannelMemberLeft}
|
||||
default:
|
||||
return domain.BotAPIChatMember{}, botAPIChatErr(err)
|
||||
}
|
||||
out := domain.BotAPIChatMember{Member: member}
|
||||
if r.deps.Users != nil {
|
||||
if u, found, uErr := r.deps.Users.ByID(ctx, botID, userID); uErr == nil && found {
|
||||
out.User = u
|
||||
}
|
||||
}
|
||||
if out.User.ID == 0 {
|
||||
out.User = domain.User{ID: userID}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func botAPIChatErr(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrChannelInvalid),
|
||||
errors.Is(err, domain.ErrChannelPrivate),
|
||||
errors.Is(err, domain.ErrChannelUserBanned):
|
||||
return errors.New("CHAT_NOT_FOUND")
|
||||
default:
|
||||
return channelInvalidErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
// BotAPIUpdates returns durable update_id based events projected for the HTTP
|
||||
// Bot API. New deployments use the dedicated Bot API queue; the legacy
|
||||
// user_update_events fallback is kept for tests that have not wired the queue.
|
||||
|
|
|
|||
|
|
@ -844,8 +844,8 @@ func TestInlineBotArticleTextChannelRoundTrip(t *testing.T) {
|
|||
}
|
||||
editReq := &tg.MessagesEditInlineBotMessageRequest{ID: msgID}
|
||||
editReq.SetMessage("inline group edited")
|
||||
editReq.SetReplyMarkup(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{
|
||||
Buttons: []tg.KeyboardButtonClass{&tg.KeyboardButtonCallback{Text: "Done", Data: []byte("v2")}},
|
||||
editReq.SetReplyMarkup(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardInlineButtonRow{{
|
||||
Buttons: []tg.KeyboardInlineButton{{Text: "Done", Type: &tg.InlineButtonTypeCallback{Data: []byte("v2")}}},
|
||||
}}})
|
||||
if ok, err := f.router.onMessagesEditInlineBotMessage(botCtx, editReq); err != nil || !ok {
|
||||
t.Fatalf("channel inline edit = %v,%v, want true,nil", ok, err)
|
||||
|
|
@ -2065,12 +2065,13 @@ func assertTGInlineReplyMarkup(t *testing.T, msg *tg.Message, wantText string, w
|
|||
if len(markup.Rows) != 1 || len(markup.Rows[0].Buttons) != 1 {
|
||||
t.Fatalf("reply_markup rows = %+v, want one callback button", markup.Rows)
|
||||
}
|
||||
button, ok := markup.Rows[0].Buttons[0].(*tg.KeyboardButtonCallback)
|
||||
button := markup.Rows[0].Buttons[0]
|
||||
buttonType, ok := button.Type.(*tg.InlineButtonTypeCallback)
|
||||
if !ok {
|
||||
t.Fatalf("reply_markup button = %T, want callback", markup.Rows[0].Buttons[0])
|
||||
t.Fatalf("reply_markup button = %T, want callback", button.Type)
|
||||
}
|
||||
if button.Text != wantText || !bytes.Equal(button.Data, wantData) {
|
||||
t.Fatalf("reply_markup button = %q/%v, want %q/%v", button.Text, button.Data, wantText, wantData)
|
||||
if button.Text != wantText || !bytes.Equal(buttonType.Data, wantData) {
|
||||
t.Fatalf("reply_markup button = %q/%v, want %q/%v", button.Text, buttonType.Data, wantText, wantData)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2119,8 +2120,8 @@ func inlineArticleResult(id, message string) tg.InputBotInlineResultClass {
|
|||
func inlineArticleResultWithCallback(id, message, button string, data []byte) tg.InputBotInlineResultClass {
|
||||
result := inlineArticleResult(id, message).(*tg.InputBotInlineResult)
|
||||
msg := result.SendMessage.(*tg.InputBotInlineMessageText)
|
||||
msg.SetReplyMarkup(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{
|
||||
Buttons: []tg.KeyboardButtonClass{&tg.KeyboardButtonCallback{Text: button, Data: data}},
|
||||
msg.SetReplyMarkup(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardInlineButtonRow{{
|
||||
Buttons: []tg.KeyboardInlineButton{{Text: button, Type: &tg.InlineButtonTypeCallback{Data: data}}},
|
||||
}}})
|
||||
return result
|
||||
}
|
||||
|
|
@ -2308,8 +2309,8 @@ func inlineContactResult(id, phone, first, last, vcard string) *tg.InputBotInlin
|
|||
func inlineContactResultWithCallback(id, phone, first, last string, data []byte) tg.InputBotInlineResultClass {
|
||||
result := inlineContactResult(id, phone, first, last, "BEGIN:VCARD\nFN:"+first+" "+last+"\nEND:VCARD")
|
||||
msg := result.SendMessage.(*tg.InputBotInlineMessageMediaContact)
|
||||
msg.SetReplyMarkup(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{
|
||||
Buttons: []tg.KeyboardButtonClass{&tg.KeyboardButtonCallback{Text: "Contact", Data: data}},
|
||||
msg.SetReplyMarkup(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardInlineButtonRow{{
|
||||
Buttons: []tg.KeyboardInlineButton{{Text: "Contact", Type: &tg.InlineButtonTypeCallback{Data: data}}},
|
||||
}}})
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -594,7 +594,7 @@ func (r *Router) onBotsRequestWebViewButton(ctx context.Context, req *tg.BotsReq
|
|||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if req.UserID == nil || req.Button == nil {
|
||||
if req.UserID == nil || req.Button.Type == nil {
|
||||
return nil, buttonDataInvalidErr()
|
||||
}
|
||||
if r.deps.Bots == nil {
|
||||
|
|
@ -622,21 +622,21 @@ func (r *Router) onBotsRequestWebViewButton(ctx context.Context, req *tg.BotsReq
|
|||
return &tg.BotsRequestedButton{WebappReqID: saved.WebAppReqID}, nil
|
||||
}
|
||||
|
||||
func (r *Router) onBotsGetRequestedWebViewButton(ctx context.Context, req *tg.BotsGetRequestedWebViewButtonRequest) (tg.KeyboardButtonClass, error) {
|
||||
func (r *Router) onBotsGetRequestedWebViewButton(ctx context.Context, req *tg.BotsGetRequestedWebViewButtonRequest) (tg.KeyboardButton, error) {
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
return tg.KeyboardButton{}, internalErr()
|
||||
}
|
||||
bot, err := r.resolveBotUserForViewer(ctx, userID, req.Bot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return tg.KeyboardButton{}, err
|
||||
}
|
||||
button, found, err := r.deps.Bots.GetRequestedWebViewButton(ctx, bot.ID, userID, req.WebappReqID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
return tg.KeyboardButton{}, internalErr()
|
||||
}
|
||||
if !found {
|
||||
return nil, buttonDataInvalidErr()
|
||||
return tg.KeyboardButton{}, buttonDataInvalidErr()
|
||||
}
|
||||
return tgKeyboardButtonRequestPeer(button), nil
|
||||
}
|
||||
|
|
@ -761,21 +761,20 @@ func (r *Router) tgBotPreviewMedia(ctx context.Context, item domain.BotAppPrevie
|
|||
return out
|
||||
}
|
||||
|
||||
func domainRequestedButtonFromTG(botUserID int64, _ tg.InputUserClass, button tg.KeyboardButtonClass) (domain.BotRequestedWebViewButton, error) {
|
||||
func domainRequestedButtonFromTG(botUserID int64, _ tg.InputUserClass, button tg.KeyboardButton) (domain.BotRequestedWebViewButton, error) {
|
||||
var out domain.BotRequestedWebViewButton
|
||||
out.BotUserID = botUserID
|
||||
switch b := button.(type) {
|
||||
case *tg.InputKeyboardButtonRequestPeer:
|
||||
out.Text = strings.TrimSpace(button.Text)
|
||||
switch b := button.Type.(type) {
|
||||
case *tg.InputButtonTypeRequestPeer:
|
||||
out.ButtonID = b.ButtonID
|
||||
out.Text = strings.TrimSpace(b.Text)
|
||||
out.PeerType, out.PeerFilter = domainRequestPeerFilter(b.PeerType)
|
||||
out.MaxQuantity = b.MaxQuantity
|
||||
out.NameRequested = b.NameRequested
|
||||
out.UsernameRequested = b.UsernameRequested
|
||||
out.PhotoRequested = b.PhotoRequested
|
||||
case *tg.KeyboardButtonRequestPeer:
|
||||
case *tg.ButtonTypeRequestPeer:
|
||||
out.ButtonID = b.ButtonID
|
||||
out.Text = strings.TrimSpace(b.Text)
|
||||
out.PeerType, out.PeerFilter = domainRequestPeerFilter(b.PeerType)
|
||||
out.MaxQuantity = b.MaxQuantity
|
||||
default:
|
||||
|
|
@ -800,12 +799,14 @@ func requestPeerTypeName(peerType tg.RequestPeerTypeClass) string {
|
|||
}
|
||||
}
|
||||
|
||||
func tgKeyboardButtonRequestPeer(button domain.BotRequestedWebViewButton) tg.KeyboardButtonClass {
|
||||
return &tg.KeyboardButtonRequestPeer{
|
||||
func tgKeyboardButtonRequestPeer(button domain.BotRequestedWebViewButton) tg.KeyboardButton {
|
||||
return tg.KeyboardButton{
|
||||
Text: button.Text,
|
||||
Type: &tg.ButtonTypeRequestPeer{
|
||||
ButtonID: button.ButtonID,
|
||||
PeerType: tgRequestPeerTypeWithFilter(button.PeerType, button.PeerFilter),
|
||||
MaxQuantity: button.MaxQuantity,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -252,9 +252,9 @@ func TestBotsLongtailCommercialAndSettingsStubs(t *testing.T) {
|
|||
}
|
||||
if _, err := f.router.onBotsRequestWebViewButton(botCtx, &tg.BotsRequestWebViewButtonRequest{
|
||||
UserID: inputUser(f.owner),
|
||||
Button: &tg.KeyboardButtonSimpleWebView{
|
||||
Button: tg.KeyboardButton{
|
||||
Text: "Open",
|
||||
URL: "https://example.com/app",
|
||||
Type: &tg.ButtonTypeSimpleWebView{URL: "https://example.com/app"},
|
||||
},
|
||||
}); !tgerr.Is(err, "BUTTON_DATA_INVALID") {
|
||||
t.Fatalf("request webview button err = %v, want BUTTON_DATA_INVALID", err)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"github.com/iamxvbaba/td/tgerr"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appaccount "telesrv/internal/app/account"
|
||||
appchannels "telesrv/internal/app/channels"
|
||||
appusers "telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
|
|
@ -26,6 +27,10 @@ func (acceptPasswordAccountService) CheckPassword(_ context.Context, _ int64, ch
|
|||
return nil
|
||||
}
|
||||
|
||||
func (acceptPasswordAccountService) GetPassword(_ context.Context, _ int64) (domain.PasswordSettings, error) {
|
||||
return domain.PasswordSettings{HasPassword: true}, nil
|
||||
}
|
||||
|
||||
func TestMessagesGetFutureChatCreatorAfterLeaveAndCreatorLeaveTransfers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
|
|
@ -109,6 +114,60 @@ func TestMessagesGetFutureChatCreatorAfterLeaveAndCreatorLeaveTransfers(t *testi
|
|||
}
|
||||
}
|
||||
|
||||
func TestLeaveChannelInvalidatesStaleFullChannelProjection(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 9401, Phone: "15550009401", FirstName: "Owner"})
|
||||
member, _ := userStore.Create(ctx, domain.User{AccessHash: 9402, Phone: "15550009402", FirstName: "Member"})
|
||||
channelStore := memory.NewChannelStore()
|
||||
channelService := appchannels.NewService(channelStore)
|
||||
r := New(Config{}, Deps{
|
||||
Users: appusers.NewService(userStore),
|
||||
Channels: channelService,
|
||||
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700009400, 0)})
|
||||
created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner.ID,
|
||||
Title: "leave projection",
|
||||
Megagroup: true,
|
||||
Date: 1700009400,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
if _, err := channelService.UpdateUsername(ctx, owner.ID, domain.UpdateChannelUsernameRequest{
|
||||
ChannelID: created.Channel.ID,
|
||||
Username: "leave_projection_pub",
|
||||
}); err != nil {
|
||||
t.Fatalf("publish channel: %v", err)
|
||||
}
|
||||
inputChannel := &tg.InputChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
|
||||
|
||||
if _, err := r.onChannelsJoinChannel(WithUserID(ctx, member.ID), inputChannel); err != nil {
|
||||
t.Fatalf("member joins: %v", err)
|
||||
}
|
||||
// Warm the channels.getFullChannel projection cache while still a member.
|
||||
full, err := r.onChannelsGetFullChannel(WithUserID(ctx, member.ID), inputChannel)
|
||||
if err != nil {
|
||||
t.Fatalf("full channel while joined: %v", err)
|
||||
}
|
||||
if chat, ok := full.Chats[0].(*tg.Channel); !ok || chat.Left {
|
||||
t.Fatalf("joined full chat = %#v, want member (not left)", full.Chats[0])
|
||||
}
|
||||
|
||||
if _, err := r.onChannelsLeaveChannel(WithUserID(ctx, member.ID), inputChannel); err != nil {
|
||||
t.Fatalf("member leaves: %v", err)
|
||||
}
|
||||
|
||||
after, err := r.onChannelsGetFullChannel(WithUserID(ctx, member.ID), inputChannel)
|
||||
if err != nil {
|
||||
t.Fatalf("full channel after leave: %v", err)
|
||||
}
|
||||
chat, ok := after.Chats[0].(*tg.Channel)
|
||||
if !ok || !chat.Left {
|
||||
t.Fatalf("post-leave full chat = %#v, want left=true (stale projection served)", after.Chats[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesEditChatCreatorTransfersWithoutChannelPts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
|
|
@ -188,6 +247,58 @@ func TestMessagesEditChatCreatorTransfersWithoutChannelPts(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestMessagesEditChatCreatorRequiresPasswordSetup covers an owner who has never
|
||||
// enabled two-step verification: the client's transfer-ownership probe
|
||||
// (inputUserEmpty + inputCheckPasswordEmpty) must get PASSWORD_MISSING, not
|
||||
// PASSWORD_HASH_INVALID -- the desktop client only recognizes PASSWORD_MISSING
|
||||
// to show its "enable 2FA first" box, and otherwise falls through into a real
|
||||
// password-entry flow it can't satisfy.
|
||||
func TestMessagesEditChatCreatorRequiresPasswordSetup(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
owner, err := userStore.Create(ctx, domain.User{AccessHash: 9221, Phone: "15550009221", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
member, err := userStore.Create(ctx, domain.User{AccessHash: 9222, Phone: "15550009222", FirstName: "Member"})
|
||||
if err != nil {
|
||||
t.Fatalf("create member: %v", err)
|
||||
}
|
||||
channelStore := memory.NewChannelStore()
|
||||
channelService := appchannels.NewService(channelStore)
|
||||
r := New(Config{}, Deps{
|
||||
Account: appaccount.NewService(memory.NewPasswordStore()),
|
||||
Users: appusers.NewService(userStore),
|
||||
Channels: channelService,
|
||||
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700009130, 0)})
|
||||
created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner.ID,
|
||||
Title: "no password owner",
|
||||
Megagroup: true,
|
||||
MemberUserIDs: []int64{member.ID},
|
||||
Date: 1700009130,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
ownerCtx := WithUserID(ctx, owner.ID)
|
||||
peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
|
||||
if _, err := r.onMessagesEditChatCreator(ownerCtx, &tg.MessagesEditChatCreatorRequest{
|
||||
Peer: peer,
|
||||
UserID: &tg.InputUserEmpty{},
|
||||
Password: &tg.InputCheckPasswordEmpty{},
|
||||
}); err == nil || !tgerr.Is(err, "PASSWORD_MISSING") {
|
||||
t.Fatalf("editChatCreator probe err = %v, want PASSWORD_MISSING", err)
|
||||
}
|
||||
if _, err := r.onMessagesEditChatCreator(ownerCtx, &tg.MessagesEditChatCreatorRequest{
|
||||
Peer: peer,
|
||||
UserID: &tg.InputUser{UserID: member.ID, AccessHash: member.AccessHash},
|
||||
Password: &tg.InputCheckPasswordSRP{SRPID: 1, A: []byte{1}, M1: []byte{2}},
|
||||
}); err == nil || !tgerr.Is(err, "PASSWORD_MISSING") {
|
||||
t.Fatalf("editChatCreator transfer err = %v, want PASSWORD_MISSING", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesGetFutureChatCreatorAfterLeaveNoCandidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
|
|
|
|||
|
|
@ -339,6 +339,20 @@ func (r *Router) onMessagesEditChatCreator(ctx context.Context, req *tg.Messages
|
|||
if req.Password == nil {
|
||||
return nil, passwordHashInvalidErr()
|
||||
}
|
||||
if r.deps.Account == nil {
|
||||
return nil, passwordHashInvalidErr()
|
||||
}
|
||||
// 转让所有权无条件要求已开启两步验证:先探测账号是否设有密码,
|
||||
// 让 messages.editChatCreator 的探测请求(inputUserEmpty + inputCheckPasswordEmpty)
|
||||
// 拿到 PASSWORD_MISSING 而非 PASSWORD_HASH_INVALID —— 客户端只识别前者来展示
|
||||
// “请先开启两步验证”提示,否则会误入真实密码校验流程并在没有密码可核对时崩溃。
|
||||
passwordSettings, err := r.deps.Account.GetPassword(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !passwordSettings.HasPassword {
|
||||
return nil, passwordMissingErr()
|
||||
}
|
||||
if _, ok := req.UserID.(*tg.InputUserEmpty); ok {
|
||||
return nil, passwordHashInvalidErr()
|
||||
}
|
||||
|
|
@ -355,9 +369,6 @@ func (r *Router) onMessagesEditChatCreator(ctx context.Context, req *tg.Messages
|
|||
if target.Bot {
|
||||
return nil, userIDInvalidErr()
|
||||
}
|
||||
if r.deps.Account == nil {
|
||||
return nil, passwordHashInvalidErr()
|
||||
}
|
||||
if err := r.deps.Account.CheckPassword(ctx, userID, domainPasswordCheck(req.Password)); err != nil {
|
||||
return nil, passwordErr(err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -341,6 +341,7 @@ func (r *Router) onChannelsInviteToChannel(ctx context.Context, req *tg.Channels
|
|||
return nil, channelInviteErr(err)
|
||||
}
|
||||
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
|
||||
r.invalidateChannelMembershipProjection(res.Channel.ID, channelMemberUserIDs(res.Members))
|
||||
r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...)
|
||||
cache := newViewerPeerCache(r)
|
||||
updates := r.channelOperationUpdatesWithPeerCache(ctx, userID, res, cache)
|
||||
|
|
@ -379,6 +380,7 @@ func (r *Router) onChannelsJoinChannel(ctx context.Context, input tg.InputChanne
|
|||
return nil, channelInviteErr(err)
|
||||
}
|
||||
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
|
||||
r.invalidateChannelMembershipProjection(res.Channel.ID, channelMemberUserIDs(res.Members))
|
||||
r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...)
|
||||
updates := r.channelOperationUpdates(ctx, userID, res)
|
||||
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
|
||||
|
|
@ -406,6 +408,11 @@ func (r *Router) onChannelsLeaveChannel(ctx context.Context, input tg.InputChann
|
|||
return nil, channelAdminErr(err)
|
||||
}
|
||||
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
|
||||
membershipChanged := channelMemberUserIDs(res.Members)
|
||||
if len(membershipChanged) == 0 {
|
||||
membershipChanged = []int64{userID}
|
||||
}
|
||||
r.invalidateChannelMembershipProjection(res.Channel.ID, membershipChanged)
|
||||
r.removeOnlineChannelMemberships(res.Channel.ID, userID)
|
||||
r.recordChannelStateForUser(ctx, userID, res.Channel.ID, true)
|
||||
updates := r.channelOperationUpdates(ctx, userID, res)
|
||||
|
|
@ -658,6 +665,7 @@ func (r *Router) onMessagesHideChatJoinRequest(ctx context.Context, req *tg.Mess
|
|||
return nil, channelInviteErr(err)
|
||||
}
|
||||
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
|
||||
r.invalidateChannelMembershipProjection(res.Channel.ID, channelMemberUserIDs(res.Members))
|
||||
r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...)
|
||||
updates := r.channelOperationUpdates(ctx, userID, res)
|
||||
r.appendPendingJoinRequestsUpdate(ctx, userID, updates, res.Channel)
|
||||
|
|
@ -696,6 +704,7 @@ func (r *Router) onMessagesHideAllChatJoinRequests(ctx context.Context, req *tg.
|
|||
return nil, channelInviteErr(err)
|
||||
}
|
||||
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
|
||||
r.invalidateChannelMembershipProjection(res.Channel.ID, channelMemberUserIDs(res.Members))
|
||||
r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...)
|
||||
updates := r.channelOperationUpdates(ctx, userID, res)
|
||||
r.appendPendingJoinRequestsUpdate(ctx, userID, updates, res.Channel)
|
||||
|
|
|
|||
|
|
@ -2,10 +2,6 @@ package rpc
|
|||
|
||||
import (
|
||||
"context"
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/proto"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"go.uber.org/zap/zaptest"
|
||||
"strings"
|
||||
appchannels "telesrv/internal/app/channels"
|
||||
appupdates "telesrv/internal/app/updates"
|
||||
|
|
@ -13,6 +9,11 @@ import (
|
|||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/proto"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"go.uber.org/zap/zaptest"
|
||||
)
|
||||
|
||||
func TestChannelRealtimeRecipientsPreferOnlineMembers(t *testing.T) {
|
||||
|
|
@ -640,6 +641,39 @@ func TestChannelUnreadMentionsRPCUsesMentionState(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// A channel post containing an @token that is not a syntactically valid
|
||||
// username (too short, leading digit, etc.) must still send. Real Telegram
|
||||
// renders it as a mention and only fails when the reader taps it; it never
|
||||
// rejects the send. Regression for a 500 INTERNAL_SERVER_ERROR.
|
||||
func TestChannelSendMessageWithUnresolvableMentionSucceeds(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 9201, Phone: "15550009201", FirstName: "Owner", Username: "owner_badmention"})
|
||||
channelStore := memory.NewChannelStore()
|
||||
channelService := appchannels.NewService(channelStore)
|
||||
r := New(Config{}, Deps{
|
||||
Users: appusers.NewService(userStore),
|
||||
Channels: channelService,
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
created, err := channelService.CreateMegagroupFromCreateChat(ctx, owner.ID, domain.CreateChannelRequest{
|
||||
Title: "Bad Mention",
|
||||
Date: 1700009201,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create megagroup: %v", err)
|
||||
}
|
||||
peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
|
||||
for i, text := range []string{"look at @ziodotsh", "hi @2cool and @_x"} {
|
||||
if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
|
||||
Peer: peer,
|
||||
Message: text,
|
||||
RandomID: int64(9202001 + i),
|
||||
}); err != nil {
|
||||
t.Fatalf("send %q: unexpected error %v", text, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelDifferenceIncludesExtraForwardSourceChannel(t *testing.T) {
|
||||
channel := domain.Channel{ID: 2000000100, AccessHash: 9010, Title: "Megagroup", Megagroup: true, Date: 1700000000, Pts: 3}
|
||||
source := domain.Channel{ID: 2000000101, AccessHash: 9011, Title: "Source", Broadcast: true, Date: 1700000000}
|
||||
|
|
|
|||
|
|
@ -229,34 +229,38 @@ func domainOutgoingReplyMarkupForSender(markup tg.ReplyMarkupClass, senderIsBot
|
|||
}
|
||||
}
|
||||
|
||||
func domainReplyKeyboardButton(button tg.KeyboardButtonClass) (domain.MarkupButton, error) {
|
||||
style, icon, err := domainMarkupButtonStyle(button)
|
||||
func domainReplyKeyboardButton(button tg.KeyboardButton) (domain.MarkupButton, error) {
|
||||
style, icon, err := domainMarkupButtonStyle(button.GetStyle())
|
||||
if err != nil {
|
||||
return domain.MarkupButton{}, err
|
||||
}
|
||||
base := domain.MarkupButton{Style: style, IconCustomEmojiID: icon}
|
||||
switch b := button.(type) {
|
||||
case *tg.KeyboardButton:
|
||||
base.Type, base.Text = domain.MarkupButtonText, b.Text
|
||||
case *tg.KeyboardButtonRequestPhone:
|
||||
base.Type, base.Text = domain.MarkupButtonRequestPhone, b.Text
|
||||
case *tg.KeyboardButtonRequestGeoLocation:
|
||||
base.Type, base.Text = domain.MarkupButtonRequestLocation, b.Text
|
||||
case *tg.KeyboardButtonRequestPoll:
|
||||
base.Type, base.Text = domain.MarkupButtonRequestPoll, b.Text
|
||||
if quiz, ok := b.GetQuiz(); ok {
|
||||
base := domain.MarkupButton{Style: style, IconCustomEmojiID: icon, Text: button.Text}
|
||||
switch t := button.Type.(type) {
|
||||
case nil, *tg.ButtonTypeDefault:
|
||||
base.Type = domain.MarkupButtonText
|
||||
case *tg.ButtonTypeRequestPhone:
|
||||
base.Type = domain.MarkupButtonRequestPhone
|
||||
case *tg.ButtonTypeRequestGeoLocation:
|
||||
base.Type = domain.MarkupButtonRequestLocation
|
||||
case *tg.ButtonTypeRequestPoll:
|
||||
base.Type = domain.MarkupButtonRequestPoll
|
||||
if quiz, ok := t.GetQuiz(); ok {
|
||||
if quiz {
|
||||
base.PollType = "quiz"
|
||||
} else {
|
||||
base.PollType = "regular"
|
||||
}
|
||||
}
|
||||
case *tg.KeyboardButtonRequestPeer:
|
||||
base.Type, base.Text = domain.MarkupButtonRequestPeer, b.Text
|
||||
base.ButtonID, base.MaxQuantity = b.ButtonID, b.MaxQuantity
|
||||
base.RequestPeerType, base.RequestPeerFilter = domainRequestPeerFilter(b.PeerType)
|
||||
case *tg.KeyboardButtonSimpleWebView:
|
||||
base.Type, base.Text, base.URL = domain.MarkupButtonSimpleWebView, b.Text, b.URL
|
||||
case *tg.ButtonTypeRequestPeer:
|
||||
base.Type = domain.MarkupButtonRequestPeer
|
||||
base.ButtonID, base.MaxQuantity = t.ButtonID, t.MaxQuantity
|
||||
base.RequestPeerType, base.RequestPeerFilter = domainRequestPeerFilter(t.PeerType)
|
||||
case *tg.InputButtonTypeRequestPeer:
|
||||
base.Type = domain.MarkupButtonRequestPeer
|
||||
base.ButtonID, base.MaxQuantity = t.ButtonID, t.MaxQuantity
|
||||
base.RequestPeerType, base.RequestPeerFilter = domainRequestPeerFilter(t.PeerType)
|
||||
case *tg.ButtonTypeSimpleWebView:
|
||||
base.Type, base.URL = domain.MarkupButtonSimpleWebView, t.URL
|
||||
default:
|
||||
return domain.MarkupButton{}, domain.ErrButtonTypeInvalid
|
||||
}
|
||||
|
|
@ -281,29 +285,29 @@ func domainInlineMarkup(inline *tg.ReplyInlineMarkup) (*domain.MessageReplyMarku
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func domainMarkupButton(btn tg.KeyboardButtonClass, buttonID int) (domain.MarkupButton, error) {
|
||||
style, icon, err := domainMarkupButtonStyle(btn)
|
||||
func domainMarkupButton(btn tg.KeyboardInlineButton, buttonID int) (domain.MarkupButton, error) {
|
||||
style, icon, err := domainMarkupButtonStyle(btn.GetStyle())
|
||||
if err != nil {
|
||||
return domain.MarkupButton{}, err
|
||||
}
|
||||
switch b := btn.(type) {
|
||||
case *tg.KeyboardButtonCallback:
|
||||
switch t := btn.Type.(type) {
|
||||
case *tg.InlineButtonTypeCallback:
|
||||
return domain.MarkupButton{
|
||||
Type: domain.MarkupButtonCallback,
|
||||
Text: b.Text,
|
||||
Text: btn.Text,
|
||||
Style: style,
|
||||
IconCustomEmojiID: icon,
|
||||
Data: append([]byte(nil), b.Data...),
|
||||
RequiresPassword: b.RequiresPassword,
|
||||
Data: append([]byte(nil), t.Data...),
|
||||
RequiresPassword: t.RequiresPassword,
|
||||
}, nil
|
||||
case *tg.KeyboardButtonURL:
|
||||
case *tg.InlineButtonTypeURL:
|
||||
return domain.MarkupButton{
|
||||
Type: domain.MarkupButtonURL, Text: b.Text, URL: b.URL,
|
||||
Type: domain.MarkupButtonURL, Text: btn.Text, URL: t.URL,
|
||||
Style: style, IconCustomEmojiID: icon,
|
||||
}, nil
|
||||
case *tg.InputKeyboardButtonURLAuth:
|
||||
case *tg.InputInlineButtonTypeURLAuth:
|
||||
botUserID := int64(0)
|
||||
switch bot := b.Bot.(type) {
|
||||
switch bot := t.Bot.(type) {
|
||||
case nil, *tg.InputUserEmpty, *tg.InputUserSelf:
|
||||
case *tg.InputUser:
|
||||
botUserID = bot.UserID
|
||||
|
|
@ -311,34 +315,33 @@ func domainMarkupButton(btn tg.KeyboardButtonClass, buttonID int) (domain.Markup
|
|||
return domain.MarkupButton{}, domain.ErrButtonInvalid
|
||||
}
|
||||
return domain.MarkupButton{
|
||||
Type: domain.MarkupButtonLoginURL, Text: b.Text, URL: b.URL,
|
||||
ForwardText: b.FwdText, ButtonID: buttonID, LoginBotUserID: botUserID,
|
||||
RequestWriteAccess: b.RequestWriteAccess, Style: style, IconCustomEmojiID: icon,
|
||||
Type: domain.MarkupButtonLoginURL, Text: btn.Text, URL: t.URL,
|
||||
ForwardText: t.FwdText, ButtonID: buttonID, LoginBotUserID: botUserID,
|
||||
RequestWriteAccess: t.RequestWriteAccess, Style: style, IconCustomEmojiID: icon,
|
||||
}, nil
|
||||
case *tg.KeyboardButtonURLAuth:
|
||||
case *tg.InlineButtonTypeURLAuth:
|
||||
return domain.MarkupButton{
|
||||
Type: domain.MarkupButtonLoginURL, Text: b.Text, URL: b.URL,
|
||||
ForwardText: b.FwdText, ButtonID: b.ButtonID,
|
||||
Type: domain.MarkupButtonLoginURL, Text: btn.Text, URL: t.URL,
|
||||
ForwardText: t.FwdText, ButtonID: t.ButtonID,
|
||||
Style: style, IconCustomEmojiID: icon,
|
||||
}, nil
|
||||
case *tg.KeyboardButtonWebView:
|
||||
return domain.MarkupButton{Type: domain.MarkupButtonWebView, Text: b.Text, URL: b.URL, Style: style, IconCustomEmojiID: icon}, nil
|
||||
case *tg.KeyboardButtonSwitchInline:
|
||||
peerTypes, err := preparedInlinePeerTypesFromTG(b.PeerTypes)
|
||||
case *tg.InlineButtonTypeWebView:
|
||||
return domain.MarkupButton{Type: domain.MarkupButtonWebView, Text: btn.Text, URL: t.URL, Style: style, IconCustomEmojiID: icon}, nil
|
||||
case *tg.InlineButtonTypeSwitchInline:
|
||||
peerTypes, err := preparedInlinePeerTypesFromTG(t.PeerTypes)
|
||||
if err != nil {
|
||||
return domain.MarkupButton{}, domain.ErrButtonInvalid
|
||||
}
|
||||
return domain.MarkupButton{Type: domain.MarkupButtonSwitchInline, Text: b.Text, Query: b.Query, SamePeer: b.SamePeer, PeerTypes: peerTypes, Style: style, IconCustomEmojiID: icon}, nil
|
||||
case *tg.KeyboardButtonCopy:
|
||||
return domain.MarkupButton{Type: domain.MarkupButtonCopy, Text: b.Text, CopyText: b.CopyText, Style: style, IconCustomEmojiID: icon}, nil
|
||||
return domain.MarkupButton{Type: domain.MarkupButtonSwitchInline, Text: btn.Text, Query: t.Query, SamePeer: t.SamePeer, PeerTypes: peerTypes, Style: style, IconCustomEmojiID: icon}, nil
|
||||
case *tg.InlineButtonTypeCopy:
|
||||
return domain.MarkupButton{Type: domain.MarkupButtonCopy, Text: btn.Text, CopyText: t.CopyText, Style: style, IconCustomEmojiID: icon}, nil
|
||||
default:
|
||||
// webview/game/url_auth/request_*/switch_inline/buy 等 P3 未实现按钮类型。
|
||||
return domain.MarkupButton{}, domain.ErrButtonTypeInvalid
|
||||
}
|
||||
}
|
||||
|
||||
func domainMarkupButtonStyle(btn tg.KeyboardButtonClass) (domain.MarkupButtonStyle, int64, error) {
|
||||
style, ok := btn.GetStyle()
|
||||
func domainMarkupButtonStyle(style tg.KeyboardButtonStyle, ok bool) (domain.MarkupButtonStyle, int64, error) {
|
||||
if !ok {
|
||||
return "", 0, nil
|
||||
}
|
||||
|
|
@ -388,7 +391,7 @@ func tgReplyMarkup(m *domain.MessageReplyMarkup) tg.ReplyMarkupClass {
|
|||
case domain.MessageReplyMarkupKeyboard:
|
||||
rows := make([]tg.KeyboardButtonRow, 0, len(m.Keyboard))
|
||||
for _, row := range m.Keyboard {
|
||||
buttons := make([]tg.KeyboardButtonClass, 0, len(row))
|
||||
buttons := make([]tg.KeyboardButton, 0, len(row))
|
||||
for _, btn := range row {
|
||||
buttons = append(buttons, tgReplyKeyboardButton(btn))
|
||||
}
|
||||
|
|
@ -415,93 +418,75 @@ func tgReplyMarkup(m *domain.MessageReplyMarkup) tg.ReplyMarkupClass {
|
|||
default:
|
||||
return nil
|
||||
}
|
||||
rows := make([]tg.KeyboardButtonRow, 0, len(m.Inline))
|
||||
rows := make([]tg.KeyboardInlineButtonRow, 0, len(m.Inline))
|
||||
for _, row := range m.Inline {
|
||||
buttons := make([]tg.KeyboardButtonClass, 0, len(row))
|
||||
buttons := make([]tg.KeyboardInlineButton, 0, len(row))
|
||||
for _, btn := range row {
|
||||
buttons = append(buttons, tgMarkupButton(btn))
|
||||
}
|
||||
rows = append(rows, tg.KeyboardButtonRow{Buttons: buttons})
|
||||
rows = append(rows, tg.KeyboardInlineButtonRow{Buttons: buttons})
|
||||
}
|
||||
return &tg.ReplyInlineMarkup{Rows: rows}
|
||||
}
|
||||
|
||||
func tgMarkupButton(btn domain.MarkupButton) tg.KeyboardButtonClass {
|
||||
func tgMarkupButton(btn domain.MarkupButton) tg.KeyboardInlineButton {
|
||||
out := tg.KeyboardInlineButton{Text: btn.Text}
|
||||
if style, ok := tgMarkupButtonStyle(btn); ok {
|
||||
out.SetStyle(style)
|
||||
}
|
||||
switch btn.Type {
|
||||
case domain.MarkupButtonURL:
|
||||
out := &tg.KeyboardButtonURL{Text: btn.Text, URL: btn.URL}
|
||||
if style, ok := tgMarkupButtonStyle(btn); ok {
|
||||
out.SetStyle(style)
|
||||
}
|
||||
return out
|
||||
out.Type = &tg.InlineButtonTypeURL{URL: btn.URL}
|
||||
case domain.MarkupButtonLoginURL:
|
||||
out := &tg.KeyboardButtonURLAuth{Text: btn.Text, URL: btn.URL, ButtonID: btn.ButtonID}
|
||||
t := &tg.InlineButtonTypeURLAuth{URL: btn.URL, ButtonID: btn.ButtonID}
|
||||
if btn.ForwardText != "" {
|
||||
out.SetFwdText(btn.ForwardText)
|
||||
t.SetFwdText(btn.ForwardText)
|
||||
}
|
||||
if style, ok := tgMarkupButtonStyle(btn); ok {
|
||||
out.SetStyle(style)
|
||||
}
|
||||
return out
|
||||
out.Type = t
|
||||
case domain.MarkupButtonWebView:
|
||||
out := &tg.KeyboardButtonWebView{Text: btn.Text, URL: btn.URL}
|
||||
if style, ok := tgMarkupButtonStyle(btn); ok {
|
||||
out.SetStyle(style)
|
||||
}
|
||||
return out
|
||||
out.Type = &tg.InlineButtonTypeWebView{URL: btn.URL}
|
||||
case domain.MarkupButtonSwitchInline:
|
||||
out := &tg.KeyboardButtonSwitchInline{Text: btn.Text, Query: btn.Query, SamePeer: btn.SamePeer}
|
||||
t := &tg.InlineButtonTypeSwitchInline{Query: btn.Query, SamePeer: btn.SamePeer}
|
||||
if len(btn.PeerTypes) > 0 {
|
||||
out.SetPeerTypes(tgPreparedInlinePeerTypes(btn.PeerTypes))
|
||||
t.SetPeerTypes(tgPreparedInlinePeerTypes(btn.PeerTypes))
|
||||
}
|
||||
if style, ok := tgMarkupButtonStyle(btn); ok {
|
||||
out.SetStyle(style)
|
||||
}
|
||||
return out
|
||||
out.Type = t
|
||||
case domain.MarkupButtonCopy:
|
||||
out := &tg.KeyboardButtonCopy{Text: btn.Text, CopyText: btn.CopyText}
|
||||
if style, ok := tgMarkupButtonStyle(btn); ok {
|
||||
out.SetStyle(style)
|
||||
}
|
||||
return out
|
||||
out.Type = &tg.InlineButtonTypeCopy{CopyText: btn.CopyText}
|
||||
default: // callback
|
||||
out := &tg.KeyboardButtonCallback{Text: btn.Text, Data: btn.Data}
|
||||
t := &tg.InlineButtonTypeCallback{Data: btn.Data}
|
||||
if btn.RequiresPassword {
|
||||
out.SetRequiresPassword(true)
|
||||
t.SetRequiresPassword(true)
|
||||
}
|
||||
if style, ok := tgMarkupButtonStyle(btn); ok {
|
||||
out.SetStyle(style)
|
||||
out.Type = t
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
func tgReplyKeyboardButton(btn domain.MarkupButton) tg.KeyboardButtonClass {
|
||||
var out tg.KeyboardButtonClass
|
||||
func tgReplyKeyboardButton(btn domain.MarkupButton) tg.KeyboardButton {
|
||||
out := tg.KeyboardButton{Text: btn.Text}
|
||||
switch btn.Type {
|
||||
case domain.MarkupButtonRequestPhone:
|
||||
out = &tg.KeyboardButtonRequestPhone{Text: btn.Text}
|
||||
out.Type = &tg.ButtonTypeRequestPhone{}
|
||||
case domain.MarkupButtonRequestLocation:
|
||||
out = &tg.KeyboardButtonRequestGeoLocation{Text: btn.Text}
|
||||
out.Type = &tg.ButtonTypeRequestGeoLocation{}
|
||||
case domain.MarkupButtonRequestPoll:
|
||||
button := &tg.KeyboardButtonRequestPoll{Text: btn.Text}
|
||||
t := &tg.ButtonTypeRequestPoll{}
|
||||
if btn.PollType == "quiz" {
|
||||
button.SetQuiz(true)
|
||||
t.SetQuiz(true)
|
||||
} else if btn.PollType == "regular" {
|
||||
button.SetQuiz(false)
|
||||
t.SetQuiz(false)
|
||||
}
|
||||
out = button
|
||||
out.Type = t
|
||||
case domain.MarkupButtonRequestPeer:
|
||||
out = &tg.KeyboardButtonRequestPeer{Text: btn.Text, ButtonID: btn.ButtonID, PeerType: tgRequestPeerTypeWithFilter(btn.RequestPeerType, btn.RequestPeerFilter), MaxQuantity: btn.MaxQuantity}
|
||||
out.Type = &tg.ButtonTypeRequestPeer{ButtonID: btn.ButtonID, PeerType: tgRequestPeerTypeWithFilter(btn.RequestPeerType, btn.RequestPeerFilter), MaxQuantity: btn.MaxQuantity}
|
||||
case domain.MarkupButtonSimpleWebView:
|
||||
out = &tg.KeyboardButtonSimpleWebView{Text: btn.Text, URL: btn.URL}
|
||||
out.Type = &tg.ButtonTypeSimpleWebView{URL: btn.URL}
|
||||
default:
|
||||
out = &tg.KeyboardButton{Text: btn.Text}
|
||||
out.Type = &tg.ButtonTypeDefault{}
|
||||
}
|
||||
if style, ok := tgMarkupButtonStyle(btn); ok {
|
||||
if setter, ok := out.(interface{ SetStyle(tg.KeyboardButtonStyle) }); ok {
|
||||
setter.SetStyle(style)
|
||||
}
|
||||
out.SetStyle(style)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,22 +9,20 @@ import (
|
|||
)
|
||||
|
||||
func TestReplyKeyboardTLDomainRoundTrip(t *testing.T) {
|
||||
statusButton := tg.KeyboardButton{Text: "Status"}
|
||||
style := tg.KeyboardButtonStyle{}
|
||||
style.SetBgPrimary(true)
|
||||
style.SetIcon(123456)
|
||||
statusButton.SetStyle(style)
|
||||
in := &tg.ReplyKeyboardMarkup{
|
||||
Resize: true,
|
||||
SingleUse: true,
|
||||
Selective: true,
|
||||
Persistent: true,
|
||||
Placeholder: "Choose",
|
||||
Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{
|
||||
&tg.KeyboardButton{Text: "Help"},
|
||||
func() *tg.KeyboardButton {
|
||||
button := &tg.KeyboardButton{Text: "Status"}
|
||||
style := tg.KeyboardButtonStyle{}
|
||||
style.SetBgPrimary(true)
|
||||
style.SetIcon(123456)
|
||||
button.SetStyle(style)
|
||||
return button
|
||||
}(),
|
||||
Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButton{
|
||||
{Text: "Help"},
|
||||
statusButton,
|
||||
}}},
|
||||
}
|
||||
got, err := domainOutgoingReplyMarkupForSender(in, true)
|
||||
|
|
@ -43,8 +41,9 @@ func TestReplyKeyboardTLDomainRoundTrip(t *testing.T) {
|
|||
if !ok || len(wire.Rows) != 1 || len(wire.Rows[0].Buttons) != 2 {
|
||||
t.Fatalf("wire markup = %#v", wire)
|
||||
}
|
||||
if button, ok := wire.Rows[0].Buttons[1].(*tg.KeyboardButton); !ok || button.Text != "Status" {
|
||||
t.Fatalf("second button = %#v", wire.Rows[0].Buttons[1])
|
||||
button := wire.Rows[0].Buttons[1]
|
||||
if button.Text != "Status" {
|
||||
t.Fatalf("second button = %#v", button)
|
||||
} else if style, ok := button.GetStyle(); !ok || !style.GetBgPrimary() || style.Icon != 123456 {
|
||||
t.Fatalf("second button style = %#v ok=%v", style, ok)
|
||||
}
|
||||
|
|
@ -54,30 +53,32 @@ func TestReplyKeyboardTLDomainRoundTrip(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestInlineButtonStyleTLDomainRoundTrip(t *testing.T) {
|
||||
button := &tg.KeyboardButtonCallback{Text: "Delete", Data: []byte("delete")}
|
||||
button := tg.KeyboardInlineButton{Text: "Delete", Type: &tg.InlineButtonTypeCallback{Data: []byte("delete")}}
|
||||
style := tg.KeyboardButtonStyle{}
|
||||
style.SetBgDanger(true)
|
||||
button.SetStyle(style)
|
||||
got, err := domainReplyMarkupForSender(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{button}}}}, true)
|
||||
got, err := domainReplyMarkupForSender(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardInlineButtonRow{{Buttons: []tg.KeyboardInlineButton{button}}}}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("domainReplyMarkupForSender: %v", err)
|
||||
}
|
||||
if got.Inline[0][0].Style != domain.MarkupButtonStyleDanger {
|
||||
t.Fatalf("domain style = %#v", got.Inline[0][0])
|
||||
}
|
||||
wire := tgReplyMarkup(got).(*tg.ReplyInlineMarkup).Rows[0].Buttons[0].(*tg.KeyboardButtonCallback)
|
||||
wire := tgReplyMarkup(got).(*tg.ReplyInlineMarkup).Rows[0].Buttons[0]
|
||||
if roundTrip, ok := wire.GetStyle(); !ok || !roundTrip.GetBgDanger() {
|
||||
t.Fatalf("wire style = %#v ok=%v", roundTrip, ok)
|
||||
}
|
||||
if _, ok := wire.Type.(*tg.InlineButtonTypeCallback); !ok {
|
||||
t.Fatalf("wire type = %#v", wire.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginURLButtonTLDomainProjection(t *testing.T) {
|
||||
button := &tg.InputKeyboardButtonURLAuth{
|
||||
Text: "Log in", URL: "https://example.com/login", Bot: &tg.InputUser{UserID: 9001, AccessHash: 77},
|
||||
}
|
||||
button.SetRequestWriteAccess(true)
|
||||
button.SetFwdText("Open login")
|
||||
markup, err := domainReplyMarkupForSender(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{button}}}}, true)
|
||||
buttonType := &tg.InputInlineButtonTypeURLAuth{URL: "https://example.com/login", Bot: &tg.InputUser{UserID: 9001, AccessHash: 77}}
|
||||
buttonType.SetRequestWriteAccess(true)
|
||||
buttonType.SetFwdText("Open login")
|
||||
button := tg.KeyboardInlineButton{Text: "Log in", Type: buttonType}
|
||||
markup, err := domainReplyMarkupForSender(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardInlineButtonRow{{Buttons: []tg.KeyboardInlineButton{button}}}}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
@ -85,8 +86,9 @@ func TestLoginURLButtonTLDomainProjection(t *testing.T) {
|
|||
if got.Type != domain.MarkupButtonLoginURL || got.LoginBotUserID != 9001 || !got.RequestWriteAccess || got.ForwardText != "Open login" || got.ButtonID != 0 {
|
||||
t.Fatalf("domain login_url = %#v", got)
|
||||
}
|
||||
wire, ok := tgReplyMarkup(markup).(*tg.ReplyInlineMarkup).Rows[0].Buttons[0].(*tg.KeyboardButtonURLAuth)
|
||||
if !ok || wire.Text != "Log in" || wire.URL != "https://example.com/login" || wire.ButtonID != 0 || wire.FwdText != "Open login" {
|
||||
wire := tgReplyMarkup(markup).(*tg.ReplyInlineMarkup).Rows[0].Buttons[0]
|
||||
wireType, ok := wire.Type.(*tg.InlineButtonTypeURLAuth)
|
||||
if !ok || wire.Text != "Log in" || wireType.URL != "https://example.com/login" || wireType.ButtonID != 0 || wireType.FwdText != "Open login" {
|
||||
t.Fatalf("wire login_url = %#v", wire)
|
||||
}
|
||||
}
|
||||
|
|
@ -112,7 +114,7 @@ func TestReplyKeyboardHideAndForceReplyTLDomainRoundTrip(t *testing.T) {
|
|||
|
||||
func TestReplyKeyboardRequestPhoneTLDomainRoundTrip(t *testing.T) {
|
||||
markup, err := domainOutgoingReplyMarkupForSender(&tg.ReplyKeyboardMarkup{Rows: []tg.KeyboardButtonRow{{
|
||||
Buttons: []tg.KeyboardButtonClass{&tg.KeyboardButtonRequestPhone{Text: "Share phone"}},
|
||||
Buttons: []tg.KeyboardButton{{Text: "Share phone", Type: &tg.ButtonTypeRequestPhone{}}},
|
||||
}}}, true)
|
||||
if err != nil || markup == nil || len(markup.Keyboard) != 1 || len(markup.Keyboard[0]) != 1 ||
|
||||
markup.Keyboard[0][0].Type != domain.MarkupButtonRequestPhone {
|
||||
|
|
@ -122,7 +124,7 @@ func TestReplyKeyboardRequestPhoneTLDomainRoundTrip(t *testing.T) {
|
|||
if !ok || len(wire.Rows) != 1 || len(wire.Rows[0].Buttons) != 1 {
|
||||
t.Fatalf("request_phone wire = %#v", wire)
|
||||
}
|
||||
if _, ok := wire.Rows[0].Buttons[0].(*tg.KeyboardButtonRequestPhone); !ok {
|
||||
if _, ok := wire.Rows[0].Buttons[0].Type.(*tg.ButtonTypeRequestPhone); !ok {
|
||||
t.Fatalf("request_phone button = %#v", wire.Rows[0].Buttons[0])
|
||||
}
|
||||
if _, err := domainReplyMarkupForSender(&tg.ReplyKeyboardHide{}, true); err == nil {
|
||||
|
|
@ -138,9 +140,9 @@ func TestReplyKeyboardRequestPeerFiltersTLDomainRoundTrip(t *testing.T) {
|
|||
chatType.SetHasUsername(false)
|
||||
chatType.SetForum(true)
|
||||
chatType.SetUserAdminRights(tg.ChatAdminRights{DeleteMessages: true, ManageTopics: true})
|
||||
in := &tg.ReplyKeyboardMarkup{Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{
|
||||
&tg.KeyboardButtonRequestPeer{Text: "Premium person", ButtonID: 1, PeerType: userType, MaxQuantity: 2},
|
||||
&tg.KeyboardButtonRequestPeer{Text: "Forum", ButtonID: 2, PeerType: chatType, MaxQuantity: 1},
|
||||
in := &tg.ReplyKeyboardMarkup{Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButton{
|
||||
{Text: "Premium person", Type: &tg.ButtonTypeRequestPeer{ButtonID: 1, PeerType: userType, MaxQuantity: 2}},
|
||||
{Text: "Forum", Type: &tg.ButtonTypeRequestPeer{ButtonID: 2, PeerType: chatType, MaxQuantity: 1}},
|
||||
}}}}
|
||||
markup, err := domainOutgoingReplyMarkupForSender(in, true)
|
||||
if err != nil {
|
||||
|
|
@ -157,14 +159,14 @@ func TestReplyKeyboardRequestPeerFiltersTLDomainRoundTrip(t *testing.T) {
|
|||
t.Fatalf("chat filter = %#v", chatFilter)
|
||||
}
|
||||
wire := tgReplyMarkup(markup).(*tg.ReplyKeyboardMarkup)
|
||||
wireUser := wire.Rows[0].Buttons[0].(*tg.KeyboardButtonRequestPeer).PeerType.(*tg.RequestPeerTypeUser)
|
||||
wireUser := wire.Rows[0].Buttons[0].Type.(*tg.ButtonTypeRequestPeer).PeerType.(*tg.RequestPeerTypeUser)
|
||||
if bot, ok := wireUser.GetBot(); !ok || bot {
|
||||
t.Fatalf("wire user bot=%v ok=%v", bot, ok)
|
||||
}
|
||||
if premium, ok := wireUser.GetPremium(); !ok || !premium {
|
||||
t.Fatalf("wire user premium=%v ok=%v", premium, ok)
|
||||
}
|
||||
wireChat := wire.Rows[0].Buttons[1].(*tg.KeyboardButtonRequestPeer).PeerType.(*tg.RequestPeerTypeChat)
|
||||
wireChat := wire.Rows[0].Buttons[1].Type.(*tg.ButtonTypeRequestPeer).PeerType.(*tg.RequestPeerTypeChat)
|
||||
if !wireChat.Creator || !wireChat.BotParticipant {
|
||||
t.Fatalf("wire chat = %#v", wireChat)
|
||||
}
|
||||
|
|
@ -177,9 +179,12 @@ func TestReplyKeyboardRequestPeerFiltersTLDomainRoundTrip(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestInputRequestPeerButtonPreservesRequestedMetadata(t *testing.T) {
|
||||
button := &tg.InputKeyboardButtonRequestPeer{
|
||||
button := tg.KeyboardButton{
|
||||
Text: "Share",
|
||||
Type: &tg.InputButtonTypeRequestPeer{
|
||||
NameRequested: true, UsernameRequested: true, PhotoRequested: true,
|
||||
Text: "Share", ButtonID: 99, PeerType: &tg.RequestPeerTypeUser{}, MaxQuantity: 3,
|
||||
ButtonID: 99, PeerType: &tg.RequestPeerTypeUser{}, MaxQuantity: 3,
|
||||
},
|
||||
}
|
||||
got, err := domainRequestedButtonFromTG(1001, nil, button)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -367,6 +367,11 @@ func tgPhotoSizes(sizes []domain.PhotoSize) []tg.PhotoSizeClass {
|
|||
func tgPhotoSize(s domain.PhotoSize) tg.PhotoSizeClass {
|
||||
switch s.Kind {
|
||||
case domain.PhotoSizeKindDefault:
|
||||
if s.W <= 0 || s.H <= 0 {
|
||||
// A zero-dimension photoSize is malformed and divides to a crash on
|
||||
// the client; dropping it lets clients fall back to another size.
|
||||
return nil
|
||||
}
|
||||
return &tg.PhotoSize{Type: s.Type, W: s.W, H: s.H, Size: s.Size}
|
||||
case domain.PhotoSizeKindStripped:
|
||||
return &tg.PhotoStrippedSize{Type: s.Type, Bytes: s.Bytes}
|
||||
|
|
@ -464,12 +469,47 @@ func compactPhotoSizeClasses(in []tg.PhotoSizeClass) []tg.PhotoSizeClass {
|
|||
return out
|
||||
}
|
||||
|
||||
// stickerCanvasSize is the square canvas a sticker / custom emoji is laid out on.
|
||||
// It is the fallback dimension when stored metadata carries a zero, which clients
|
||||
// divide by while sizing the render (a 0 there crashes Telegram Desktop).
|
||||
const stickerCanvasSize = 512
|
||||
|
||||
func stickerLikeMime(mimeType string) bool {
|
||||
switch mimeType {
|
||||
case mimeApplicationXTGSticker, "image/webp", "video/webm":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// safeMediaDimension replaces a non-positive width/height with a usable value:
|
||||
// the sticker canvas for sticker-like documents, otherwise 1, so no projected
|
||||
// attribute ever carries a zero a client would divide by.
|
||||
func safeMediaDimension(v int, mimeType string) int {
|
||||
if v > 0 {
|
||||
return v
|
||||
}
|
||||
if stickerLikeMime(mimeType) {
|
||||
return stickerCanvasSize
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func tgDocumentAttributes(mimeType string, attrs []domain.DocumentAttribute) []tg.DocumentAttributeClass {
|
||||
out := make([]tg.DocumentAttributeClass, 0, len(attrs))
|
||||
for _, a := range attrs {
|
||||
switch a.Kind {
|
||||
case domain.DocAttrImageSize:
|
||||
out = append(out, &tg.DocumentAttributeImageSize{W: a.W, H: a.H})
|
||||
if (a.W <= 0 || a.H <= 0) && !stickerLikeMime(mimeType) {
|
||||
// Malformed size on a plain image: drop it rather than emit a zero.
|
||||
// Clients cope with a missing imageSize; a zero one crashes them.
|
||||
continue
|
||||
}
|
||||
out = append(out, &tg.DocumentAttributeImageSize{
|
||||
W: safeMediaDimension(a.W, mimeType),
|
||||
H: safeMediaDimension(a.H, mimeType),
|
||||
})
|
||||
case domain.DocAttrAnimated:
|
||||
if mimeType == mimeApplicationXTGSticker {
|
||||
continue
|
||||
|
|
@ -487,8 +527,8 @@ func tgDocumentAttributes(mimeType string, attrs []domain.DocumentAttribute) []t
|
|||
SupportsStreaming: a.SupportsStreaming,
|
||||
Nosound: a.NoSound,
|
||||
Duration: a.Duration,
|
||||
W: a.W,
|
||||
H: a.H,
|
||||
W: safeMediaDimension(a.W, mimeType),
|
||||
H: safeMediaDimension(a.H, mimeType),
|
||||
}
|
||||
if a.VideoCodec != "" {
|
||||
video.SetVideoCodec(a.VideoCodec)
|
||||
|
|
|
|||
|
|
@ -291,6 +291,7 @@ func scoreInvalidErr() error { return tgerr.New(400, "SCORE_INVALID") }
|
|||
|
||||
func sessionPasswordNeededErr() error { return tgerr.New(401, "SESSION_PASSWORD_NEEDED") }
|
||||
func passwordHashInvalidErr() error { return tgerr.New(400, "PASSWORD_HASH_INVALID") }
|
||||
func passwordMissingErr() error { return tgerr.New(400, "PASSWORD_MISSING") }
|
||||
func srpIDInvalidErr() error { return tgerr.New(400, "SRP_ID_INVALID") }
|
||||
func srpPasswordChangedErr() error { return tgerr.New(400, "SRP_PASSWORD_CHANGED") }
|
||||
func newSettingsInvalidErr() error { return tgerr.New(400, "NEW_SETTINGS_INVALID") }
|
||||
|
|
@ -468,6 +469,8 @@ func passwordErr(err error) error {
|
|||
switch {
|
||||
case errors.Is(err, domain.ErrPasswordHashInvalid):
|
||||
return passwordHashInvalidErr()
|
||||
case errors.Is(err, domain.ErrPasswordMissing):
|
||||
return passwordMissingErr()
|
||||
case errors.Is(err, domain.ErrSRPIDInvalid):
|
||||
return srpIDInvalidErr()
|
||||
case errors.Is(err, domain.ErrSRPPasswordChanged):
|
||||
|
|
|
|||
120
internal/rpc/forum_reply_topic_rpc_test.go
Normal file
120
internal/rpc/forum_reply_topic_rpc_test.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appchannels "telesrv/internal/app/channels"
|
||||
appusers "telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// A reply inside a forum must inherit the *target's* topic, never the target's
|
||||
// own message id. Regression: replying to a General message produced
|
||||
// reply_to_top_id = <that message's id>, a topic that no client can resolve, so
|
||||
// the reply vanished from every topic view and reply-jump said "doesn't exist".
|
||||
func TestForumReplyInheritsTargetTopic(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 91, Phone: "15550009101", FirstName: "Owner"})
|
||||
channelStore := memory.NewChannelStore()
|
||||
r := New(Config{}, Deps{
|
||||
Users: appusers.NewService(userStore),
|
||||
Channels: appchannels.NewService(channelStore),
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
ownerCtx := WithUserID(ctx, owner.ID)
|
||||
|
||||
created, err := r.onChannelsCreateChannel(ownerCtx, &tg.ChannelsCreateChannelRequest{Title: "Forum", Megagroup: true})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
channel := created.(*tg.Updates).Chats[0].(*tg.Channel)
|
||||
input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
|
||||
peer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
|
||||
if _, err := r.onChannelsToggleForum(ownerCtx, &tg.ChannelsToggleForumRequest{Channel: input, Enabled: true, Tabs: true}); err != nil {
|
||||
t.Fatalf("toggle forum: %v", err)
|
||||
}
|
||||
topicUpd, err := r.onMessagesCreateForumTopic(ownerCtx, &tg.MessagesCreateForumTopicRequest{
|
||||
Peer: peer, Title: "Test", IconColor: domain.DefaultForumTopicIconColor, RandomID: 9101001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create topic: %v", err)
|
||||
}
|
||||
testTopicID := forumTopicRootMessageID(t, topicUpd, "Test")
|
||||
|
||||
send := func(text string, randomID int64, reply *tg.InputReplyToMessage) *tg.Message {
|
||||
req := &tg.MessagesSendMessageRequest{Peer: peer, Message: text, RandomID: randomID}
|
||||
if reply != nil {
|
||||
req.SetReplyTo(reply)
|
||||
}
|
||||
upd, err := r.onMessagesSendMessage(ownerCtx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("send %q: %v", text, err)
|
||||
}
|
||||
for _, u := range upd.(*tg.Updates).Updates {
|
||||
if nm, ok := u.(*tg.UpdateNewChannelMessage); ok {
|
||||
if m, ok := nm.Message.(*tg.Message); ok && m.Message == text {
|
||||
return m
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("no new message for %q in %+v", text, upd)
|
||||
return nil
|
||||
}
|
||||
topID := func(m *tg.Message) int {
|
||||
h, ok := m.ReplyTo.(*tg.MessageReplyHeader)
|
||||
if !ok {
|
||||
t.Fatalf("message %d has reply header %T, want *MessageReplyHeader", m.ID, m.ReplyTo)
|
||||
}
|
||||
id, _ := h.GetReplyToTopID()
|
||||
if !h.ForumTopic {
|
||||
t.Fatalf("message %d reply header missing forum_topic flag: %+v", m.ID, h)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// A plain General message (no reply header).
|
||||
g1 := send("g1", 9101002, nil)
|
||||
|
||||
// Reply to it -> topic must be General (1), not g1.ID.
|
||||
r1 := send("r1", 9101003, &tg.InputReplyToMessage{ReplyToMsgID: g1.ID})
|
||||
if got := topID(r1); got != domain.ForumGeneralTopicID {
|
||||
t.Fatalf("reply to a General message: reply_to_top_id = %d, want %d (General), not the target id %d",
|
||||
got, domain.ForumGeneralTopicID, g1.ID)
|
||||
}
|
||||
|
||||
// Reply again, this time the client also passes top_msg_id: 1 (General).
|
||||
// Previously this was rejected because General has no channel_forum_topics row.
|
||||
replyWithTop := &tg.InputReplyToMessage{ReplyToMsgID: g1.ID}
|
||||
replyWithTop.SetTopMsgID(domain.ForumGeneralTopicID)
|
||||
r2 := send("r2", 9101004, replyWithTop)
|
||||
if got := topID(r2); got != domain.ForumGeneralTopicID {
|
||||
t.Fatalf("reply with top_msg_id=1: reply_to_top_id = %d, want %d", got, domain.ForumGeneralTopicID)
|
||||
}
|
||||
|
||||
// Post directly into the "Test" topic, then reply to a plain message there.
|
||||
tInTopic := &tg.InputReplyToMessage{ReplyToMsgID: 0}
|
||||
tInTopic.SetTopMsgID(testTopicID)
|
||||
m1 := send("t1", 9101005, tInTopic)
|
||||
if got := topID(m1); got != testTopicID {
|
||||
t.Fatalf("message in Test topic: reply_to_top_id = %d, want %d", got, testTopicID)
|
||||
}
|
||||
rt := send("rt", 9101006, &tg.InputReplyToMessage{ReplyToMsgID: m1.ID})
|
||||
if got := topID(rt); got != testTopicID {
|
||||
t.Fatalf("reply inside Test topic: reply_to_top_id = %d, want %d (topic), not %d", got, testTopicID, m1.ID)
|
||||
}
|
||||
|
||||
// Replying to a General message while claiming a mismatched topic is rejected.
|
||||
bad := &tg.InputReplyToMessage{ReplyToMsgID: g1.ID}
|
||||
bad.SetTopMsgID(testTopicID)
|
||||
req := &tg.MessagesSendMessageRequest{Peer: peer, Message: "bad", RandomID: 9101007}
|
||||
req.SetReplyTo(bad)
|
||||
if _, err := r.onMessagesSendMessage(ownerCtx, req); err == nil {
|
||||
t.Fatal("reply with a topic id that doesn't match the target's topic was accepted")
|
||||
}
|
||||
}
|
||||
133
internal/rpc/forum_topics_preview_rpc_test.go
Normal file
133
internal/rpc/forum_topics_preview_rpc_test.go
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appchannels "telesrv/internal/app/channels"
|
||||
appusers "telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// A public forum's topic list is browsable before joining, like its history.
|
||||
// Regression: getForumTopics used the member-only access path and returned
|
||||
// CHANNEL_PRIVATE / an empty list to non-members, so the topic list (and even
|
||||
// General) was invisible until they joined.
|
||||
func TestGetForumTopicsVisibleToPublicNonMember(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 81, Phone: "15550008101", FirstName: "Owner"})
|
||||
outsider, _ := userStore.Create(ctx, domain.User{AccessHash: 82, Phone: "15550008102", FirstName: "Outsider"})
|
||||
channelStore := memory.NewChannelStore()
|
||||
channelSvc := appchannels.NewService(channelStore)
|
||||
r := New(Config{}, Deps{
|
||||
Users: appusers.NewService(userStore),
|
||||
Channels: channelSvc,
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
ownerCtx := WithUserID(ctx, owner.ID)
|
||||
outsiderCtx := WithUserID(ctx, outsider.ID)
|
||||
|
||||
created, err := r.onChannelsCreateChannel(ownerCtx, &tg.ChannelsCreateChannelRequest{Title: "Public Forum", Megagroup: true})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
channel := created.(*tg.Updates).Chats[0].(*tg.Channel)
|
||||
input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
|
||||
forumPeer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
|
||||
|
||||
if _, err := r.onChannelsToggleForum(ownerCtx, &tg.ChannelsToggleForumRequest{Channel: input, Enabled: true, Tabs: true}); err != nil {
|
||||
t.Fatalf("toggle forum: %v", err)
|
||||
}
|
||||
if _, err := channelSvc.UpdateUsername(ctx, owner.ID, domain.UpdateChannelUsernameRequest{
|
||||
ChannelID: channel.ID,
|
||||
Username: "publicforum",
|
||||
}); err != nil {
|
||||
t.Fatalf("set channel username: %v", err)
|
||||
}
|
||||
if _, err := r.onMessagesCreateForumTopic(ownerCtx, &tg.MessagesCreateForumTopicRequest{
|
||||
Peer: forumPeer,
|
||||
Title: "Test",
|
||||
IconColor: domain.DefaultForumTopicIconColor,
|
||||
RandomID: 8101001,
|
||||
}); err != nil {
|
||||
t.Fatalf("create forum topic: %v", err)
|
||||
}
|
||||
|
||||
res, err := r.onMessagesGetForumTopics(outsiderCtx, &tg.MessagesGetForumTopicsRequest{
|
||||
Peer: forumPeer,
|
||||
Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("getForumTopics as non-member: %v", err)
|
||||
}
|
||||
titles := map[string]bool{}
|
||||
testTopicID := 0
|
||||
for _, tc := range res.Topics {
|
||||
switch topic := tc.(type) {
|
||||
case *tg.ForumTopic:
|
||||
titles[topic.Title] = true
|
||||
if topic.Title == "Test" {
|
||||
testTopicID = topic.ID
|
||||
}
|
||||
case *tg.ForumTopicDeleted:
|
||||
}
|
||||
}
|
||||
if !titles["General"] {
|
||||
t.Fatalf("non-member did not see the General topic: %+v", res.Topics)
|
||||
}
|
||||
if !titles["Test"] {
|
||||
t.Fatalf("non-member did not see the Test topic: %+v", res.Topics)
|
||||
}
|
||||
|
||||
// A non-member can also read the replies inside a topic (preview), the same
|
||||
// way ListChannelHistory lets them preview a public group's flat history.
|
||||
if testTopicID == 0 {
|
||||
t.Fatal("no Test topic id to open")
|
||||
}
|
||||
if _, err := r.onMessagesGetReplies(outsiderCtx, &tg.MessagesGetRepliesRequest{
|
||||
Peer: forumPeer,
|
||||
MsgID: testTopicID,
|
||||
Limit: 20,
|
||||
}); err != nil {
|
||||
t.Fatalf("getReplies as non-member of a public forum: %v", err)
|
||||
}
|
||||
|
||||
// The forum's own channel must come back with left=true so the client still
|
||||
// offers a Join button instead of treating the forum as already joined.
|
||||
var forumChat *tg.Channel
|
||||
for _, c := range res.Chats {
|
||||
if ch, ok := c.(*tg.Channel); ok && ch.ID == channel.ID {
|
||||
forumChat = ch
|
||||
}
|
||||
}
|
||||
if forumChat == nil {
|
||||
t.Fatalf("forum channel missing from getForumTopics chats: %+v", res.Chats)
|
||||
}
|
||||
if !forumChat.Left {
|
||||
t.Fatalf("non-member forum chat = %#v, want left=true", forumChat)
|
||||
}
|
||||
|
||||
// A private forum still refuses a non-member.
|
||||
priv, err := r.onChannelsCreateChannel(ownerCtx, &tg.ChannelsCreateChannelRequest{Title: "Private Forum", Megagroup: true})
|
||||
if err != nil {
|
||||
t.Fatalf("create private channel: %v", err)
|
||||
}
|
||||
privCh := priv.(*tg.Updates).Chats[0].(*tg.Channel)
|
||||
privInput := &tg.InputChannel{ChannelID: privCh.ID, AccessHash: privCh.AccessHash}
|
||||
privPeer := &tg.InputPeerChannel{ChannelID: privCh.ID, AccessHash: privCh.AccessHash}
|
||||
if _, err := r.onChannelsToggleForum(ownerCtx, &tg.ChannelsToggleForumRequest{Channel: privInput, Enabled: true, Tabs: true}); err != nil {
|
||||
t.Fatalf("toggle private forum: %v", err)
|
||||
}
|
||||
if _, err := r.onMessagesGetForumTopics(outsiderCtx, &tg.MessagesGetForumTopicsRequest{Peer: privPeer, Limit: 100}); err == nil {
|
||||
t.Fatal("non-member read a private forum's topic list")
|
||||
}
|
||||
if _, err := r.onMessagesGetReplies(outsiderCtx, &tg.MessagesGetRepliesRequest{Peer: privPeer, MsgID: 1, Limit: 20}); err == nil {
|
||||
t.Fatal("non-member read a private forum topic's replies")
|
||||
}
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ func (r *Router) registerHelp(d *tlprofile.Dispatcher) {
|
|||
}, nil
|
||||
})
|
||||
registerRPC[*tg.HelpGetInviteTextRequest](d, tlprofile.SemanticMethodHelpGetInviteText, func(ctx context.Context, layerRequest *tg.HelpGetInviteTextRequest) (any, error) {
|
||||
return &tg.HelpInviteText{Message: "Join me on " + branding.ProductName + "."}, nil
|
||||
return &tg.HelpInviteText{Message: "Join me on " + branding.ProductName() + "."}, nil
|
||||
})
|
||||
registerRPC[*tg.HelpSaveAppLogRequest](d, tlprofile.SemanticMethodHelpSaveAppLog, func(ctx context.Context, _ *tg.HelpSaveAppLogRequest) (any, error) {
|
||||
return r.onHelpSaveAppLog(ctx)
|
||||
|
|
@ -186,7 +186,7 @@ func (r *Router) onHelpDismissSuggestion(ctx context.Context, req *tg.HelpDismis
|
|||
// dead payment URLs. All six TL fields are mandatory.
|
||||
func (r *Router) onHelpGetPremiumPromo(ctx context.Context) (*tg.HelpPremiumPromo, error) {
|
||||
promo := &tg.HelpPremiumPromo{
|
||||
StatusText: branding.PremiumName + " is not active on this account.",
|
||||
StatusText: branding.PremiumName() + " is not active on this account.",
|
||||
StatusEntities: []tg.MessageEntityClass{},
|
||||
VideoSections: []string{},
|
||||
Videos: []tg.DocumentClass{},
|
||||
|
|
@ -215,7 +215,7 @@ func (r *Router) onHelpGetPremiumPromo(ctx context.Context) (*tg.HelpPremiumProm
|
|||
}
|
||||
if u.PremiumActiveAt(r.clock.Now().Unix()) {
|
||||
until := time.Unix(int64(u.PremiumUntil), 0)
|
||||
promo.StatusText = branding.PremiumName + " is active until " + until.Format("2006-01-02") + "."
|
||||
promo.StatusText = branding.PremiumName() + " is active until " + until.Format("2006-01-02") + "."
|
||||
}
|
||||
if r.deps.PremiumPromo != nil {
|
||||
catalog, found, err := r.deps.PremiumPromo.PremiumPromo(ctx)
|
||||
|
|
|
|||
|
|
@ -341,8 +341,11 @@ func (r *Router) mentionedUserIDsFromDomainMessage(ctx context.Context, currentU
|
|||
for _, username := range extractMentionUsernames(message, domain.MaxChannelMentionRecipients-len(out)) {
|
||||
user, found, err := identity.ResolveUsername(ctx, currentUserID, username)
|
||||
if err != nil {
|
||||
if mentionResolveFatal(err) {
|
||||
return nil, internalErr()
|
||||
}
|
||||
continue
|
||||
}
|
||||
if found {
|
||||
add(user.ID)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,8 +99,8 @@ func TestMessagesEditInlineBotMessageEditsPrivateInlineMessage(t *testing.T) {
|
|||
|
||||
editReq := &tg.MessagesEditInlineBotMessageRequest{ID: msgID}
|
||||
editReq.SetMessage("after edit")
|
||||
editReq.SetReplyMarkup(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{
|
||||
Buttons: []tg.KeyboardButtonClass{&tg.KeyboardButtonCallback{Text: "done", Data: []byte("v2")}},
|
||||
editReq.SetReplyMarkup(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardInlineButtonRow{{
|
||||
Buttons: []tg.KeyboardInlineButton{{Text: "done", Type: &tg.InlineButtonTypeCallback{Data: []byte("v2")}}},
|
||||
}}})
|
||||
if ok, err := f.router.onMessagesEditInlineBotMessage(botCtx, editReq); err != nil || !ok {
|
||||
t.Fatalf("edit inline bot message = %v,%v, want true,nil", ok, err)
|
||||
|
|
@ -291,8 +291,8 @@ func inlineArticleResultWithCallbackMarkup(id, message, button string, data []by
|
|||
Title: id,
|
||||
SendMessage: &tg.InputBotInlineMessageText{
|
||||
Message: message,
|
||||
ReplyMarkup: &tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{
|
||||
Buttons: []tg.KeyboardButtonClass{&tg.KeyboardButtonCallback{Text: button, Data: data}},
|
||||
ReplyMarkup: &tg.ReplyInlineMarkup{Rows: []tg.KeyboardInlineButtonRow{{
|
||||
Buttons: []tg.KeyboardInlineButton{{Text: button, Type: &tg.InlineButtonTypeCallback{Data: data}}},
|
||||
}}},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -474,12 +474,37 @@ func (r *Router) forumTopicsResponse(ctx context.Context, userID int64, view dom
|
|||
Count: count,
|
||||
Topics: topics,
|
||||
Messages: messages,
|
||||
Chats: tgChannels(userID, channels),
|
||||
Chats: r.forumTopicsChats(userID, view, channels),
|
||||
Users: r.tgUsersForIDs(ctx, userID, userIDs),
|
||||
Pts: view.Channel.Pts,
|
||||
})
|
||||
}
|
||||
|
||||
// forumTopicsChats projects the forum's own channel with the viewer's member
|
||||
// state (so a non-member preview carries left=true and the client still shows a
|
||||
// Join button) and every other referenced channel as a min chat. Rendering the
|
||||
// primary as a bare min chat lets a client that has no other object for the
|
||||
// channel treat the forum as already joined.
|
||||
func (r *Router) forumTopicsChats(userID int64, view domain.ChannelView, channels []domain.Channel) []tg.ChatClass {
|
||||
if view.Channel.ID == 0 {
|
||||
return tgChannels(userID, channels)
|
||||
}
|
||||
chats := make([]tg.ChatClass, 0, len(channels))
|
||||
chats = append(chats, tgChannelChatForView(userID, view))
|
||||
seen := map[int64]struct{}{view.Channel.ID: {}}
|
||||
for _, extra := range channels {
|
||||
if extra.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[extra.ID]; dup {
|
||||
continue
|
||||
}
|
||||
seen[extra.ID] = struct{}{}
|
||||
chats = append(chats, tgChannelChatMin(userID, extra))
|
||||
}
|
||||
return chats
|
||||
}
|
||||
|
||||
func tgForumGeneralTopic(viewerUserID int64, view domain.ChannelView, topic domain.ChannelForumTopic) *tg.ForumTopic {
|
||||
return &tg.ForumTopic{
|
||||
My: view.Channel.CreatorUserID == viewerUserID && viewerUserID != 0,
|
||||
|
|
|
|||
|
|
@ -437,8 +437,11 @@ func (r *Router) mentionedUserIDsFromMessage(ctx context.Context, currentUserID
|
|||
for _, username := range extractMentionUsernames(message, domain.MaxChannelMentionRecipients-len(out)) {
|
||||
user, found, err := identity.ResolveUsername(ctx, currentUserID, username)
|
||||
if err != nil {
|
||||
if mentionResolveFatal(err) {
|
||||
return nil, internalErr()
|
||||
}
|
||||
continue
|
||||
}
|
||||
if found {
|
||||
add(user.ID)
|
||||
}
|
||||
|
|
@ -450,6 +453,17 @@ func (r *Router) mentionedUserIDsFromMessage(ctx context.Context, currentUserID
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// mentionResolveFatal reports whether a ResolveUsername error while scanning
|
||||
// message text for @mentions should abort the send. A syntactically invalid or
|
||||
// unoccupied @token is not a failure: real Telegram sends the message, renders
|
||||
// the token as a mention, and only fails to open it when the reader taps it.
|
||||
// Only an unexpected (storage) error aborts the RPC.
|
||||
func mentionResolveFatal(err error) bool {
|
||||
return err != nil &&
|
||||
!errors.Is(err, domain.ErrUsernameInvalid) &&
|
||||
!errors.Is(err, domain.ErrUsernameNotOccupied)
|
||||
}
|
||||
|
||||
func extractMentionUsernames(message string, limit int) []string {
|
||||
if limit <= 0 || message == "" {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -12,9 +12,10 @@ import (
|
|||
|
||||
// registerPayments 注册 payments.* RPC。telesrv 不实现 Stars/Star Gift 经济:
|
||||
// 大部分曾经的 Stars/gift 动作 RPC 已不再注册;仍注册的几个只读状态 RPC
|
||||
// (getStarsStatus/Subscriptions/Transactions/RevenueStats)返回固定空值,
|
||||
// 因为部分客户端界面会无条件加载它们——错误返回会导致界面卡死或崩溃,
|
||||
// 空值则让界面正常渲染成"没有 Stars"。
|
||||
// (getStarsStatus/Subscriptions/Transactions/RevenueStats、getStarGifts、
|
||||
// getSavedStarGifts)返回固定空值,因为部分客户端界面会无条件加载它们——
|
||||
// 错误返回会导致界面卡死或崩溃,且 tdesktop 对 500 会疯狂重试,空值则让界面
|
||||
// 正常渲染成"没有 Stars / 没有礼物"。
|
||||
func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
|
||||
registerRPC[*tg.PaymentsCanPurchaseStoreRequest](d, tlprofile.SemanticMethodPaymentsCanPurchaseStore, func(ctx context.Context, req *tg.PaymentsCanPurchaseStoreRequest) (any, error) {
|
||||
return r.onPaymentsCanPurchaseStore(ctx, req)
|
||||
|
|
@ -51,7 +52,22 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
|
|||
registerRPC[*tg.PaymentsGetStarsRevenueStatsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsRevenueStats, func(ctx context.Context, req *tg.PaymentsGetStarsRevenueStatsRequest) (any, error) {
|
||||
return r.onPaymentsGetStarsRevenueStats(ctx, req)
|
||||
})
|
||||
|
||||
// Star Gift catalogue / profile gift list: telesrv has no gift economy, but
|
||||
// tdesktop polls both on startup and retries hard on an error (a 500 turns
|
||||
// into a request storm). Answer with a valid empty list so the client stops.
|
||||
registerRPC[*tg.PaymentsGetStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGifts, func(ctx context.Context, _ *tg.PaymentsGetStarGiftsRequest) (any, error) {
|
||||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
return &tg.PaymentsStarGifts{Gifts: []tg.StarGiftClass{}, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}}, nil
|
||||
})
|
||||
registerRPC[*tg.PaymentsGetSavedStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetSavedStarGifts, func(ctx context.Context, _ *tg.PaymentsGetSavedStarGiftsRequest) (any, error) {
|
||||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
// No next_offset: an empty page must be terminal, not an infinite scroll.
|
||||
return &tg.PaymentsSavedStarGifts{Gifts: []tg.SavedStarGift{}, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Router) onPaymentsCanPurchaseStore(ctx context.Context, _ *tg.PaymentsCanPurchaseStoreRequest) (bool, error) {
|
||||
|
|
|
|||
|
|
@ -531,6 +531,13 @@ func (r *Router) withUserPresence(u domain.User) domain.User {
|
|||
if u.Bot {
|
||||
return u
|
||||
}
|
||||
// 已注销账号同理不参与 presence:tgUser/tgSelfUser 对 Deleted 用户直接短路输出
|
||||
// 精简 tombstone,从不读取 Status,这里覆盖与否本应无观测差异——但只靠那一层
|
||||
// 短路里应外合:任何上游把 Deleted 弄丢的 bug(例如缓存往返丢字段)都会让这里
|
||||
// 覆盖出的 Status 变成可见的“最近上线”,掩盖真正的 bug 而不是让它更早炸出来。
|
||||
if u.Deleted {
|
||||
return u
|
||||
}
|
||||
u.Status = r.userPresenceStatusForUser(u)
|
||||
return u
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1290,6 +1290,8 @@ func TestTDesktopStartupRPCsEncode(t *testing.T) {
|
|||
{name: "payments.canPurchaseStore", req: &tg.PaymentsCanPurchaseStoreRequest{Purpose: &tg.InputStorePaymentStarsTopup{Stars: 1000, Currency: "USD", Amount: 99}}},
|
||||
{name: "payments.getStarsStatus", req: &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}}},
|
||||
{name: "payments.getStarsSubscriptions", req: &tg.PaymentsGetStarsSubscriptionsRequest{Peer: &tg.InputPeerSelf{}}},
|
||||
{name: "payments.getStarGifts", req: &tg.PaymentsGetStarGiftsRequest{}},
|
||||
{name: "payments.getSavedStarGifts", req: &tg.PaymentsGetSavedStarGiftsRequest{Peer: &tg.InputPeerSelf{}, Limit: 20}},
|
||||
{name: "updates.getDifference", req: &tg.UpdatesGetDifferenceRequest{}},
|
||||
{name: "users.getFullUser", req: &tg.UsersGetFullUserRequest{ID: &tg.InputUserSelf{}}},
|
||||
{name: "users.getRequirementsToContact", req: &tg.UsersGetRequirementsToContactRequest{ID: []tg.InputUserClass{&tg.InputUserSelf{}}}},
|
||||
|
|
|
|||
|
|
@ -290,6 +290,24 @@ func (r *Router) invalidateRPCProjectionForPeer(ownerUserID int64, peer domain.P
|
|||
}
|
||||
}
|
||||
|
||||
// invalidateChannelMembershipProjection drops the cached channels.getFullChannel
|
||||
// projection for each user whose membership in channelID just changed (join,
|
||||
// leave, invite, request approval). Without it a client that polls
|
||||
// channels.getFullChannel right after channels.leaveChannel keeps getting a
|
||||
// projection that still shows it as an active member (left=false) until the
|
||||
// entry's TTL lapses, so it keeps an open compose box even though sends are
|
||||
// already rejected with CHANNEL_PRIVATE.
|
||||
func (r *Router) invalidateChannelMembershipProjection(channelID int64, userIDs []int64) {
|
||||
if r.channelFullProjectionCache == nil || channelID == 0 {
|
||||
return
|
||||
}
|
||||
for _, userID := range userIDs {
|
||||
if userID != 0 {
|
||||
r.channelFullProjectionCache.DeletePair(userID, channelID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) invalidateRPCProjectionForChannel(channelID int64) {
|
||||
if r.channelFullProjectionCache != nil {
|
||||
r.channelFullProjectionCache.DeleteChannel(channelID)
|
||||
|
|
|
|||
|
|
@ -681,6 +681,64 @@ func TestTGDocumentDoesNotEmitAnimatedAttributeForTGSSticker(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestTGDocumentNeverEmitsZeroImageOrVideoDimensions(t *testing.T) {
|
||||
// A sticker / custom emoji whose stored metadata carries a zero dimension
|
||||
// must not reach the client as documentAttributeImageSize#0 -- Telegram
|
||||
// Desktop divides by it and crashes when the emoji is used as a reaction.
|
||||
sticker := tgDocument(domain.Document{
|
||||
ID: 100, AccessHash: 1, DCID: 2, MimeType: "application/x-tgsticker",
|
||||
Attributes: []domain.DocumentAttribute{
|
||||
{Kind: domain.DocAttrImageSize, W: 0, H: 0},
|
||||
{Kind: domain.DocAttrCustomEmoji, Alt: "🙂", StickerSetID: 10, StickerSetAccessHash: 20},
|
||||
},
|
||||
}).(*tg.Document)
|
||||
var gotImage bool
|
||||
for _, attr := range sticker.Attributes {
|
||||
if a, ok := attr.(*tg.DocumentAttributeImageSize); ok {
|
||||
gotImage = true
|
||||
if a.W != 512 || a.H != 512 {
|
||||
t.Fatalf("sticker imageSize = %dx%d, want 512x512 fallback", a.W, a.H)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !gotImage {
|
||||
t.Fatal("sticker lost its imageSize attribute entirely")
|
||||
}
|
||||
|
||||
video := tgDocument(domain.Document{
|
||||
ID: 101, AccessHash: 1, DCID: 2, MimeType: "video/webm",
|
||||
Attributes: []domain.DocumentAttribute{
|
||||
{Kind: domain.DocAttrVideo, W: 0, H: 0, Duration: 3},
|
||||
{Kind: domain.DocAttrSticker, Alt: "😀", StickerSetID: 10, StickerSetAccessHash: 20},
|
||||
},
|
||||
}).(*tg.Document)
|
||||
for _, attr := range video.Attributes {
|
||||
if a, ok := attr.(*tg.DocumentAttributeVideo); ok && (a.W <= 0 || a.H <= 0) {
|
||||
t.Fatalf("video sticker attribute has zero dimension: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
// A malformed plain image drops the attribute rather than emitting a zero.
|
||||
plain := tgDocument(domain.Document{
|
||||
ID: 102, AccessHash: 1, DCID: 2, MimeType: "image/jpeg",
|
||||
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrImageSize, W: 0, H: 480}},
|
||||
}).(*tg.Document)
|
||||
for _, attr := range plain.Attributes {
|
||||
if _, ok := attr.(*tg.DocumentAttributeImageSize); ok {
|
||||
t.Fatalf("plain image kept a malformed imageSize: %+v", attr)
|
||||
}
|
||||
}
|
||||
|
||||
// A zero-dimension thumbnail is dropped, not emitted.
|
||||
thumbed := tgDocument(domain.Document{
|
||||
ID: 103, AccessHash: 1, DCID: 2, MimeType: "image/webp",
|
||||
Thumbs: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "s", W: 0, H: 0, Size: 10}},
|
||||
}).(*tg.Document)
|
||||
if len(thumbed.Thumbs) != 0 {
|
||||
t.Fatalf("kept a zero-dimension thumb: %#v", thumbed.Thumbs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTGDocumentUsesDomainDocumentID(t *testing.T) {
|
||||
const documentID int64 = 1382305375846410902
|
||||
|
||||
|
|
|
|||
|
|
@ -540,18 +540,14 @@ func (s *ChannelStore) resolveChannelReplyLocked(req domain.SendChannelMessageRe
|
|||
if req.ReplyTo.TopMessageID <= 0 || !channel.Forum {
|
||||
return nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
topic, ok := s.topics[req.ChannelID][req.ReplyTo.TopMessageID]
|
||||
if !ok || topic.Hidden {
|
||||
return nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID, selfBoostsApplied) {
|
||||
return nil, domain.ErrChannelWriteForbidden
|
||||
}
|
||||
reply := cloneMessageReply(req.ReplyTo)
|
||||
reply.MessageID = 0
|
||||
reply.Peer = channelPeer
|
||||
reply.TopMessageID = topic.TopicID
|
||||
reply.ForumTopic = true
|
||||
if err := s.validateForumReplyTopicLocked(channel, member, req.ReplyTo.TopMessageID, req.UserID, selfBoostsApplied); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply.TopMessageID = req.ReplyTo.TopMessageID
|
||||
return reply, nil
|
||||
}
|
||||
target, ok := s.findMessageLocked(req.ChannelID, req.ReplyTo.MessageID)
|
||||
|
|
@ -561,6 +557,22 @@ func (s *ChannelStore) resolveChannelReplyLocked(req domain.SendChannelMessageRe
|
|||
reply := cloneMessageReply(req.ReplyTo)
|
||||
reply.MessageID = target.ID
|
||||
reply.Peer = channelPeer
|
||||
|
||||
if channel.Forum {
|
||||
// A forum reply belongs to the TARGET's topic, never the target's own id.
|
||||
topicID := domain.ForumReplyTopicID(target)
|
||||
if req.ReplyTo.TopMessageID > 0 && req.ReplyTo.TopMessageID != topicID {
|
||||
return nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
if err := s.validateForumReplyTopicLocked(channel, member, topicID, req.UserID, selfBoostsApplied); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply.TopMessageID = topicID
|
||||
reply.ForumTopic = true
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
// Non-forum discussion thread: reply_to_top_id is the comment-thread root.
|
||||
reply.TopMessageID = target.ID
|
||||
if target.ReplyTo != nil && target.ReplyTo.TopMessageID > 0 {
|
||||
reply.TopMessageID = target.ReplyTo.TopMessageID
|
||||
|
|
@ -568,17 +580,25 @@ func (s *ChannelStore) resolveChannelReplyLocked(req domain.SendChannelMessageRe
|
|||
if req.ReplyTo.TopMessageID > 0 && req.ReplyTo.TopMessageID != reply.TopMessageID {
|
||||
return nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
if channel.Forum && reply.TopMessageID > 0 {
|
||||
if topic, ok := s.topics[req.ChannelID][reply.TopMessageID]; ok && !topic.Hidden {
|
||||
if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID, selfBoostsApplied) {
|
||||
return nil, domain.ErrChannelWriteForbidden
|
||||
}
|
||||
reply.ForumTopic = true
|
||||
}
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
// validateForumReplyTopicLocked mirrors the postgres store: General
|
||||
// (ForumGeneralTopicID) is a virtual topic with no row and is always valid.
|
||||
func (s *ChannelStore) validateForumReplyTopicLocked(channel domain.Channel, member domain.ChannelMember, topicID int, userID int64, selfBoostsApplied int) error {
|
||||
if topicID == domain.ForumGeneralTopicID {
|
||||
return nil
|
||||
}
|
||||
topic, ok := s.topics[channel.ID][topicID]
|
||||
if !ok || topic.Hidden {
|
||||
return domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
if topic.Closed && !canManageForumTopic(channel, member, topic, userID, selfBoostsApplied) {
|
||||
return domain.ErrChannelWriteForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func inactiveChannelDate(dialog domain.Dialog, channel domain.Channel, member domain.ChannelMember) int {
|
||||
if dialog.TopMessageDate > 0 {
|
||||
return dialog.TopMessageDate
|
||||
|
|
|
|||
|
|
@ -128,6 +128,9 @@ func (s *ChannelStore) CheckUsername(_ context.Context, userID, channelID int64,
|
|||
return false, err
|
||||
}
|
||||
usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@")))
|
||||
if s.usernameRegistry != nil && s.usernameRegistry.nameReserved(usernameLower) {
|
||||
return false, nil
|
||||
}
|
||||
for id, channel := range s.channels {
|
||||
if channel.Deleted || channel.Username == "" {
|
||||
continue
|
||||
|
|
@ -175,6 +178,13 @@ func (s *ChannelStore) UpdateUsername(ctx context.Context, req domain.UpdateChan
|
|||
}
|
||||
prevUsername := channel.Username
|
||||
channel.Username = username
|
||||
// A public group cannot keep pre-history hidden: assigning a username forces
|
||||
// "chat history for new members" back to visible (matches the official
|
||||
// server). Removing the username leaves the flag untouched.
|
||||
clearedPrehistory := username != "" && channel.PreHistoryHidden
|
||||
if clearedPrehistory {
|
||||
channel.PreHistoryHidden = false
|
||||
}
|
||||
s.channels[req.ChannelID] = channel
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: req.ChannelID,
|
||||
|
|
@ -184,6 +194,16 @@ func (s *ChannelStore) UpdateUsername(ctx context.Context, req domain.UpdateChan
|
|||
PrevString: prevUsername,
|
||||
NewString: username,
|
||||
})
|
||||
if clearedPrehistory {
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: req.ChannelID,
|
||||
UserID: req.UserID,
|
||||
Date: int(time.Now().Unix()),
|
||||
Type: domain.ChannelAdminLogTogglePreHistoryHidden,
|
||||
PrevBool: true,
|
||||
NewBool: false,
|
||||
})
|
||||
}
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ func (s *ChannelStore) GeneralForumTopic(_ context.Context, viewerUserID, channe
|
|||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, member, err := s.channelAndMemberLocked(viewerUserID, channelID)
|
||||
channel, member, _, err := s.channelForViewerLocked(viewerUserID, channelID)
|
||||
if err != nil {
|
||||
return domain.ChannelForumTopic{}, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -420,7 +420,9 @@ func (s *ChannelStore) DeleteForumTopicHistory(_ context.Context, req domain.Del
|
|||
func (s *ChannelStore) ListForumTopics(_ context.Context, viewerUserID int64, filter domain.ChannelForumTopicFilter) (domain.ChannelForumTopicList, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, member, err := s.channelAndMemberLocked(viewerUserID, filter.ChannelID)
|
||||
// channelForViewerLocked, not channelAndMemberLocked: a public forum's topic
|
||||
// list is browsable before joining, exactly like its message history.
|
||||
channel, member, _, err := s.channelForViewerLocked(viewerUserID, filter.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ChannelForumTopicList{}, err
|
||||
}
|
||||
|
|
@ -463,7 +465,7 @@ func (s *ChannelStore) ListForumTopics(_ context.Context, viewerUserID int64, fi
|
|||
func (s *ChannelStore) GetForumTopicsByID(_ context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelForumTopicList, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, member, err := s.channelAndMemberLocked(viewerUserID, channelID)
|
||||
channel, member, _, err := s.channelForViewerLocked(viewerUserID, channelID)
|
||||
if err != nil {
|
||||
return domain.ChannelForumTopicList{}, err
|
||||
}
|
||||
|
|
@ -499,7 +501,9 @@ func (s *ChannelStore) GetForumTopicsByID(_ context.Context, viewerUserID, chann
|
|||
func (s *ChannelStore) ListChannelReplies(_ context.Context, viewerUserID int64, filter domain.ChannelRepliesFilter) (domain.ChannelHistory, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
source, member, err := s.channelAndMemberOrLinkedGuestLocked(viewerUserID, filter.ChannelID)
|
||||
// Viewer scope (not strict membership): non-members can preview topic
|
||||
// replies in a public channel/supergroup, matching ListChannelHistory.
|
||||
source, member, _, err := s.channelForViewerLocked(viewerUserID, filter.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ChannelHistory{}, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,26 @@ type CollectibleUsernameStore struct {
|
|||
transfers map[int64][]domain.CollectibleUsernameTransfer
|
||||
// commands maps a provenance command key onto the asset it touched.
|
||||
commands map[string]int64
|
||||
// reserved, when set, is the operator blocklist consulted before a name is
|
||||
// assigned to an editable slot or minted, mirroring the PostgreSQL checks.
|
||||
reserved *ReservedUsernameStore
|
||||
}
|
||||
|
||||
// WithReservedUsernames wires the operator blocklist into the registry so a
|
||||
// reserved name is refused, matching PostgreSQL.
|
||||
func (s *CollectibleUsernameStore) WithReservedUsernames(reserved *ReservedUsernameStore) *CollectibleUsernameStore {
|
||||
s.reserved = reserved
|
||||
return s
|
||||
}
|
||||
|
||||
// nameReserved reports whether a name is on the operator blocklist. It touches
|
||||
// only s.reserved (its own lock), so it is safe from any context.
|
||||
func (s *CollectibleUsernameStore) nameReserved(usernameLower string) bool {
|
||||
if s == nil || s.reserved == nil {
|
||||
return false
|
||||
}
|
||||
r, _ := s.reserved.IsReserved(context.Background(), usernameLower)
|
||||
return r
|
||||
}
|
||||
|
||||
// collectibleRegistryRow is one peer_usernames row: the owning peer plus the
|
||||
|
|
@ -101,6 +121,9 @@ func (s *CollectibleUsernameStore) SetEditableUsername(_ context.Context, peer d
|
|||
return false, domain.ErrUsernameInvalid
|
||||
}
|
||||
key := strings.ToLower(username)
|
||||
if s.nameReserved(key) {
|
||||
return false, domain.ErrUsernameOccupied
|
||||
}
|
||||
if existing, ok := s.registry[key]; ok {
|
||||
if existing.peer == peer && existing.row.Editable {
|
||||
if existing.row.Username == username {
|
||||
|
|
@ -313,6 +336,9 @@ func (s *CollectibleUsernameStore) MintCollectibleUsername(_ context.Context, re
|
|||
if _, ok := s.registry[key]; ok {
|
||||
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
|
||||
}
|
||||
if s.nameReserved(key) {
|
||||
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
purchaseDate := req.PurchaseDate
|
||||
if purchaseDate.IsZero() {
|
||||
|
|
|
|||
100
internal/store/memory/reserved_username.go
Normal file
100
internal/store/memory/reserved_username.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// ReservedUsernameStore is the in-memory operator username blocklist.
|
||||
type ReservedUsernameStore struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]domain.ReservedUsername // keyed by username_lower
|
||||
}
|
||||
|
||||
// NewReservedUsernameStore creates an empty blocklist.
|
||||
func NewReservedUsernameStore() *ReservedUsernameStore {
|
||||
return &ReservedUsernameStore{entries: make(map[string]domain.ReservedUsername)}
|
||||
}
|
||||
|
||||
func (s *ReservedUsernameStore) IsReserved(_ context.Context, usernameLower string) (bool, error) {
|
||||
if s == nil {
|
||||
return false, nil
|
||||
}
|
||||
usernameLower = strings.ToLower(strings.TrimSpace(usernameLower))
|
||||
if usernameLower == "" {
|
||||
return false, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, ok := s.entries[usernameLower]
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func (s *ReservedUsernameStore) ReserveUsername(_ context.Context, username, reason, actor string) (bool, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
lower := strings.ToLower(username)
|
||||
if lower == "" {
|
||||
return false, domain.ErrUsernameInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.entries[lower]; ok {
|
||||
return false, nil
|
||||
}
|
||||
s.entries[lower] = domain.ReservedUsername{Username: username, Reason: reason, Actor: actor, CreatedAt: time.Now().UTC()}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *ReservedUsernameStore) UnreserveUsername(_ context.Context, username string) (bool, error) {
|
||||
lower := strings.ToLower(strings.TrimSpace(username))
|
||||
if lower == "" {
|
||||
return false, domain.ErrUsernameInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.entries[lower]; !ok {
|
||||
return false, nil
|
||||
}
|
||||
delete(s.entries, lower)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *ReservedUsernameStore) ReservedUsernames(_ context.Context, filter domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
q := strings.ToLower(strings.TrimSpace(filter.Query))
|
||||
out := make([]domain.ReservedUsername, 0, len(s.entries))
|
||||
for key, entry := range s.entries {
|
||||
if q != "" && !strings.HasPrefix(key, q) {
|
||||
continue
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if !out[i].CreatedAt.Equal(out[j].CreatedAt) {
|
||||
return out[i].CreatedAt.After(out[j].CreatedAt)
|
||||
}
|
||||
return strings.ToLower(out[i].Username) < strings.ToLower(out[j].Username)
|
||||
})
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
offset := filter.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset >= len(out) {
|
||||
return []domain.ReservedUsername{}, nil
|
||||
}
|
||||
end := offset + limit
|
||||
if end > len(out) {
|
||||
end = len(out)
|
||||
}
|
||||
return out[offset:end], nil
|
||||
}
|
||||
37
internal/store/memory/reserved_username_test.go
Normal file
37
internal/store/memory/reserved_username_test.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestCheckUsernameReportsReservedAsTaken(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reserved := NewReservedUsernameStore()
|
||||
if _, err := reserved.ReserveUsername(ctx, "support", "official", "ops"); err != nil {
|
||||
t.Fatalf("seed reserve: %v", err)
|
||||
}
|
||||
registry := NewCollectibleUsernameStore().WithReservedUsernames(reserved)
|
||||
|
||||
users := NewUserStore()
|
||||
users.AttachUsernameRegistry(registry)
|
||||
u, _ := users.Create(ctx, domain.User{AccessHash: 1, Phone: "15550001000", FirstName: "A"})
|
||||
if ok, err := users.CheckUsername(ctx, u.ID, "support"); err != nil || ok {
|
||||
t.Fatalf("CheckUsername(reserved) = %v, %v; want false, nil", ok, err)
|
||||
}
|
||||
if ok, err := users.CheckUsername(ctx, u.ID, "freename"); err != nil || !ok {
|
||||
t.Fatalf("CheckUsername(free) = %v, %v; want true, nil", ok, err)
|
||||
}
|
||||
|
||||
channels := NewChannelStore()
|
||||
channels.AttachUsernameRegistry(registry)
|
||||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: u.ID, Title: "C", Megagroup: true, Date: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
if ok, err := channels.CheckUsername(ctx, u.ID, created.Channel.ID, "support"); err != nil || ok {
|
||||
t.Fatalf("channel CheckUsername(reserved) = %v, %v; want false, nil", ok, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -158,6 +158,9 @@ func (s *UserStore) CheckUsername(_ context.Context, userID int64, username stri
|
|||
if username == "" {
|
||||
return true, nil
|
||||
}
|
||||
if s.usernameRegistry != nil && s.usernameRegistry.nameReserved(username) {
|
||||
return false, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for id, u := range s.byID {
|
||||
|
|
|
|||
|
|
@ -795,21 +795,14 @@ func (s *ChannelStore) resolveChannelReply(ctx context.Context, db sqlcgen.DBTX,
|
|||
if req.ReplyTo.TopMessageID <= 0 || !channel.Forum {
|
||||
return nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
topic, err := s.getForumTopic(ctx, db, req.ChannelID, req.ReplyTo.TopMessageID)
|
||||
if err != nil {
|
||||
return nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
if topic.Hidden {
|
||||
return nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID, selfBoostsApplied) {
|
||||
return nil, domain.ErrChannelWriteForbidden
|
||||
}
|
||||
reply := cloneMessageReply(req.ReplyTo)
|
||||
reply.MessageID = 0
|
||||
reply.Peer = channelPeer
|
||||
reply.TopMessageID = topic.TopicID
|
||||
reply.ForumTopic = true
|
||||
if err := s.validateForumReplyTopic(ctx, db, channel, member, req.ReplyTo.TopMessageID, req.UserID, selfBoostsApplied); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply.TopMessageID = req.ReplyTo.TopMessageID
|
||||
return reply, nil
|
||||
}
|
||||
target, err := s.getChannelMessage(ctx, db, req.ChannelID, req.ReplyTo.MessageID)
|
||||
|
|
@ -825,6 +818,22 @@ func (s *ChannelStore) resolveChannelReply(ctx context.Context, db sqlcgen.DBTX,
|
|||
reply := cloneMessageReply(req.ReplyTo)
|
||||
reply.MessageID = target.ID
|
||||
reply.Peer = channelPeer
|
||||
|
||||
if channel.Forum {
|
||||
// A forum reply belongs to the TARGET's topic, never the target's own id.
|
||||
topicID := domain.ForumReplyTopicID(target)
|
||||
if req.ReplyTo.TopMessageID > 0 && req.ReplyTo.TopMessageID != topicID {
|
||||
return nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
if err := s.validateForumReplyTopic(ctx, db, channel, member, topicID, req.UserID, selfBoostsApplied); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply.TopMessageID = topicID
|
||||
reply.ForumTopic = true
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
// Non-forum discussion thread: reply_to_top_id is the comment-thread root.
|
||||
reply.TopMessageID = target.ID
|
||||
if target.ReplyTo != nil && target.ReplyTo.TopMessageID > 0 {
|
||||
reply.TopMessageID = target.ReplyTo.TopMessageID
|
||||
|
|
@ -832,19 +841,29 @@ func (s *ChannelStore) resolveChannelReply(ctx context.Context, db sqlcgen.DBTX,
|
|||
if req.ReplyTo.TopMessageID > 0 && req.ReplyTo.TopMessageID != reply.TopMessageID {
|
||||
return nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
if channel.Forum && reply.TopMessageID > 0 {
|
||||
if topic, err := s.getForumTopic(ctx, db, req.ChannelID, reply.TopMessageID); err == nil && !topic.Hidden {
|
||||
if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID, selfBoostsApplied) {
|
||||
return nil, domain.ErrChannelWriteForbidden
|
||||
}
|
||||
reply.ForumTopic = true
|
||||
} else if err != nil && !errors.Is(err, domain.ErrMessageIDInvalid) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
// validateForumReplyTopic checks that topicID is a topic the caller may post
|
||||
// into. General (ForumGeneralTopicID) is a virtual topic with no
|
||||
// channel_forum_topics row and is always valid.
|
||||
func (s *ChannelStore) validateForumReplyTopic(ctx context.Context, db sqlcgen.DBTX, channel domain.Channel, member domain.ChannelMember, topicID int, userID int64, selfBoostsApplied int) error {
|
||||
if topicID == domain.ForumGeneralTopicID {
|
||||
return nil
|
||||
}
|
||||
topic, err := s.getForumTopic(ctx, db, channel.ID, topicID)
|
||||
if err != nil {
|
||||
return domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
if topic.Hidden {
|
||||
return domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
if topic.Closed && !canManageForumTopic(channel, member, topic, userID, selfBoostsApplied) {
|
||||
return domain.ErrChannelWriteForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func visibleChannelTopAfter(ctx context.Context, db sqlcgen.DBTX, channelID int64, availableMinID int, fallbackDate int) (int, int, error) {
|
||||
var id, date int
|
||||
err := db.QueryRow(ctx, `
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ func (s *ChannelStore) ImportInvite(ctx context.Context, req domain.ImportChanne
|
|||
return domain.CreateChannelResult{}, fmt.Errorf("commit import channel invite: %w", err)
|
||||
}
|
||||
committed = true
|
||||
s.invalidateChannelMembershipCaches(result.Channel.ID, req.UserID)
|
||||
recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, result.Channel.ID, 0)
|
||||
result.Recipients = recipients
|
||||
return result, nil
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ func (s *ChannelStore) InviteToChannel(ctx context.Context, channelID, inviterUs
|
|||
return domain.CreateChannelResult{}, fmt.Errorf("commit invite channel: %w", err)
|
||||
}
|
||||
committed = true
|
||||
s.invalidateChannelMembershipCaches(channelID, invitedIDs...)
|
||||
recipients, _ := s.ListActiveChannelMemberIDs(ctx, inviterUserID, channelID, 0)
|
||||
return domain.CreateChannelResult{Channel: channel, Members: members, Message: msg, Event: event, Recipients: recipients}, nil
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue