From 9353953cf47ce3d49fb0d1ad4695f4191c072ebd Mon Sep 17 00:00:00 2001 From: Astra Date: Sun, 13 Sep 2026 17:59:24 +0100 Subject: [PATCH 1/2] 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. --- docs/telegram-feature-comparison.md | 286 ++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 docs/telegram-feature-comparison.md diff --git a/docs/telegram-feature-comparison.md b/docs/telegram-feature-comparison.md new file mode 100644 index 00000000..174fb19a --- /dev/null +++ b/docs/telegram-feature-comparison.md @@ -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 From 8df27528b920554d24a413749cf20ef861c00cd1 Mon Sep 17 00:00:00 2001 From: Astra Date: Sun, 13 Sep 2026 17:59:39 +0100 Subject: [PATCH 2/2] 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. --- cmd/createuser/main.go | 153 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 cmd/createuser/main.go diff --git a/cmd/createuser/main.go b/cmd/createuser/main.go new file mode 100644 index 00000000..579a4e0c --- /dev/null +++ b/cmd/createuser/main.go @@ -0,0 +1,153 @@ +// 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" + "flag" + "fmt" + "os" + "time" + + "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.Fprintf(os.Stderr, "createuser: id %d already exists (or insert failed): %v\n", *id, 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) +} + +// 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) +}