Compare commits

...

64 commits

Author SHA1 Message Date
6649b70d5e branding: make product identity runtime-configurable
Adopt upstream owpengram/owpengram-server's branding.Config/Configure/
Current in place of the old package-level constants. This is the piece the
earlier merge attempt was blocked on (internal/branding failed to import
during that merge). The default identity is unchanged -- every existing
ProductName/ProductUsername/... default still reads "OwpenGram" -- so this
is a pure capability add: nothing currently calls Configure, and every call
site now reads the current snapshot via a function instead of a compile-time
constant.

Five string templates that concatenated branding.ProductName into a `const`
had to become `var`, since a func call is no longer a valid const operand.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 11:37:36 +01:00
7fef8438c5 deps: bump github.com/iamxvbaba/td to v1.3.2
Matches upstream owpengram/owpengram-server's version. v1.3.2 collapses the
old per-kind KeyboardButtonClass sum type (KeyboardButton, KeyboardButtonURL,
KeyboardButtonCallback, KeyboardButtonRequestPeer, ...) into two unified
structs mirroring Telegram's actual current MTProto layer: KeyboardButton
(reply keyboards) and KeyboardInlineButton (inline keyboards), each carrying
a Text/Style pair plus a Type field (ButtonTypeClass / InlineButtonTypeClass)
that now holds what used to be the concrete Go type.

Migrated the two call sites (internal/rpc/convert_markup.go,
internal/rpc/bots_longtail.go) and their tests to the new shape. Behavior is
unchanged -- every button kind (callback, url, url_auth, web_view,
switch_inline, copy, request_phone, request_geo_location, request_poll,
request_peer, simple_web_view) still round-trips the same domain fields,
just read from/written to Type instead of the button's own concrete type.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 11:28:16 +01:00
ecdb14aaca account: stop rotating the SRP challenge on every getPassword read
GetPassword minted a brand-new random SRP server secret and B on every call
while only ever assigning SRPID once (when zero). Two account.getPassword
calls in a row -- e.g. a settings screen refreshing state, then the transfer-
ownership dialog's own cloudPassword().reload() moments later -- silently
invalidated 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 now stays stable across reads and only rotates when it's
missing entirely; UpdatePasswordSettings/RecoverPassword already mint their
own fresh challenge whenever the password actually changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 22:30:02 +01:00
b565efe9d1 build.sh: restart via systemd instead of managing containers directly
owpengram-server and owpengram-admin are now systemd-managed units; build.sh
no longer needs to podman create/start them itself, just build the image and
restart the two services so they pick up the new image. The pod itself still
isn't systemd's job, so build.sh keeps ensuring it exists first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 22:11:54 +01:00
c1c7db325f users: never cache a deleted user's base row
redisstore.userBaseValue has no Deleted/DeletedAt/Status field, so caching a
deleted user silently reset Deleted back to false (and Status to the zero
UserStatusUnknown) on every round trip. That never self-healed: each later
cache miss reloaded the correctly tombstoned DB row and immediately
re-corrupted it on write, so once anyone looked a deleted account up, it kept
showing a blank name with "last seen recently" instead of "Deleted Account".

