GetParticipant only converted a stale "left" row into USER_NOT_PARTICIPANT
when the participant being queried was the viewer themselves. Querying
about a different user who previously left/was kicked returned their stale
row as a successful result instead, which a client can't classify as a
proper participant type. Check the status regardless of who's asking.
The moderation case decision dropdown had no way to apply the new
restrict_account/unrestrict_account sanction; add it alongside freeze,
including the appeal-remedy auto-detection. Rebuilds web/dist so the
embedded panel binary picks up the change.
Adds a narrower spam sanction alongside the existing account freeze: a
restricted account keeps every existing membership and conversation, but
cannot join new channels/groups (public join or invite link) and cannot
start a new conversation with a non-contact. Reachable both as a standalone
admin action and as a decision on a reported user's moderation case, with
the same idempotent-supersession and appeal wiring freeze already has.
An email-signup account's users.phone is a random, meaningless "888"
placeholder (see internal/domain/emailphone.go, cmd/createuser) -- the
account's own My Account / Edit Profile screen should show the email it
was actually created with instead, matching what an official client
displays there.
Only tgSelfUser substitutes signup_email for the phone; tgUser (how other
viewers see this account) is untouched, since the phone field there is
already privacy-gated and substituting the email would leak it past that
gate to anyone allowed to see a phone.
UpdateUsernameAdmin already skipped the reserved-word check in the
availability lookup, but UserStore.UpdateUsername's own write path
(replacePeerUsernameTx / CollectibleUsernameStore.SetEditableUsername)
enforces the same operator blocklist a second time, independently and
unconditionally. That second check is what was still rejecting an admin
handing out a word they'd deliberately reserved, with "username occupied".
Add UpdateUsernameAdmin/SetEditableUsernameAdmin bypass variants down the
write path (postgres and memory) and route users.Service's actual write
through them when the availability check was already bypassed.
updateChannelParticipant carries the account's qts per the MTProto spec, but
the server always sent Qts: 0, so real clients silently discarded it as a
stale duplicate -- the banned/kicked user's channel never vanished locally
and no correct "removed by admin" message showed, even though the update was
delivered successfully at the transport layer.
Add a durable per-device qts queue (channel_participant_event_queue) sharing
its qts number space with the existing secret-chat queue (one qts sequence
per device, per spec), and use it to stamp a correct, monotonically
increasing qts on the update for every device of the affected user -- for
channel bans/kicks, admin promotion/demotion, and ownership transfer. A
device offline when it happened can now recover the event via
updates.getDifference instead of missing it permanently.
EditChannelAdmin reactivates a previously kicked/banned member to Active
status but left BannedRights.ViewMessages set from the old ban, an
Active+banned-from-viewing state nothing else in the codebase expects.
TransferChannelOwnership's participant check trips on it and rejects the
transfer with PARTICIPANT_MISSING, even though the target is now a
visibly active admin.
Add users.Service.UpdateUsernameAdmin, which skips the
config.ReservedUsernames block that self-service UpdateUsername enforces.
Operators need to be able to hand a reserved word to a specific account
even though regular users can't claim it themselves.
.containerignore excludes .env.* (to keep secrets out of the build), which
also matches .env.example, so COPY --from=build /src/.env.example failed:
that file was never actually present under /src in the build stage. Drop
it -- ReadEnvGroups now falls back to an empty list instead of crashing
when neither .env nor .env.example is present, and deploy hosts already
provide their own via a mounted .env.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ReadEnvGroups returned a nil slice (out := groups[:0]) whenever no group
ended up with fields, which marshals to JSON null instead of []. The
admin UI's EnvSection calls groups.reduce()/iterates unconditionally, so
that null crashed the Server Settings page.
Also switch the source of the panel structure from .env.example to .env
once .env exists, falling back to .env.example only pre-setup: .env
already carries the same group headers and comments after any save
(WriteEnvValues always rewrites it from .env.example's exact text), so
it's the more current source of what's actually configured.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ReadEnvGroups returned (nil, nil) when .env.example wasn't found, and that
nil slice marshaled to JSON null instead of []. The admin UI's env editor
unconditionally calls .reduce()/.map() on the response, so it crashed on
load with "Cannot read properties of null" -- and .env.example was never
copied into the container image in the first place, so this was always the
production code path, not an edge case.
Fixed the nil-slice response and added .env.example to the image so the
editor actually has a template of available settings to show. The real
current values still require the deploy host to mount its actual .env file
into the owpengram-admin container at /app/.env (procctl.Manager.Root is
just os.Getwd(), i.e. the container's /app workdir) -- that's a systemd
Quadlet unit change on the deploy host, not something this commit can fix on
its own.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
dist/index.html was pointing at index-DTpNyCcP.js, a bundle left over from
mid-rebase: three consecutive admin-UI commits ("match the reserved-usernames
page layout to the NFT page", "self-contained reserve-username modal", "use
the standard dry-run/confirm flow") each conflicted on dist/index.html, and
resolving each by keeping the already-applied side discarded that commit's
own frontend rebuild. The result was a built bundle several commits stale
relative to the actual ServerSettingsPage.tsx source, which crashed on load
(Cannot read properties of null (reading 'reduce')) since it no longer
matched the current API shape.
Rebuilt via `npm run build` against current source and removed the orphaned
stale bundles.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
botFatherHelpText became a func() string during the rebase onto upstream
(matching upstream's own runtime-configurable-branding-safe pattern); two
call sites still referenced it as a bare value.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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.
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.
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.
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>
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.
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.
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.
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.
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.
- 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.
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.
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.
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.
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.
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.
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.
.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.
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.
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.