Keep deleted users off the base cache entirely so lookups always hit the
authoritative store, and stop presence overlay from touching a Deleted user's
Status at all as defense in depth.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 22:08:06 +01:00
798ea70b4e rpc: return PASSWORD_MISSING for channel transfer without 2FA
messages.editChatCreator unconditionally returned PASSWORD_HASH_INVALID for
an account with no cloud password at all. Real Telegram Desktop's transfer-
ownership flow only recognizes the distinct PASSWORD_MISSING error to show
its "enable 2FA first" box; anything else falls through into the real
password-entry flow, which then has nothing to check against and crashes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 22:07:54 +01:00
7f0921c82c tools/createuser: report which field collided on insert failure
ON CONFLICT (id) DO NOTHING only catches the id itself, so every other
failure (duplicate username/phone/signup_email) was reported as a generic
"already exists (or insert failed)" - not useful for telling apart the four
distinct causes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 22:07:28 +01:00
2e60301a08 Merge branch 'container/build-createuser' 2026-09-13 20:57:32 +01:00
e81742ba51 container: build and ship cmd/createuser in the server image
Mirrors the telesrv-admin pattern - a static build stage compile, copied
into the final alpine image as /app/createuser - so the tool is
available wherever the server image runs, not just from a local go run.
2026-09-13 20:57:32 +01:00
38112062a1 Merge branch 'tools/createuser' 2026-09-13 17:59:46 +01:00
8df27528b9 tools: add createuser command for reserving a custom user id
auth.signUp never lets a caller pick a user id (users_id_seq always
assigns it), but users.id is GENERATED BY DEFAULT rather than ALWAYS, so
an explicit id in the INSERT is honored - the same mechanism
ensureOfficialSystemUserWithDB already relies on to seed the built-in
system accounts at fixed ids.

createuser -id N [-phone ...|-email ...] inserts a user row at that id
for local/dev use, refusing (unless -force) a reserved system-account id
or one at/above UserIDSequenceBase where a future organic signup could
collide with it. -email reproduces the real email-signup path exactly:
a synthetic 888-prefixed display phone (domain.NewEmailSignupDisplayPhone,
re-rolled on collision) plus the real address in signup_email, rather
than storing the address in users.phone directly.
2026-09-13 17:59:39 +01:00
655fdacb0a Merge branch 'docs/telegram-feature-comparison' 2026-09-13 17:59:30 +01:00
9353953cf4 docs: compare OwpenGram Server's implemented features to Telegram's official server
Feature-by-feature comparison across transport/auth, messaging, groups,
media, bots, calls, stories, secret chats, sync, themes, moderation, and
the payments/Stars economy - marking each Full / Partial / Stub / None,
with the deliberate omissions and OwpenGram-only additions called out
separately.
2026-09-13 17:59:24 +01:00
b71f8d7fa7 Merge branch 'fix/smtp-date-messageid-headers' 2026-09-11 17:40:03 +01:00
2e96d9490e smtp: add Date and Message-ID headers to outgoing mail
RFC 5322 requires both headers. Date uses RFC1123Z formatting; Message-ID
is a random 16-byte token scoped to the sending domain parsed from From.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 17:39:55 +01:00
2d42158ec5 Merge branch 'fix/reserved-usernames-checkusername' 2026-09-09 21:41:53 +01:00
367f2be59c usernames: report reserved names as taken in the check paths too
account.checkUsername / channels.checkUsername / bots.checkUsername said a
reserved name was available and only updateUsername rejected it. Add the
blocklist check to peerUsernameAvailable (covers account + channel, both
backends) and to bots.Service.CheckUsername, so the client shows "username is
taken" immediately.
2026-09-09 21:41:53 +01:00
5b2f68c61e Merge branch 'fix/reserved-usernames-actionflow' 2026-09-09 21:23:25 +01:00
90af881867 admin ui: reserved usernames use the standard dry-run/confirm flow
Both reserve and unreserve go through ActionButton now (reason -> dry-run ->
confirm, journalled) like every other admin action. The reserve modal keeps
just the username field and hands off; it autofocuses and echoes @<name> live
so the field being filled is unambiguous.
2026-09-09 21:23:25 +01:00
37a3462a22 Merge branch 'fix/reserved-usernames-modal' 2026-09-09 21:15:23 +01:00
4e2cf7c5ac admin ui: self-contained reserve-username modal, plain @ text
The reserve modal delegated to a nested ActionButton, whose own flow modal
opened over it - the username field ended up behind it and the request preview
came through empty on confirm. Replace it with a modal that owns its username
and reason fields and posts the reserve/unreserve command directly. Render the
@ prefix as text, not an icon.
2026-09-09 21:15:23 +01:00
93a4ed2bca Merge branch 'fix/reserved-usernames-layout' 2026-09-09 21:11:52 +01:00
241fd442fb admin ui: match the reserved-usernames page layout to the NFT page
Move "Reserve username" into a modal opened from the page actions, and keep a
single search toolbar in the query panel, so the page matches Collectible
Usernames instead of stacking two toolbars with an unconstrained input.
2026-09-09 21:11:52 +01:00
7dbb931b31 Merge branch 'feat/reserved-usernames' 2026-09-09 19:57:14 +01:00
65aaa263b1 usernames: operator reserved-username blocklist
A plain blocklist for names like @support - separate from the collectible
system, so a reservation has no owner, no price and no "bought on Fragment"
badge.

- reserved_usernames table + migration.
- Enforced in replacePeerUsernameTx (the single editable-username write point:
  account.updateUsername, channels.updateUsername, @BotFather /setusername) and
  in the collectible mint path; a reserved name returns USERNAME_OCCUPIED.
- admin.Service: ReserveUsername / UnreserveUsername (journalled commands) and
  the ReservedUsernames listing.
- adminapi: /v1/reserved-usernames{,/reserve,/unreserve}.
- telesrv-admin panel + a "Reserved Usernames" page in the web UI (dist rebuilt).
- Postgres and in-memory store implementations; the memory registry gains an
  optional reserved-name check so tests exercise the same rule.
2026-09-09 19:57:14 +01:00
515038aace Merge branch 'feat/botapi-chat-members' 2026-09-09 16:26:33 +01:00
d9ee8cbee0 botapi: getChatMemberCount, getChatMember, and a fuller getChat
- getChatMemberCount: channel/supergroup participant count (numeric chat_id).
- getChatMember: resolves a member via GetParticipant, projected to a Bot API
  ChatMember (creator/administrator/restricted/member/left/kicked with the
  matching rights); a user simply not in an accessible chat returns "left".
- getChat now uses the full channel view and adds permissions (from the default
  restrictions), slow_mode_delay, linked_chat_id and pinned_message.

Channel-only methods reject user chat_ids; private chats the bot cannot access
return CHAT_NOT_FOUND.
2026-09-09 16:26:33 +01:00
71da34607e Merge branch 'feat/botapi-getchat' 2026-09-09 16:14:49 +01:00
578eb5a1f6 botapi: implement getChat
Adds the getChat method to the HTTP Bot API gateway. Numeric chat_id only (no
@username). Resolution goes through the shared peer resolvers:

- user: ByID; unknown -> CHAT_NOT_FOUND
- channel/supergroup: ResolveChannel, so a public one resolves even when the bot
  is not a member (projected as a preview); a private one the bot cannot access
  -> CHAT_NOT_FOUND, a banned bot likewise

The Chat projection returns id (bot-api encoded), type ("channel" for a
broadcast, "supergroup" for a megagroup, "private" for a user), title, username,
first/last name, description, is_forum, and the scam/fake/verified flags.
2026-09-09 16:14:49 +01:00
3229bfa6b3 Merge branch 'chore/build-sh-port-2500' 2026-09-09 16:11:07 +01:00
36d0f19767 build: map port 2500 on the pod 2026-09-09 16:11:07 +01:00
25008fddc5 Merge branch 'chore/build-sh-pod-create' 2026-09-09 15:07:25 +01:00
284d8be365 build: create the pod with its port mappings if missing
Instead of erroring when the owpengram pod is absent, build.sh now creates it
with the MTProto (2398), admin (2600), extra TCP (2400) and RTC/UDP port
mappings.
2026-09-09 15:07:25 +01:00
485323dbfa Merge branch 'chore/build-sh-deploy' 2026-09-09 15:06:19 +01:00
f940d3403b build: recreate and start the pod containers after building
build.sh now, after building the image, recreates owpengram-server and
owpengram-admin in the pod (podman create --replace) and starts them. Guards
that the pod and .env exist; NO_DEPLOY=1 keeps the old build-only behaviour,
POD overrides the pod name.
2026-09-09 15:06:19 +01:00
0e2fc6705b Merge branch 'fix/botfather-start-manage-bot' 2026-09-09 15:03:53 +01:00
a3fc5ba3b5 botfather: /start <bot> opens that bot's menu
The "Manage Bot" button on a bot's profile deep-links to @BotFather with
start=<bot username>. parseBotCommand dropped the argument, so /start <bot>
just replied with the generic greeting instead of the per-bot menu.

Route "/start <arg>" to the bot's "What do you want to do?" screen (same as
/mybots then tapping the bot) when <arg> names one of the sender's own bots by
username or id; empty or unknown args keep the greeting.
2026-09-09 15:03:53 +01:00
f478c87a56 Merge branch 'fix/admin-accounts-list-sessionless' 2026-09-09 14:56:10 +01:00
c939f7c92e admin: list accounts that have no active sessions
The Accounts tab (readStore.ListAccounts) inner-joined the authorizations
aggregate, so any account with zero authorization rows was silently hidden -
accounts that never finished login, had all sessions revoked, or were frozen
then unfrozen. CountAccounts and SearchAccounts already LEFT JOIN, so the count
and search disagreed with the list.

Switch ListAccounts to LEFT JOIN auth and COALESCE the null last_active_at /
device_count (sessionless accounts sort last), matching SearchAccounts.
2026-09-09 14:56:10 +01:00
686adc1e10 Merge branch 'fix/forum-topic-replies-preview' 2026-09-09 14:45:06 +01:00
7083faa786 forum: let non-members preview topic replies in a public channel
ListChannelReplies used getChannelForMemberOrLinkedGuest, so messages.getReplies
was member-only. ListChannelHistory (flat history) uses getChannelForViewer and
already allows a public channel's non-members to preview it. The mismatch meant
that on a public forum you could preview the flat history but not the topics -
and after leaving, tdesktop's topic view got CHANNEL_PRIVATE and sat on
"Loading..." forever instead of rendering a preview.

Switch the primary channel lookup in ListChannelReplies (both stores) to the
viewer-scope path. Private channels still return CHANNEL_PRIVATE to non-members;
the broadcast comment-thread lookup is unchanged.
2026-09-09 14:43:59 +01:00
7d280b6f8a Merge branch 'build-metadata-stamping' 2026-09-09 14:27:28 +01:00
1c477fe32a build: stamp git metadata into the container image
.containerignore excludes .git, so go build's automatic VCS stamping produced
nothing and telesrv logged git_commit/git_branch/git_tree_state/build_time as
"unknown" on startup.

- Containerfile: accept GIT_COMMIT/GIT_BRANCH/GIT_TREE_STATE/BUILD_TIME build
  args and pass them to the gramsrv build via -ldflags -X.
- build.sh: wrapper that fills those args from the current checkout and runs
  podman build.
2026-09-09 14:26:58 +01:00
31a6ab4887 Merge pull request 'forum: project the forum's own channel with member state in getForumTopics' (#5) from fix/forum-topics-nonmember-left into main
Reviewed-on: #5
2026-09-09 14:53:56 +02:00
f94edc6cad forum: project the forum's own channel with member state in getForumTopics
messages.getForumTopics returned every chat via tgChannels -> tgChannelChatMin,
so the forum's own channel came back as a min object with left unset. A client
with no other object for that peer (a fresh account browsing a public forum by
username) then rendered the forum as already joined: topic list visible, no
Join button, but no messages.

Render the primary channel with tgChannelChatForView so a non-member preview
carries left=true; keep the other referenced channels as min.
2026-09-09 13:53:20 +01:00
5f65a47d89 Merge pull request 'welcome message: point updates-channel mention at @ziodotsh' (#4) from welcome-mention-ziodotsh into main
Reviewed-on: #4
2026-09-09 14:49:49 +02:00
7b662ad647 welcome message: point updates-channel mention at @ziodotsh
Rename the official updates channel mention from @zio to @ziodotsh in the
welcome message template and update the affected send-message test.
2026-09-09 13:49:07 +01:00
be3623797e Merge pull request 'channels: drop stale membership caches on join/leave' (#3) from fix/stale-membership-after-leave into main
Reviewed-on: #3
2026-09-09 14:34:04 +02:00
55a6e0bb35 channels: drop stale membership caches on join/leave
After channels.leaveChannel, a client that polls channels.getFullChannel kept
receiving a projection that still showed it as an active member (left=false)
until the per-(viewer,channel) RPC projection cache and the store-level member
cache lapsed on their own or the async read-model NOTIFY landed. The client
therefore kept an open compose box while every send was already rejected with
CHANNEL_PRIVATE - most visible on public forum supergroups, where getFullChannel
keeps succeeding via the preview path instead of tearing the chat down.

Every other membership-mutating path already busts these caches synchronously;
join/leave/invite/request-approval did not. Add:

- store: invalidateChannelMembershipCaches (row + member + dialog caches),
  called post-commit from JoinChannel, LeaveChannel, ImportInvite,
  InviteToChannel.
- rpc: invalidateChannelMembershipProjection (channelFullProjectionCache pair),
  called from the join/leave/invite/hide-requests handlers for every user whose
  membership changed.
2026-09-09 13:28:50 +01:00
2c782aab95 Merge pull request 'channels: force pre-history visible when a group gets a public username' (#2) from fix/prehistory-visible-on-publish into main
Reviewed-on: #2
2026-09-09 13:52:52 +02:00
879ff48b49 channels: force pre-history visible when a group gets a public username
New supergroups are created with "chat history for new members" hidden (the
client sets this right after creation, matching official Telegram). The
official server then forces it back to visible when the group is made public;
owpengram's UpdateUsername left the flag alone, leaving public groups in a
state where non-members (and post-join members) see no history at all.

UpdateUsername now clears pre_history_hidden whenever a non-empty username is
assigned, in the same transaction, with a matching admin-log event. Removing
the username leaves the flag untouched, so the creator can hide history again
once the group is private.
2026-09-09 12:51:23 +01:00
7daaaf164f Merge pull request 'channels: give getParticipants a stable Hash when read-model versions are missing' (#1) from fix/channel-participants-fallback-hash into main
Reviewed-on: astra/owpengram#1
2026-09-09 13:27:20 +02:00
045f39c3f9 channels: give getParticipants a stable Hash when read-model versions are missing
cachedParticipants returned a participant page with Hash=0 whenever the
channel_base / channel_participants rows in read_model_versions were never
seeded for a channel (e.g. groups created via messages.createChat). With
Hash=0 the RPC layer can never answer channels.channelParticipantsNotModified,
so a client that polls the member list re-fetches it in a tight loop forever.

Fall back to a deterministic content hash derived from the page itself
(channel id, page key, count, and each member's id/role/status/rank) so an
unchanged member list yields an identical non-zero Hash and the client
converges. The read-model-backed path is unchanged.
2026-09-09 12:26:30 +01:00
bf22bd4006 forum: fix reply_to_top_id for replies inside a forum
resolveChannelReply applied discussion-thread logic (reply_to_top_id =
the replied-to message's own id) to forum replies. Replying to a General
message produced reply_to_top_id = <that id>, a topic no client can
resolve: the reply vanished from every topic view and reply-jump on
strict clients said "message doesn't exist".

Forum replies now inherit the target's topic via domain.ForumReplyTopicID
(target's topic, or its own id if it's a topic-create, else General), and
General (topic 1) is accepted as a valid virtual topic everywhere, so
sends carrying top_msg_id: 1 are no longer rejected. Non-forum discussion
threads are unchanged.
2026-09-09 11:15:56 +01:00
6d34843fd0 forum: let non-members browse a public forum's topic list
ListForumTopics / GetForumTopicsByID / GeneralForumTopic gated on
membership while channel history uses the public-preview path, so a
public forum's topics (General included) were invisible until you joined.
Switch them to getChannelForViewer / channelForViewerLocked; private
forums and write paths keep the membership gate.
2026-09-09 11:15:56 +01:00
f26468ef6d media: never project zero image/video dimensions (crashes Telegram Desktop on reactions) 2026-09-08 16:29:37 +01:00
233a2399e9 welcome message: link @zio with a mention entity 2026-09-08 16:29:37 +01:00
f8aefb7fd5 payments: stub getStarGifts / getSavedStarGifts to stop tdesktop 500 retry storm 2026-09-08 16:29:37 +01:00
b3281d66d7 Containerfile: copy sticker-seed data into the image 2026-09-08 13:55:30 +01:00
8bc54fbfc5 /mybots: Edit Bot summary screen, return-to-menu, clickable @mentions
- Edit Bot now shows the current value of every field (Name/About/
  Description/Botpic/Commands) like BotFather, with real botpic status
  via a new PeerHasAvatar port method.
- After editing a field the dialog lands back on a fresh Edit Bot menu
  (working "Back to bot" / "Bots list" buttons) instead of ending, so a
  follow-up button press no longer reports the button as expired.
- Service-bot messages now render @username as a tappable mention entity.
2026-09-08 13:44:01 +01:00
165f81414c Add @zio channel invite to the sign-in welcome message 2026-09-08 13:31:39 +01:00
11142f74c5 Interactive /mybots menu for @BotFather
Button-driven bot management: paginated picker, per-bot API token /
revoke, Edit Bot (name/description/about/commands/botpic), Bot Settings
toggles (inline/groups/privacy), and delete. Navigation edits the menu
message in place via a new editServiceBotMessage helper.

Edit Botpic accepts a photo the user sends to @BotFather and sets it as
the bot's profile photo (new files.SetAvatarFromExistingPhoto, wired
through bots.SetBotUserpic / WithBotAvatarStore); photos.uploadProfilePhoto
does not accept a bot target so this is the only route.
2026-09-08 13:31:05 +01:00
1ebf8ddb5a Add Containerfile and .containerignore 2026-09-08 13:23:50 +01:00
80e99e8781 Don't 500 on channel sends with an unresolvable @mention
A channel post containing an @token that is not a syntactically valid
username (too short, leading digit, etc.) made messages.sendMessage return
500 INTERNAL_SERVER_ERROR: mentionedUserIDsFromMessage turned every
ResolveUsername error into internalErr().

Skip tokens that fail with ErrUsernameInvalid / ErrUsernameNotOccupied
instead, matching real Telegram (the message sends, the client renders the
mention and only fails to open it on tap). Only unexpected storage errors
still abort. Same fix applied to the bot send path.
2026-09-08 11:42:03 +01:00
110 changed files with 5490 additions and 383 deletions

5
.containerignore Normal file
View file

@ -0,0 +1,5 @@
.git
bin
*.pem
.env
.env.*

32
Containerfile Normal file
View 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
View 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
View 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)
}

View file

@ -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)

View file

@ -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 {

View file

@ -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

File diff suppressed because one or more lines are too long

View file

@ -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>

View file

@ -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) =>

View file

@ -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>

View 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,
);
}

View file

@ -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} />;
}

View file

@ -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";

View file

@ -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

View file

@ -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,

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS public.reserved_usernames;

View 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);

View 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
View file

@ -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
View file

@ -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=

View file

@ -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")

View file

@ -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)
}
}

View file

@ -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{

View file

@ -510,12 +510,30 @@ func (fakeService) ReviewModerationAppeal(context.Context, domain.ModerationDeci
type captureCollectibleUsernameService struct {
fakeService
mint admin.MintCollectibleUsernameRequest
transfer admin.TransferCollectibleUsernameRequest
revoke admin.RevokeCollectibleUsernameRequest
del admin.DeleteCollectibleUsernameRequest
filter domain.CollectibleUsernameFilter
assetID int64
mint admin.MintCollectibleUsernameRequest
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) {
@ -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)
}
}

View file

@ -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{

View file

@ -271,18 +271,26 @@ 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
}
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

View file

@ -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

View file

@ -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.`

View file

@ -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==ownerbot 发出的
// 那一份 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 并设置 joingroupsjoin=true或 privacyjoin=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
View 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.BotChatStateCommand="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
}

View 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)
}
}

View 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)
}
}

View file

@ -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) {

View file

@ -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

View file

@ -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},

View file

@ -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 != "" {

View file

@ -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

View file

@ -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)
}
}

View file

@ -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,

View file

@ -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)

View file

@ -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())

View 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
}

View file

@ -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,7 +315,9 @@ func (s *Service) ensureStickerMaterialShape(ctx context.Context, doc domain.Doc
case domain.DocAttrImageSize:
hasImageSize = true
case domain.DocAttrVideo:
hasVideo = true
if attr.W > 0 && attr.H > 0 {
hasVideo = true
}
}
}
switch mimeType {
@ -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 {

View file

@ -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(),
}
}

View file

@ -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,

View file

@ -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)
}
}

View file

@ -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()

View file

@ -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:

View file

@ -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",

View file

@ -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

View file

@ -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
}

View file

@ -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")
}
})
}
}

View file

@ -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")

View 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
}

View file

@ -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,

View file

@ -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.`

View 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
}

View file

@ -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,
}

View file

@ -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

View 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)
}
}
}

View file

@ -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
}

View file

@ -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.

View file

@ -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,

View file

@ -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.

View file

@ -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
}

View file

@ -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{
Text: button.Text,
ButtonID: button.ButtonID,
PeerType: tgRequestPeerTypeWithFilter(button.PeerType, button.PeerFilter),
MaxQuantity: button.MaxQuantity,
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,
},
}
}

View file

@ -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)

View file

@ -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()

View file

@ -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)
}

View file

@ -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)

View file

@ -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}

View file

@ -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)
}
return out
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
}

View file

@ -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{
NameRequested: true, UsernameRequested: true, PhotoRequested: true,
Text: "Share", ButtonID: 99, PeerType: &tg.RequestPeerTypeUser{}, MaxQuantity: 3,
button := tg.KeyboardButton{
Text: "Share",
Type: &tg.InputButtonTypeRequestPeer{
NameRequested: true, UsernameRequested: true, PhotoRequested: true,
ButtonID: 99, PeerType: &tg.RequestPeerTypeUser{}, MaxQuantity: 3,
},
}
got, err := domainRequestedButtonFromTG(1001, nil, button)
if err != nil {

View file

@ -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)

View file

@ -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):

View 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")
}
}

View 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")
}
}

View file

@ -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)

View file

@ -341,7 +341,10 @@ 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 {
return nil, internalErr()
if mentionResolveFatal(err) {
return nil, internalErr()
}
continue
}
if found {
add(user.ID)

View file

@ -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}}},
}}},
},
}

View file

@ -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,

View file

@ -437,7 +437,10 @@ 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 {
return nil, internalErr()
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

View file

@ -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) {

View file

@ -531,6 +531,13 @@ func (r *Router) withUserPresence(u domain.User) domain.User {
if u.Bot {
return u
}
// 已注销账号同理不参与 presencetgUser/tgSelfUser 对 Deleted 用户直接短路输出
// 精简 tombstone从不读取 Status这里覆盖与否本应无观测差异——但只靠那一层
// 短路里应外合:任何上游把 Deleted 弄丢的 bug例如缓存往返丢字段都会让这里
// 覆盖出的 Status 变成可见的“最近上线”,掩盖真正的 bug 而不是让它更早炸出来。
if u.Deleted {
return u
}
u.Status = r.userPresenceStatusForUser(u)
return u
}

View file

@ -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{}}}},

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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() {

View 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
}

View 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)
}
}

View file

@ -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 {

View file

@ -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, `

View file

@ -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

View file

@ -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