=l.t-n){c.h&&(c=l),i=0;break}if(l.t-n>e){i=a;break}a {"Share this link (a button, a QR code, a message) so anyone with the OwpenGram client can add this server in one tap. It only carries the address and port -- the client fetches the name, description, and key directly from the server itself, so the link can never be tampered with to point someone at a fake identity for this address."} {text} {described ? "No caption" : "No text"} {"Hi!"}
+ {"Let's get your server set up -- a name, an address for clients, and an account of "}
+ {"your own. Takes about a minute, and everything here stays editable later."}
+ {"Shown to clients when they add this server, and in the sidebar here."} {"Takes effect once setup finishes below -- that last step restarts the server."}
+ {"An HTTP gateway that lets bot libraries -- python-telegram-bot, aiogram and friends -- "}
+ {"talk to this server. Leave it off if you are not running bots; you can turn it on later in Server Settings."}
+ {"Replace the generated password with a login of your own."}
+ {"That's the essentials. Finishing restarts the server so the network and Bot API settings from "}
+ {"the earlier steps take effect. Everything here stays editable from Server Settings and Operators any time."}
+ {children} {"Enter credentials to open the console."}
+ {"Sent from the official system account on every completed sign-in. Use "}
+
+ {"Sent from the official system account with every login code (SMS and email alike). Must contain "}
+ {group.description}
+ {"Leave a category at 0 to inherit the shared Retention age. The mode switch still applies to all of them -- these only change how old that one category's media must be."}
+
+ {"Also affects built-in system bot avatars and the bundled GIF catalog (@gif), not just user media."}
+
+ {"Deletes the file bytes of every document/photo matching the categories below, right now -- independent of the retention mode/age configured above. The message/profile-photo itself is never deleted, only its file; a purged item starts showing as unavailable. Leave \"Created before\" empty to purge everything in the selected categories, regardless of age."}
+
+ {"Avatar also purges system bot avatars (auto-restored on next restart). GIF also purges the bundled GIF catalog (@gif) -- permanently, unless its source file is still in TELESRV_GIF_SEED_DIR."}
+ {"Loading current settings..."}
+ {retentionMode === "off" &&
+ "No storage sweep runs; nothing is auto-deleted. Storage usage is still tracked and shown above either way."}
+ {retentionMode === "orphan" &&
+ "Safe: a document or photo's file is deleted only once it is no longer referenced by any message, profile photo, or sticker set. Media still visible in a conversation is never touched, regardless of age."}
+ {retentionMode === "hard" &&
+ "Irreversible and aggressive: a document or photo's file bytes are deleted once old enough, REGARDLESS of whether a message still references it. Old media in active conversations will start showing as unavailable once purged -- only the file is removed, the message itself keeps rendering its placeholder (name, size, thumbnail)."}
+
+ {"Once total physical storage exceeds the Max total storage budget, actively delete the oldest files (regardless of category or age) until back under budget -- the same way \"hard\" retention mode purges files. Independent of the retention mode: this can run even when that's Off. Off by default, since this changes the storage budget from block-new-uploads-only to also reclaiming from existing files."}
+ =0;--o)if(h=this.shapes[o],h.shape._mdf){for(g=h.localShapeCollection,g.releaseShapes(),this.m===2&&s>1?(b=this.calculateShapeEdges(t,n,h.totalShapeLength,y,p),y+=h.totalShapeLength):b=[[_,v]],l=b.length,c=0;ct.e){n.c=!1;break}else t.s<=l&&t.e>=l+u.addedLength?(this.addSegment(i[a].v[s-1],i[a].o[s-1],i[a].i[s],i[a].v[s],n,d,g),g=!1):(p=Ae.getNewSegment(i[a].v[s-1],i[a].v[s],i[a].o[s-1],i[a].i[s],(t.s-l)/u.addedLength,(t.e-l)/u.addedLength,f[s-1]),this.addSegmentFromArray(p,n,d,g),g=!1,n.c=!1),l+=u.addedLength,d+=1;if(i[a].c&&f.length){if(u=f[s-1],l<=t.e){var _=f[s-1].addedLength;t.s<=l&&t.e>=l+_?(this.addSegment(i[a].v[s-1],i[a].o[s-1],i[a].i[0],i[a].v[0],n,d,g),g=!1):(p=Ae.getNewSegment(i[a].v[s-1],i[a].v[0],i[a].o[s-1],i[a].i[0],(t.s-l)/_,(t.e-l)/_,f[s-1]),this.addSegmentFromArray(p,n,d,g),g=!1,n.c=!1)}else n.c=!1;l+=u.addedLength,d+=1}if(n._length&&(n.setXYAt(n.v[h][0],n.v[h][1],`i`,h),n.setXYAt(n.v[n._length-1][0],n.v[n._length-1][1],`o`,n._length-1)),l>t.e)break;a=1?u=.99:u<=-1&&(u=-.99);var d=c*u,f=Math.cos(l+t.a.v)*d+o[0],p=Math.sin(l+t.a.v)*d+o[1];i=a.createRadialGradient(f,p,0,o[0],o[1],c)}var m,h=e.g.p,g=t.g.c,_=1;for(m=0;m{"Share server"}
+ {routeTitle(route.path)}
+ ) : (
+
{title}
+ {detail}}
+ {navigate && (
+
+ )}
+ ;
}
+// Placeholder rows for a table that has nothing yet *because it is still
+// loading* -- distinct from EmptyRow, which asserts the query genuinely
+// returned nothing. Showing "No results" during the first fetch reads as a
+// wrong answer rather than a pending one.
+export function LoadingRow({ colSpan, rows = 3 }: { colSpan: number; rows?: number }) {
+ return (
+ <>
+ {Array.from({ length: rows }, (_, row) => (
+ {"No results"}
+ {Array.from({ length: colSpan }, (_unused, cell) => (
+
+ ))}
+ >
+ );
+}
+
export function LoadingSurface({ label }: { label: string }) {
return
+
+
+ ))}
+
+
+
+
+
+
+
+ {/* The built-in operator first: it has the most rights and no
+ database row, so a list that started with the named accounts
+ would put the most powerful login last, or nowhere. */}
+ {system && (
+ {"Username"}
+ {"Can do"}
+ {"Status"}
+ {"Last login"}
+
+
+
+ )}
+ {rows.map((row) => (
+
+ {system.username} {"built-in"}
+
+
+ {"Enabled"}
+ {"—"}
+
+
+
+
+
+ ))}
+ {rows.length === 0 && !system &&
+ (busy || !loaded ? {row.username}
+
+
+ {row.enabled
+ ? {"Enabled"}
+ : {"Disabled"}}
+
+ {row.last_login_at ? new Date(row.last_login_at).toLocaleString() : "—"}
+
+
+
+
+ {title}
+ {`Password for ${operator.username}`}
+
{row.ID}
@@ -118,7 +118,7 @@ export function BroadcastsPage() {
{row.TargetMode === "all" ?
{row.SentCount}
{row.FailedCount > 0 ?
- {row.TotalCount}
+ {row.TargetCount}
{row.CreatedBy || "-"}
{formatDate(row.CreatedAt)}
diff --git a/cmd/telesrv-admin/web/src/pages/CollectibleUsernamesPage.tsx b/cmd/telesrv-admin/web/src/pages/CollectibleUsernamesPage.tsx
index cc92d2ae..764ce884 100644
--- a/cmd/telesrv-admin/web/src/pages/CollectibleUsernamesPage.tsx
+++ b/cmd/telesrv-admin/web/src/pages/CollectibleUsernamesPage.tsx
@@ -166,7 +166,7 @@ export function ownerLabel(row: CollectibleUsernameRow, vaultLabel: string): str
export function priceLabel(row: CollectibleUsernameRow): string {
const base = formatCurrency(row.Amount, row.Currency);
if (row.CryptoCurrency && row.CryptoAmount && row.CryptoAmount !== "0") {
- return `${base} (${formatCurrency(row.CryptoAmount, row.CryptoCurrency)})`;
+ return `${formatCurrency(row.CryptoAmount, row.CryptoCurrency)} (${base})`;
}
return base;
}
diff --git a/cmd/telesrv-admin/web/src/pages/Dashboard.tsx b/cmd/telesrv-admin/web/src/pages/Dashboard.tsx
index a3c702e9..4c30db7d 100644
--- a/cmd/telesrv-admin/web/src/pages/Dashboard.tsx
+++ b/cmd/telesrv-admin/web/src/pages/Dashboard.tsx
@@ -17,13 +17,19 @@ import {
} from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { api } from "../api";
+import { cacheGet, cacheKeys, cacheSet } from "../lib/cache";
import { Alert } from "../components/ui";
import type { Navigate } from "../routing";
import { formatBytes, formatQuantity } from "../lib/format";
import type { DashboardResponse } from "../types";
export function Dashboard({ navigate }: { navigate: Navigate }) {
- const [data, setData] = useState {"Stored rows (JSON)"}
+
+
+ setIconFailed(true)} />
+
- OwpenGram
+ {serverName}
{"Admin Console"}
{"Operations Admin"}
- {"Stored rows (JSON)"}
+ setIconFailed(true)}
+ />
+ ) : (
+
{"{{server_name}}"}
+ {" to insert the server's configured name."}
+ {"{{code}}"}
+ {" exactly once -- that's where the actual code is inserted and bolded. "}
+ {"{{server_name}}"}
+ {" is optional and may appear any number of times."}
+ {"Change server icon"}
+ {"Still restarting..."}
+ {"Restarting"}
+
+ that toggles ascending/descending on click and
+// shows which column (and direction) is currently active -- there's no
+// existing sortable-table convention elsewhere in this admin panel to
+// mirror, so this is a small, self-contained one for this page.
+function SortableHeader({
+ label,
+ sortKey,
+ activeKey,
+ desc,
+ onSort
+}: {
+ label: string;
+ sortKey: StorageSortKey;
+ activeKey: StorageSortKey;
+ desc: boolean;
+ onSort: (key: StorageSortKey) => void;
+}) {
+ const active = sortKey === activeKey;
+ return (
+
+
+
+ );
+}
+
+function StorageOverviewTab({ navigate }: { navigate: Navigate }) {
+ // Seeded from this session's last figures, so returning to Storage opens on
+ // numbers rather than on shimmering placeholders. loadStats still runs.
+ const [stats, setStats] = useState
-
@@ -100,9 +202,10 @@ export function StoragePage({ navigate: _navigate }: { navigate: Navigate }) {
{"User ID"}
- {"Account"}
- {"Storage used"}
- {"Files"}
+
{displayUsername(row.Username) || row.FirstName || "-"}
{formatBytes(row.Bytes)}
{formatQuantity(row.FileCount)}
+
))}
- {rows.length === 0 && {"Per-category overrides"}
+ {"Manually purge storage"}
+ tag).
+const ServerIconPath = "/owpengram/server-icon"
+
+// ServerInfoResponse is ServerInfoPath's JSON body. RSAPublicKeyPEM is the
+// PKCS#1 "RSA PUBLIC KEY" PEM block -- the same format
+// `openssl rsa -RSAPublicKey_out` produces, and what the client's "Add
+// Server" RSA key field already expects verbatim. Name/Description are
+// admin-edited via internal/identity and optional -- clients should treat
+// blank as "no override" and keep whatever the user typed.
+type ServerInfoResponse struct {
+ DCID int `json:"dc_id"`
+ RSAPublicKeyPEM string `json:"rsa_public_key_pem"`
+ Name string `json:"name,omitempty"`
+ Description string `json:"description,omitempty"`
+ // HasIcon tells the client whether GET ServerIconPath is worth calling,
+ // without requiring a separate round trip just to find out.
+ HasIcon bool `json:"has_icon,omitempty"`
+}
+
+// rsaPublicKeyPEM renders key's public half as a PKCS#1 PEM block, matching
+// `openssl rsa -in server_rsa.pem -RSAPublicKey_out` byte-for-byte (that
+// flag selects PKCS#1 encoding, not the x509/PKIX default `-pubout` would
+// produce -- a real format difference, not just a header/footer string).
+func rsaPublicKeyPEM(key *rsa.PrivateKey) []byte {
+ if key == nil {
+ return nil
+ }
+ der := x509.MarshalPKCS1PublicKey(&key.PublicKey)
+ return pem.EncodeToMemory(&pem.Block{Type: "RSA PUBLIC KEY", Bytes: der})
+}
+
+// serverInfoHTTPHandler serves ServerInfoResponse at ServerInfoPath and the
+// raw icon bytes at ServerIconPath, delegating every other path to next (the
+// existing WebSocket route handler). pubKeyPEM is nil when the server has no
+// RSA key configured (shouldn't happen in production -- handshakes would
+// already be broken -- but a client asking anyway gets 503, not a panic or
+// an empty key). identityStore may be nil (identity feature disabled);
+// Name/Description/icon are then simply omitted, RSA key/DC still serve.
+func serverInfoHTTPHandler(
+ next http.Handler,
+ dc int,
+ pubKeyPEM []byte,
+ identityStore *identity.Store,
+) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case ServerInfoPath:
+ serveServerInfo(w, r, dc, pubKeyPEM, identityStore)
+ case ServerIconPath:
+ serveServerIcon(w, r, identityStore)
+ default:
+ next.ServeHTTP(w, r)
+ }
+ })
+}
+
+func serveServerInfo(
+ w http.ResponseWriter,
+ r *http.Request,
+ dc int,
+ pubKeyPEM []byte,
+ identityStore *identity.Store,
+) {
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ w.Header().Set("Allow", "GET, HEAD")
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if len(pubKeyPEM) == 0 {
+ http.Error(w, "server key not configured", http.StatusServiceUnavailable)
+ return
+ }
+ resp := ServerInfoResponse{
+ DCID: dc,
+ RSAPublicKeyPEM: string(pubKeyPEM),
+ }
+ if identityStore != nil {
+ if info, err := identityStore.Get(); err == nil {
+ resp.Name = info.Name
+ resp.Description = info.Description
+ resp.HasIcon = info.IconExt != ""
+ }
+ }
+ body, err := json.Marshal(resp)
+ if err != nil {
+ http.Error(w, "encode server info", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ // See serveServerIcon's identical Content-Length comment -- same reason:
+ // keeps net/http from chunked-encoding a response the desktop client's
+ // raw-socket parser can't decode. name/description are short today, but
+ // nothing enforces that server-side, so this isn't purely defensive.
+ w.Header().Set("Content-Length", strconv.Itoa(len(body)))
+ if r.Method == http.MethodHead {
+ return
+ }
+ _, _ = w.Write(body)
+}
+
+func serveServerIcon(w http.ResponseWriter, r *http.Request, identityStore *identity.Store) {
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ w.Header().Set("Allow", "GET, HEAD")
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if identityStore == nil {
+ http.NotFound(w, r)
+ return
+ }
+ data, ext, ok := identityStore.Icon()
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ contentType := mime.TypeByExtension(ext)
+ if contentType == "" {
+ contentType = "application/octet-stream"
+ }
+ w.Header().Set("Content-Type", contentType)
+ w.Header().Set("Cache-Control", "no-store")
+ // Explicit Content-Length keeps net/http from switching to
+ // Transfer-Encoding: chunked, which it otherwise does automatically once
+ // a single Write() exceeds its small internal sniff buffer (true for
+ // basically any real icon, easily hundreds of KB) -- the desktop
+ // client's same-port fetch is a hand-rolled raw-socket HTTP/1.1 parser
+ // (see FetchServerIcon in owpengram_servers.cpp), not a real HTTP
+ // client, and has no chunked-decoding logic: it would otherwise treat
+ // the chunk-size-prefixed framing as image bytes and fail to decode.
+ w.Header().Set("Content-Length", strconv.Itoa(len(data)))
+ if r.Method == http.MethodHead {
+ return
+ }
+ _, _ = w.Write(data)
+}
diff --git a/internal/mtprotoedge/server_info_http_test.go b/internal/mtprotoedge/server_info_http_test.go
new file mode 100644
index 00000000..7b41db68
--- /dev/null
+++ b/internal/mtprotoedge/server_info_http_test.go
@@ -0,0 +1,105 @@
+package mtprotoedge
+
+import (
+ "crypto/rand"
+ "crypto/rsa"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "telesrv/internal/identity"
+)
+
+func TestServeServerInfoIncludesIdentity(t *testing.T) {
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatal(err)
+ }
+ pem := rsaPublicKeyPEM(key)
+
+ store := identity.NewStore(t.TempDir())
+ if err := store.SetText("OwpenGram", "A self-hosted server."); err != nil {
+ t.Fatal(err)
+ }
+
+ fallback := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })
+ h := serverInfoHTTPHandler(fallback, 2, pem, store)
+
+ req := httptest.NewRequest(http.MethodGet, ServerInfoPath, nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d", rec.Code)
+ }
+ var resp ServerInfoResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatal(err)
+ }
+ if resp.Name != "OwpenGram" || resp.Description != "A self-hosted server." {
+ t.Fatalf("got %+v", resp)
+ }
+ if resp.HasIcon {
+ t.Fatal("has_icon should be false before any upload")
+ }
+}
+
+func TestServeServerInfoWithoutIdentityStore(t *testing.T) {
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatal(err)
+ }
+ pem := rsaPublicKeyPEM(key)
+
+ fallback := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })
+ h := serverInfoHTTPHandler(fallback, 2, pem, nil)
+
+ req := httptest.NewRequest(http.MethodGet, ServerInfoPath, nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d", rec.Code)
+ }
+ var resp ServerInfoResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatal(err)
+ }
+ if resp.Name != "" || resp.Description != "" || resp.HasIcon {
+ t.Fatalf("expected empty identity fields when store is nil, got %+v", resp)
+ }
+ if resp.RSAPublicKeyPEM == "" {
+ t.Fatal("RSA key should still serve when identity store is nil")
+ }
+}
+
+func TestServeServerIcon(t *testing.T) {
+ store := identity.NewStore(t.TempDir())
+ fallback := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })
+ h := serverInfoHTTPHandler(fallback, 2, []byte("pem"), store)
+
+ // No icon configured yet -> 404.
+ req := httptest.NewRequest(http.MethodGet, ServerIconPath, nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404 before upload", rec.Code)
+ }
+
+ png := []byte{0x89, 'P', 'N', 'G', 1, 2, 3, 4}
+ if err := store.SetIcon(png, ".png"); err != nil {
+ t.Fatal(err)
+ }
+
+ req = httptest.NewRequest(http.MethodGet, ServerIconPath, nil)
+ rec = httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d", rec.Code)
+ }
+ if rec.Body.String() != string(png) {
+ t.Fatal("icon body mismatch")
+ }
+ if ct := rec.Header().Get("Content-Type"); ct != "image/png" {
+ t.Fatalf("Content-Type = %q", ct)
+ }
+}
diff --git a/internal/mtprotoedge/session_barrier_integration_test.go b/internal/mtprotoedge/session_barrier_integration_test.go
index 908ddd5e..05273c0a 100644
--- a/internal/mtprotoedge/session_barrier_integration_test.go
+++ b/internal/mtprotoedge/session_barrier_integration_test.go
@@ -77,7 +77,7 @@ func TestBadServerSaltRetainsOneProvisionalConnUntilCorrected(t *testing.T) {
cs := newConnState()
var plain bin.Buffer
- firstConn, err := s.handleEncrypted(context.Background(), tr, cs, nil, &stored, firstWrong, &plain)
+ firstConn, err := s.handleEncrypted(context.Background(), tr, cs, nil, "", &stored, firstWrong, &plain)
if err != nil {
t.Fatalf("first bad salt: %v", err)
}
@@ -92,7 +92,7 @@ func TestBadServerSaltRetainsOneProvisionalConnUntilCorrected(t *testing.T) {
secondWrong, _ := encryptedRPCFrameWithAuthoritativeSaltForBarrierTest(
t, key, wrongSalt, serverSalt, sessionID, secondID, 3,
)
- secondConn, err := s.handleEncrypted(context.Background(), tr, cs, firstConn, nil, secondWrong, &plain)
+ secondConn, err := s.handleEncrypted(context.Background(), tr, cs, firstConn, "", nil, secondWrong, &plain)
if err != nil {
t.Fatalf("second bad salt: %v", err)
}
@@ -123,7 +123,7 @@ func TestBadServerSaltRetainsOneProvisionalConnUntilCorrected(t *testing.T) {
corrected, _ := encryptedRPCFrameWithAuthoritativeSaltForBarrierTest(
t, key, serverSalt, serverSalt, sessionID, firstID, 1,
)
- activeConn, err := s.handleEncrypted(context.Background(), tr, cs, secondConn, nil, corrected, &plain)
+ activeConn, err := s.handleEncrypted(context.Background(), tr, cs, secondConn, "", nil, corrected, &plain)
if err != nil {
t.Fatalf("corrected retry: %v", err)
}
@@ -171,7 +171,7 @@ func TestWrongSaltSessionChangeTransfersPhysicalOwnership(t *testing.T) {
}
cs := newConnState()
var plain bin.Buffer
- oldConn, err := s.handleEncrypted(context.Background(), tr, cs, nil, &stored, firstFrame, &plain)
+ oldConn, err := s.handleEncrypted(context.Background(), tr, cs, nil, "", &stored, firstFrame, &plain)
if err != nil {
t.Fatalf("activate first session: %v", err)
}
@@ -185,7 +185,7 @@ func TestWrongSaltSessionChangeTransfersPhysicalOwnership(t *testing.T) {
wrongFrame, _ := encryptedRPCFrameWithAuthoritativeSaltForBarrierTest(
t, key, wrongSalt, serverSalt, secondSID, secondID, 1,
)
- newConn, err := s.handleEncrypted(context.Background(), tr, cs, oldConn, nil, wrongFrame, &plain)
+ newConn, err := s.handleEncrypted(context.Background(), tr, cs, oldConn, "", nil, wrongFrame, &plain)
if err != nil {
t.Fatalf("new session bad salt: %v", err)
}
@@ -205,7 +205,7 @@ func TestWrongSaltSessionChangeTransfersPhysicalOwnership(t *testing.T) {
corrected, _ := encryptedRPCFrameWithAuthoritativeSaltForBarrierTest(
t, key, serverSalt, serverSalt, secondSID, secondID, 1,
)
- activated, err := s.handleEncrypted(context.Background(), tr, cs, newConn, nil, corrected, &plain)
+ activated, err := s.handleEncrypted(context.Background(), tr, cs, newConn, "", nil, corrected, &plain)
if err != nil {
t.Fatalf("activate transferred session: %v", err)
}
@@ -318,7 +318,7 @@ func TestHandleEncryptedRequiredSessionBarrierPrecedesStateRegistrationAndRPC(t
}
done := make(chan result, 1)
go func() {
- conn, err := s.handleEncrypted(context.Background(), tr, cs, nil, &stored, frame, &plain)
+ conn, err := s.handleEncrypted(context.Background(), tr, cs, nil, "", &stored, frame, &plain)
done <- result{conn: conn, err: err}
}()
@@ -394,7 +394,7 @@ func TestHandleEncryptedRequiredSessionBarrierFailureIsAtomic(t *testing.T) {
done := make(chan error, 1)
go func() {
- _, err := s.handleEncrypted(context.Background(), tr, cs, nil, &stored, frame, &plain)
+ _, err := s.handleEncrypted(context.Background(), tr, cs, nil, "", &stored, frame, &plain)
done <- err
}()
select {
@@ -458,7 +458,7 @@ func TestCrossConnectionInflightRPCHasOneBusinessOwnerAndReplaysResult(t *testin
firstTransport := &collectingSessionTransport{}
firstState := newConnState()
var firstPlain bin.Buffer
- firstConn, err := s.handleEncrypted(context.Background(), firstTransport, firstState, nil, &stored, firstFrame, &firstPlain)
+ firstConn, err := s.handleEncrypted(context.Background(), firstTransport, firstState, nil, "", &stored, firstFrame, &firstPlain)
if err != nil {
t.Fatalf("first handleEncrypted: %v", err)
}
@@ -478,7 +478,7 @@ func TestCrossConnectionInflightRPCHasOneBusinessOwnerAndReplaysResult(t *testin
}
secondDone := make(chan handleResult, 1)
go func() {
- conn, handleErr := s.handleEncrypted(context.Background(), secondTransport, secondState, nil, &stored, secondFrame, &secondPlain)
+ conn, handleErr := s.handleEncrypted(context.Background(), secondTransport, secondState, nil, "", &stored, secondFrame, &secondPlain)
secondDone <- handleResult{conn: conn, err: handleErr}
}()
@@ -566,7 +566,7 @@ func TestCrossConnectionInflightAbortRetriesOnlyAfterOldOwnerStops(t *testing.T)
firstTransport := &collectingSessionTransport{}
firstState := newConnState()
var firstPlain bin.Buffer
- firstConn, err := s.handleEncrypted(context.Background(), firstTransport, firstState, nil, &stored, firstFrame, &firstPlain)
+ firstConn, err := s.handleEncrypted(context.Background(), firstTransport, firstState, nil, "", &stored, firstFrame, &firstPlain)
if err != nil {
t.Fatalf("first handleEncrypted: %v", err)
}
@@ -580,7 +580,7 @@ func TestCrossConnectionInflightAbortRetriesOnlyAfterOldOwnerStops(t *testing.T)
secondTransport := &collectingSessionTransport{}
secondState := newConnState()
var secondPlain bin.Buffer
- secondConn, err := s.handleEncrypted(context.Background(), secondTransport, secondState, nil, &stored, secondFrame, &secondPlain)
+ secondConn, err := s.handleEncrypted(context.Background(), secondTransport, secondState, nil, "", &stored, secondFrame, &secondPlain)
if err != nil && !errors.Is(err, ErrConnClosed) {
t.Fatalf("second handleEncrypted: %v", err)
}
@@ -605,7 +605,7 @@ func TestCrossConnectionInflightAbortRetriesOnlyAfterOldOwnerStops(t *testing.T)
thirdTransport := &collectingSessionTransport{}
thirdState := newConnState()
var thirdPlain bin.Buffer
- thirdConn, err := s.handleEncrypted(context.Background(), thirdTransport, thirdState, nil, &stored, thirdFrame, &thirdPlain)
+ thirdConn, err := s.handleEncrypted(context.Background(), thirdTransport, thirdState, nil, "", &stored, thirdFrame, &thirdPlain)
if err != nil {
t.Fatalf("third handleEncrypted: %v", err)
}
@@ -614,9 +614,6 @@ func TestCrossConnectionInflightAbortRetriesOnlyAfterOldOwnerStops(t *testing.T)
if got := handler.max.Load(); got != 1 {
t.Fatalf("old and retry handlers overlapped: max active=%d, want 1", got)
}
- if got := s.rpcResults.flightLimit.snapshot(); got != 0 {
- t.Fatalf("sequential retry leaked flight claims: %d", got)
- }
resultCount := 0
deadline = time.Now().Add(2 * time.Second)
@@ -640,6 +637,11 @@ func TestCrossConnectionInflightAbortRetriesOnlyAfterOldOwnerStops(t *testing.T)
if resultCount != 1 {
t.Fatalf("sequential retry result count = %d, want 1", resultCount)
}
+ // Scheduler task completion only proves that result encoding/enqueue has
+ // finished. The outbound actor publishes the terminal execution receipt
+ // after the physical write, so inspect the flight only after observing that
+ // result rather than racing the actor callback.
+ waitForRPCFlightClaims(t, s.rpcResults, 0)
if !firstConn.isRetired() || !secondConn.isRetired() || thirdConn == nil || !thirdConn.isActive() {
t.Fatalf("replacement lifecycle = first:%v second:%v third:%p active:%v", firstConn.lifecycleState(), secondConn.lifecycleState(), thirdConn, thirdConn != nil && thirdConn.isActive())
}
@@ -656,3 +658,14 @@ func waitForAtomicCalls(t *testing.T, calls interface{ Load() int32 }, want int3
t.Fatalf("handler calls = %d, want %d", got, want)
}
}
+
+func waitForRPCFlightClaims(t *testing.T, ledger *rpcExecutionLedger, want int64) {
+ t.Helper()
+ deadline := time.Now().Add(2 * time.Second)
+ for ledger.flightLimit.snapshot() != want && time.Now().Before(deadline) {
+ time.Sleep(time.Millisecond)
+ }
+ if got := ledger.flightLimit.snapshot(); got != want {
+ t.Fatalf("rpc flight claims = %d, want %d", got, want)
+ }
+}
diff --git a/internal/mtprotoedge/session_manager.go b/internal/mtprotoedge/session_manager.go
index 9c851dd4..54be5dde 100644
--- a/internal/mtprotoedge/session_manager.go
+++ b/internal/mtprotoedge/session_manager.go
@@ -61,6 +61,10 @@ const (
maxChannelSubscriptionsPerSession = 10
defaultChannelSubscriptionTTL = 75 * time.Second
maxChannelSubscriptionTTL = 2 * time.Minute
+ // A claim is normally released by its rpc_result delivery callback or the
+ // pending-update flush it starts. This lease only recovers the exceptional
+ // path where result encoding is replaced before the callback can be attached.
+ updatesActivationClaimTTL = time.Minute
)
// forceCloseBatchTimeout is one deadline for a whole revoke/replace/eviction batch. Conn.Close
@@ -201,7 +205,9 @@ type SessionManager struct {
// 去 Google 化设备)下仍能收到来电、消息等实时推送。登记后在 pushToUserWithSender
// 中被视为【永久就绪】,绕过 receivesUpdates 门槛直接投递,而不是排队等一个永远
// 不会到来的 getState。See memory: call-inactive-account-network-pause。
- pushSessions map[[8]byte]map[int64]struct{}
+ pushSessions map[[8]byte]map[int64]struct{}
+ updatesActivationSeq uint64
+ bootstrapProbeSeq uint64
lifecycle SessionLifecycleObserver
log *zap.Logger
@@ -821,6 +827,8 @@ func (m *SessionManager) bindUserLocked(c *Conn, key sessionKey, userID int64) {
if old != userID {
m.clearSessionChannelIndexesLocked(c, key)
c.membershipsSynced.Store(false)
+ m.clearUpdatesActivationLocked(c)
+ m.clearBootstrapProbeLocked(c)
// 身份变化即丢弃暂存推送:它们属于前一个账号,flush 给新账号是跨账号泄露。
// 同时取消进行中的排空(runFlush 还另有 owner 校验做批内兜底)。
m.deletePendingLocked(key)
@@ -833,6 +841,8 @@ func (m *SessionManager) bindUserLocked(c *Conn, key sessionKey, userID int64) {
} else {
m.clearSessionChannelIndexesLocked(c, key)
c.membershipsSynced.Store(false)
+ m.clearUpdatesActivationLocked(c)
+ m.clearBootstrapProbeLocked(c)
m.deletePendingLocked(key)
delete(m.flushing, key)
}
@@ -898,6 +908,8 @@ func (m *SessionManager) bindAuthKeyLocked(c *Conn, key sessionKey, authKeyID [8
}
m.clearSessionChannelIndexesLocked(c, key)
c.membershipsSynced.Store(false)
+ m.clearUpdatesActivationLocked(c)
+ m.clearBootstrapProbeLocked(c)
m.deletePendingLocked(key)
delete(m.flushing, key)
c.userID.Store(0)
@@ -1166,6 +1178,8 @@ func (m *SessionManager) UnbindAuthKey(authKeyID [8]byte) int {
}
m.clearSessionChannelIndexesLocked(c, key)
c.membershipsSynced.Store(false)
+ m.clearUpdatesActivationLocked(c)
+ m.clearBootstrapProbeLocked(c)
// 授权解除后暂存推送属于已登出的账号,不能等下一个登录者置位时 flush 出去。
m.deletePendingLocked(key)
delete(m.flushing, key)
@@ -1185,6 +1199,7 @@ func (m *SessionManager) setReceivesUpdatesLocked(c *Conn, key sessionKey, recei
c.receivesUpdates.Store(false)
m.clearSessionChannelIndexesLocked(c, key)
c.membershipsSynced.Store(false)
+ m.clearUpdatesActivationLocked(c)
// 取消进行中的排空激活:runFlush 在置位前会复查该标志,标志已删则放弃置位,
// 避免把刚置 false 的开关翻回 true。
delete(m.flushing, key)
@@ -1198,11 +1213,15 @@ func (m *SessionManager) setReceivesUpdatesLocked(c *Conn, key sessionKey, recei
c.receivesUpdates.Store(false)
m.clearSessionChannelIndexesLocked(c, key)
c.membershipsSynced.Store(false)
+ m.clearUpdatesActivationLocked(c)
delete(m.flushing, key)
return 0, false
}
if c.receivesUpdates.Load() || m.flushing[key] {
// 已就绪,或已有排空协程在跑(完成时会自行取走新增暂存并置位)。
+ if c.receivesUpdates.Load() {
+ m.clearUpdatesActivationLocked(c)
+ }
return 0, false
}
if len(m.pending[key]) == 0 {
@@ -1232,6 +1251,7 @@ func (m *SessionManager) runFlush(c *Conn, key sessionKey, owner int64, attempt
// 排空期间发生登出/换号:剩余暂存属于旧账号,丢弃且不得发给新账号。
m.deletePendingLocked(key)
delete(m.flushing, key)
+ m.clearUpdatesActivationLocked(c)
m.mu.Unlock()
return
}
@@ -1239,6 +1259,7 @@ func (m *SessionManager) runFlush(c *Conn, key sessionKey, owner int64, attempt
if len(batch) == 0 {
c.receivesUpdates.Store(true)
delete(m.flushing, key)
+ m.clearUpdatesActivationLocked(c)
m.mu.Unlock()
return
}
@@ -1250,6 +1271,7 @@ func (m *SessionManager) runFlush(c *Conn, key sessionKey, owner int64, attempt
m.mu.Lock()
m.deletePendingLocked(key)
delete(m.flushing, key)
+ m.clearUpdatesActivationLocked(c)
m.mu.Unlock()
releaseQueuedPushes(batch[i:])
return
@@ -1296,6 +1318,7 @@ func (m *SessionManager) runFlush(c *Conn, key sessionKey, owner int64, attempt
if c.userID.Load() != owner {
m.deletePendingLocked(key)
delete(m.flushing, key)
+ m.clearUpdatesActivationLocked(c)
}
m.mu.Unlock()
releaseQueuedPushes(batch[i:])
@@ -1316,6 +1339,7 @@ func (m *SessionManager) runFlush(c *Conn, key sessionKey, owner int64, attempt
c.receivesUpdates.Store(true)
m.deletePendingLocked(key)
delete(m.flushing, key)
+ m.clearUpdatesActivationLocked(c)
m.mu.Unlock()
m.log.Debug("Flush gave up after retries; activated with getDifference fallback",
zap.String("auth_key_id", sessionKeyLog(key.authKeyID)),
@@ -1356,6 +1380,119 @@ func (m *SessionManager) ReceivesUpdatesForAuthKey(authKeyID [8]byte, sessionID
return hasProfile && c.receivesUpdates.Load() && c.membershipsSynced.Load()
}
+// BeginSessionUpdatesActivation claims the readiness transition for the
+// current physical connection. Ordinary startup RPCs race here before they
+// register delivery hooks, so at most one of them can enqueue the expensive
+// channel-membership synchronization. Cursor commits remain request-owned.
+func (m *SessionManager) BeginSessionUpdatesActivation(authKeyID [8]byte, sessionID int64) (uint64, bool) {
+ if m == nil {
+ return 0, false
+ }
+ key := sessionKey{authKeyID: authKeyID, sessionID: sessionID}
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ c := m.bySession[key]
+ if c == nil || c.isRetired() {
+ return 0, false
+ }
+ if _, hasProfile := c.LayerProfile(); hasProfile && c.receivesUpdates.Load() && c.membershipsSynced.Load() {
+ return 0, false
+ }
+ now := time.Now()
+ if c.now != nil {
+ now = c.now()
+ }
+ if c.updatesActivationToken != 0 {
+ // A pending FIFO flush owns the activation until it reaches a terminal
+ // outcome. Never lease-steal while that ordered delivery is in progress.
+ if m.flushing[key] || now.Sub(c.updatesActivationAt) < updatesActivationClaimTTL {
+ return 0, false
+ }
+ }
+ m.updatesActivationSeq++
+ if m.updatesActivationSeq == 0 {
+ m.updatesActivationSeq++
+ }
+ c.updatesActivationToken = m.updatesActivationSeq
+ c.updatesActivationAt = now
+ return c.updatesActivationToken, true
+}
+
+// EndSessionUpdatesActivation releases only the token owned by the caller and
+// only on the same current physical Conn. If SetReceivesUpdates started an
+// ordered pending flush, that flush retains and releases the claim itself.
+func (m *SessionManager) EndSessionUpdatesActivation(authKeyID [8]byte, sessionID int64, token uint64) {
+ if m == nil || token == 0 {
+ return
+ }
+ key := sessionKey{authKeyID: authKeyID, sessionID: sessionID}
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ c := m.bySession[key]
+ if c == nil || c.updatesActivationToken != token || m.flushing[key] {
+ return
+ }
+ m.clearUpdatesActivationLocked(c)
+}
+
+// BeginSessionBootstrapProbe claims the first durable bootstrap-job lookup for
+// the current physical connection generation. Unlike updates activation, this
+// is completed only by a delivered getState/getDifference baseline.
+func (m *SessionManager) BeginSessionBootstrapProbe(authKeyID [8]byte, sessionID int64) (uint64, bool) {
+ if m == nil {
+ return 0, false
+ }
+ key := sessionKey{authKeyID: authKeyID, sessionID: sessionID}
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ c := m.bySession[key]
+ if c == nil || c.isRetired() || c.bootstrapProbed || c.bootstrapProbeToken != 0 {
+ return 0, false
+ }
+ m.bootstrapProbeSeq++
+ if m.bootstrapProbeSeq == 0 {
+ m.bootstrapProbeSeq++
+ }
+ c.bootstrapProbeToken = m.bootstrapProbeSeq
+ return c.bootstrapProbeToken, true
+}
+
+// EndSessionBootstrapProbe completes or releases only the token on the same
+// current Conn. A delayed callback from a replaced connection cannot mutate the
+// replacement's one-shot state.
+func (m *SessionManager) EndSessionBootstrapProbe(authKeyID [8]byte, sessionID int64, token uint64, success bool) {
+ if m == nil || token == 0 {
+ return
+ }
+ key := sessionKey{authKeyID: authKeyID, sessionID: sessionID}
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ c := m.bySession[key]
+ if c == nil || c.bootstrapProbeToken != token {
+ return
+ }
+ c.bootstrapProbeToken = 0
+ if success {
+ c.bootstrapProbed = true
+ }
+}
+
+func (m *SessionManager) clearUpdatesActivationLocked(c *Conn) {
+ if c == nil {
+ return
+ }
+ c.updatesActivationToken = 0
+ c.updatesActivationAt = time.Time{}
+}
+
+func (m *SessionManager) clearBootstrapProbeLocked(c *Conn) {
+ if c == nil {
+ return
+ }
+ c.bootstrapProbeToken = 0
+ c.bootstrapProbed = false
+}
+
// SetReceivesUpdatesForAuthKey 标记指定 raw auth_key_id + session_id 是否接收主动 updates。
func (m *SessionManager) SetReceivesUpdatesForAuthKey(authKeyID [8]byte, sessionID int64, receives bool) {
m.mu.Lock()
@@ -1470,11 +1607,32 @@ func (m *SessionManager) PushToUserAuthKeyTransient(ctx context.Context, userID
return m.pushToBusinessAuthKeyBestEffort(ctx, userID, businessAuthKeyID, 0, t, msg, timeout)
}
-func (m *SessionManager) PushToUserAuthKeyTransientAtLeastLayer(ctx context.Context, userID int64, businessAuthKeyID [8]byte, minLayer int, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) {
- return m.pushToBusinessAuthKeyBestEffort(ctx, userID, businessAuthKeyID, minLayer, t, msg, timeout)
+func (m *SessionManager) PushToUserAuthKeyTransientCompatible(ctx context.Context, userID int64, businessAuthKeyID [8]byte, semantic tlprofile.SemanticID, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) {
+ return m.pushToBusinessAuthKeyBestEffort(ctx, userID, businessAuthKeyID, semantic, t, msg, timeout)
}
-func (m *SessionManager) pushToBusinessAuthKeyBestEffort(ctx context.Context, userID int64, businessAuthKeyID [8]byte, minLayer int, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) {
+// PushToUserExceptBusinessAuthKey 把 update 投给账号其它设备,精确排除同一 permanent
+// business auth key 下的所有 raw/temp/PFS 连接。密聊 accept 用它让输掉竞态的设备收敛为
+// discarded,同时保证获胜设备的其它连接不会误删刚建立的密聊。
+func (m *SessionManager) PushToUserExceptBusinessAuthKey(ctx context.Context, userID int64, excludeBusinessAuthKeyID [8]byte, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) {
+ getUpdates := onceLayerUpdatesFanout(ctx, msg)
+ return m.pushToUserWithSender(ctx, userID, nil, 0, &excludeBusinessAuthKeyID, 0, t, getUpdates, false, func(c *Conn) error {
+ if c.outbound == nil || c.outboundControl == nil {
+ return ErrConnClosed
+ }
+ updates, err := getUpdates()
+ if err != nil {
+ return err
+ }
+ encoded, err := updates.prepareForConn(ctx, c)
+ if err != nil {
+ return err
+ }
+ return c.SendBestEffortEncoded(ctx, t, encoded, timeout)
+ })
+}
+
+func (m *SessionManager) pushToBusinessAuthKeyBestEffort(ctx context.Context, userID int64, businessAuthKeyID [8]byte, semantic tlprofile.SemanticID, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) {
if ctx != nil && ctx.Err() != nil {
return 0, ctx.Err()
}
@@ -1497,7 +1655,7 @@ func (m *SessionManager) pushToBusinessAuthKeyBestEffort(ctx context.Context, us
defer cancel()
}
getUpdates := onceLayerUpdatesFanout(sendCtx, msg)
- return m.pushToBusinessAuthKey(ctx, userID, businessAuthKeyID, minLayer, func(c *Conn) error {
+ return m.pushToBusinessAuthKey(ctx, userID, businessAuthKeyID, semantic, func(c *Conn) error {
if c.outbound == nil || c.outboundControl == nil {
return ErrConnClosed
}
@@ -1520,7 +1678,7 @@ func (m *SessionManager) pushToBusinessAuthKeyBestEffort(ctx context.Context, us
})
}
-func (m *SessionManager) pushToBusinessAuthKey(ctx context.Context, userID int64, businessAuthKeyID [8]byte, minLayer int, send func(*Conn) error) (int, error) {
+func (m *SessionManager) pushToBusinessAuthKey(ctx context.Context, userID int64, businessAuthKeyID [8]byte, semantic tlprofile.SemanticID, send func(*Conn) error) (int, error) {
m.mu.Lock()
candidates := m.businessAuthKeyCandidatesLocked(businessAuthKeyID)
conns := make([]*Conn, 0, len(candidates))
@@ -1532,7 +1690,7 @@ func (m *SessionManager) pushToBusinessAuthKey(ctx context.Context, userID int64
// 未就绪:密聊消息靠 getDifference 补,typing 直接丢——都不进 pending。
continue
}
- if !sessionSupportsMinimumLayer(c, minLayer) {
+ if !sessionSupportsSemantic(c, semantic) {
continue
}
conns = append(conns, c)
@@ -1579,7 +1737,7 @@ func (m *SessionManager) pushToBusinessAuthKey(ctx context.Context, userID int64
func (m *SessionManager) pushToUser(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, t proto.MessageType, msg tg.UpdatesClass) (int, error) {
getUpdates := onceLayerUpdatesFanout(ctx, msg)
- return m.pushToUserWithSender(ctx, userID, excludeAuthKeyID, excludeSessionID, 0, t, getUpdates, true, func(c *Conn) error {
+ return m.pushToUserWithSender(ctx, userID, excludeAuthKeyID, excludeSessionID, nil, 0, t, getUpdates, true, func(c *Conn) error {
if c.outbound == nil || c.outboundControl == nil {
return ErrConnClosed
}
@@ -1602,7 +1760,7 @@ func (m *SessionManager) pushToUser(ctx context.Context, userID int64, excludeAu
// 「durable 兜底」丢弃。走 best-effort 发送,不阻塞调用方。
func (m *SessionManager) PushToUserTransientExceptAuthKeySession(ctx context.Context, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) {
getUpdates := onceLayerUpdatesFanout(ctx, msg)
- return m.pushToUserWithSender(ctx, userID, &excludeAuthKeyID, excludeSessionID, 0, t, getUpdates, false, func(c *Conn) error {
+ return m.pushToUserWithSender(ctx, userID, &excludeAuthKeyID, excludeSessionID, nil, 0, t, getUpdates, false, func(c *Conn) error {
if c.outbound == nil || c.outboundControl == nil {
return ErrConnClosed
}
@@ -1618,9 +1776,9 @@ func (m *SessionManager) PushToUserTransientExceptAuthKeySession(ctx context.Con
})
}
-func (m *SessionManager) PushToUserTransientAtLeastLayer(ctx context.Context, userID int64, minLayer int, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) {
+func (m *SessionManager) PushToUserTransientCompatible(ctx context.Context, userID int64, semantic tlprofile.SemanticID, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) {
getUpdates := onceLayerUpdatesFanout(ctx, msg)
- return m.pushToUserWithSender(ctx, userID, nil, 0, minLayer, t, getUpdates, false, func(c *Conn) error {
+ return m.pushToUserWithSender(ctx, userID, nil, 0, nil, semantic, t, getUpdates, false, func(c *Conn) error {
if c.outbound == nil || c.outboundControl == nil {
return ErrConnClosed
}
@@ -1641,10 +1799,6 @@ func (m *SessionManager) PushToUserExceptAuthKeySessionBestEffort(ctx context.Co
}
func (m *SessionManager) pushToUserBestEffort(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) {
- return m.pushToUserBestEffortAtLeastLayer(ctx, userID, excludeAuthKeyID, excludeSessionID, 0, t, msg, timeout)
-}
-
-func (m *SessionManager) pushToUserBestEffortAtLeastLayer(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, minLayer int, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) {
if ctx != nil && ctx.Err() != nil {
return 0, ctx.Err()
}
@@ -1670,7 +1824,7 @@ func (m *SessionManager) pushToUserBestEffortAtLeastLayer(ctx context.Context, u
defer cancel()
}
getUpdates := onceLayerUpdatesFanout(sendCtx, msg)
- return m.pushToUserWithSender(ctx, userID, excludeAuthKeyID, excludeSessionID, minLayer, t, getUpdates, true, func(c *Conn) error {
+ return m.pushToUserWithSender(ctx, userID, excludeAuthKeyID, excludeSessionID, nil, 0, t, getUpdates, true, func(c *Conn) error {
if c.outbound == nil || c.outboundControl == nil {
return ErrConnClosed
}
@@ -1717,7 +1871,7 @@ func onceLayerUpdatesFanout(ctx context.Context, msg tg.UpdatesClass) func() (*l
}
}
-func (m *SessionManager) pushToUserWithSender(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, minLayer int, t proto.MessageType, getUpdates func() (*layerUpdatesFanout, error), queueWhenNotReady bool, send func(*Conn) error) (int, error) {
+func (m *SessionManager) pushToUserWithSender(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, excludeBusinessAuthKeyID *[8]byte, semantic tlprofile.SemanticID, t proto.MessageType, getUpdates func() (*layerUpdatesFanout, error), queueWhenNotReady bool, send func(*Conn) error) (int, error) {
// push fan-out 是连接层最热路径之一:debug 日志的字段构造(含 auth_key hex 格式化)
// 在关闭 debug 时也会求值,先查级别一次、按需记日志。
debug := m.log.Core().Enabled(zapcore.DebugLevel)
@@ -1733,11 +1887,11 @@ func (m *SessionManager) pushToUserWithSender(ctx context.Context, userID int64,
skipped := 0
needQueue := false
for key, c := range m.byUser[userID] {
- if shouldExcludeSession(c, excludeAuthKeyID, excludeSessionID) {
+ if shouldExcludeSession(c, excludeAuthKeyID, excludeSessionID) || shouldExcludeBusinessAuthKey(c, excludeBusinessAuthKeyID) {
excluded++
continue
}
- if !sessionSupportsMinimumLayer(c, minLayer) {
+ if !sessionSupportsSemantic(c, semantic) {
skipped++
continue
}
@@ -1768,11 +1922,11 @@ func (m *SessionManager) pushToUserWithSender(ctx context.Context, userID int64,
m.mu.Lock()
total = len(m.byUser[userID])
for key, c := range m.byUser[userID] {
- if shouldExcludeSession(c, excludeAuthKeyID, excludeSessionID) {
+ if shouldExcludeSession(c, excludeAuthKeyID, excludeSessionID) || shouldExcludeBusinessAuthKey(c, excludeBusinessAuthKeyID) {
excluded++
continue
}
- if !sessionSupportsMinimumLayer(c, minLayer) {
+ if !sessionSupportsSemantic(c, semantic) {
skipped++
continue
}
@@ -1825,6 +1979,9 @@ func (m *SessionManager) pushToUserWithSender(ctx context.Context, userID int64,
if c.userID.Load() != userID {
continue
}
+ if shouldExcludeBusinessAuthKey(c, excludeBusinessAuthKeyID) {
+ continue
+ }
if err := send(c); err != nil {
if isOutboundStaleLayerEpoch(err) {
// Do not classify profile correction as slow-consumer evidence.
@@ -2330,6 +2487,7 @@ func (m *SessionManager) removeLocked(c *Conn, dropPending bool) int64 {
removeUserIndex(m.byUser, uid, key)
}
m.clearSessionChannelIndexesLocked(c, key)
+ m.clearUpdatesActivationLocked(c)
if dropPending {
m.deletePendingLocked(key)
}
@@ -2783,15 +2941,26 @@ func shouldExcludeSession(c *Conn, excludeAuthKeyID *[8]byte, excludeSessionID i
return c.authKeyID == *excludeAuthKeyID
}
-func sessionSupportsMinimumLayer(c *Conn, minLayer int) bool {
- if minLayer <= 0 {
+func shouldExcludeBusinessAuthKey(c *Conn, excludeBusinessAuthKeyID *[8]byte) bool {
+ if c == nil || excludeBusinessAuthKeyID == nil || *excludeBusinessAuthKeyID == ([8]byte{}) {
+ return false
+ }
+ return connUsesBusinessAuthKey(c, *excludeBusinessAuthKeyID)
+}
+
+func sessionSupportsSemantic(c *Conn, semantic tlprofile.SemanticID) bool {
+ if semantic == 0 {
return true
}
if c == nil {
return false
}
state := c.LayerProfileState()
- return state.Origin != LayerProfileUnknown && int(state.Profile) >= minLayer
+ if state.Origin == LayerProfileUnknown {
+ return false
+ }
+ _, ok := tlprofile.WireID(state.Profile, semantic)
+ return ok
}
func sessionKeyLog(id [8]byte) string {
diff --git a/internal/mtprotoedge/session_manager_test.go b/internal/mtprotoedge/session_manager_test.go
index dd4b3c0f..92ae6465 100644
--- a/internal/mtprotoedge/session_manager_test.go
+++ b/internal/mtprotoedge/session_manager_test.go
@@ -219,8 +219,8 @@ func TestSessionManagerBestEffortFanoutPreparesOncePerProfile(t *testing.T) {
c := &Conn{
sessionID: int64(i + 1),
authKeyID: [8]byte{byte(i + 1)},
- outbound: make(chan outboundOp, 1),
- outboundControl: make(chan outboundOp, 1),
+ outbound: make(chan *outboundOp, 1),
+ outboundControl: make(chan *outboundOp, 1),
outboundStop: make(chan struct{}),
metrics: NopMetrics{},
}
@@ -270,8 +270,8 @@ func TestSessionManagerMixedLayerFanoutUsesProfileBoundBodies(t *testing.T) {
c := &Conn{
sessionID: int64(profile),
authKeyID: authKeyID,
- outbound: make(chan outboundOp, 1),
- outboundControl: make(chan outboundOp, 1),
+ outbound: make(chan *outboundOp, 1),
+ outboundControl: make(chan *outboundOp, 1),
outboundStop: make(chan struct{}),
metrics: NopMetrics{},
}
@@ -390,11 +390,11 @@ func TestSessionManagerBestEffortFanoutUsesOneBudgetAndDropsOnlySlowConsumers(t
authKeyID: [8]byte{byte(i + 1)},
transport: tr,
metrics: NopMetrics{},
- outbound: make(chan outboundOp, 1),
- outboundControl: make(chan outboundOp, 1),
+ outbound: make(chan *outboundOp, 1),
+ outboundControl: make(chan *outboundOp, 1),
outboundStop: make(chan struct{}),
}
- c.outbound <- outboundOp{}
+ c.outbound <- &outboundOp{}
c.userID.Store(userID)
c.userIDResolved.Store(true)
c.receivesUpdates.Store(true)
@@ -409,8 +409,8 @@ func TestSessionManagerBestEffortFanoutUsesOneBudgetAndDropsOnlySlowConsumers(t
sessionID: 99,
authKeyID: [8]byte{99},
metrics: NopMetrics{},
- outbound: make(chan outboundOp, 1),
- outboundControl: make(chan outboundOp, 1),
+ outbound: make(chan *outboundOp, 1),
+ outboundControl: make(chan *outboundOp, 1),
outboundStop: make(chan struct{}),
}
healthy.userID.Store(userID)
@@ -656,8 +656,8 @@ func TestForceCloseBatchTimeoutStillClosesProducerAndRPCGates(t *testing.T) {
c := &Conn{
transport: tr,
metrics: NopMetrics{},
- outbound: make(chan outboundOp, 1),
- outboundControl: make(chan outboundOp, 1),
+ outbound: make(chan *outboundOp, 1),
+ outboundControl: make(chan *outboundOp, 1),
outboundStop: make(chan struct{}),
}
c.startInboundRPCScheduler(scheduler, 1, 1, time.Second)
@@ -743,8 +743,8 @@ func TestPushToUserAuthKeyUsesOneDeadlineAndDropsOnlySlowPFSConnections(t *testi
sessionID: sessionID,
metrics: NopMetrics{},
transport: transport,
- outbound: make(chan outboundOp, 1),
- outboundControl: make(chan outboundOp, 1),
+ outbound: make(chan *outboundOp, 1),
+ outboundControl: make(chan *outboundOp, 1),
outboundStop: make(chan struct{}),
}
c.receivesUpdates.Store(true)
@@ -752,7 +752,7 @@ func TestPushToUserAuthKeyUsesOneDeadlineAndDropsOnlySlowPFSConnections(t *testi
t.Fatalf("freeze profile: %v", err)
}
if queueFull {
- c.outbound <- outboundOp{}
+ c.outbound <- &outboundOp{}
}
sm.Register(c)
sm.BindAuthKeyForSession(raw, sessionID, business)
@@ -961,8 +961,8 @@ func TestPushToSessionForAuthKeyImmediateBypassesReadinessQueue(t *testing.T) {
c := &Conn{
sessionID: 42,
authKeyID: raw,
- outbound: make(chan outboundOp, 1),
- outboundControl: make(chan outboundOp, 1),
+ outbound: make(chan *outboundOp, 1),
+ outboundControl: make(chan *outboundOp, 1),
outboundStop: make(chan struct{}),
}
if err := c.FreezeLayerProfile(tlprofile.Profile227); err != nil {
@@ -1006,8 +1006,8 @@ func TestSessionManagerWithholdsUpdatesReadinessUntilExactProfile(t *testing.T)
c := &Conn{
authKeyID: key.authKeyID,
sessionID: key.sessionID,
- outbound: make(chan outboundOp, 1),
- outboundControl: make(chan outboundOp, 1),
+ outbound: make(chan *outboundOp, 1),
+ outboundControl: make(chan *outboundOp, 1),
outboundStop: make(chan struct{}),
metrics: NopMetrics{},
outboundTrackedBudget: newOutboundTrackedBudget(1 << 20),
@@ -1049,7 +1049,7 @@ func TestSessionManagerWithholdsUpdatesReadinessUntilExactProfile(t *testing.T)
}
c.membershipsSynced.Store(true)
sm.SetReceivesUpdatesForAuthKey(key.authKeyID, key.sessionID, true)
- var op outboundOp
+ var op *outboundOp
select {
case op = <-c.outbound:
case <-time.After(time.Second):
@@ -1071,6 +1071,110 @@ func TestSessionManagerWithholdsUpdatesReadinessUntilExactProfile(t *testing.T)
}
}
+func TestSessionUpdatesActivationIsSingleFlightAndGenerationFenced(t *testing.T) {
+ sm := NewSessionManager(zaptest.NewLogger(t))
+ key := sessionKey{authKeyID: [8]byte{0x51}, sessionID: 5100}
+ old := &Conn{authKeyID: key.authKeyID, sessionID: key.sessionID}
+ if err := sm.Register(old); err != nil {
+ t.Fatal(err)
+ }
+ oldToken, ok := sm.BeginSessionUpdatesActivation(key.authKeyID, key.sessionID)
+ if !ok || oldToken == 0 {
+ t.Fatal("first physical generation did not acquire activation")
+ }
+ if token, ok := sm.BeginSessionUpdatesActivation(key.authKeyID, key.sessionID); ok || token != 0 {
+ t.Fatalf("concurrent activation acquired token %d", token)
+ }
+ sm.EndSessionUpdatesActivation(key.authKeyID, key.sessionID, oldToken+1)
+ if token, ok := sm.BeginSessionUpdatesActivation(key.authKeyID, key.sessionID); ok || token != 0 {
+ t.Fatal("wrong-token release cleared active claim")
+ }
+
+ replacement := &Conn{authKeyID: key.authKeyID, sessionID: key.sessionID}
+ if err := sm.Register(replacement); err != nil {
+ t.Fatal(err)
+ }
+ newToken, ok := sm.BeginSessionUpdatesActivation(key.authKeyID, key.sessionID)
+ if !ok || newToken == 0 || newToken == oldToken {
+ t.Fatalf("replacement activation token = %d, old %d", newToken, oldToken)
+ }
+ sm.EndSessionUpdatesActivation(key.authKeyID, key.sessionID, oldToken)
+ if token, ok := sm.BeginSessionUpdatesActivation(key.authKeyID, key.sessionID); ok || token != 0 {
+ t.Fatal("old generation callback cleared replacement claim")
+ }
+ sm.EndSessionUpdatesActivation(key.authKeyID, key.sessionID, newToken)
+ if token, ok := sm.BeginSessionUpdatesActivation(key.authKeyID, key.sessionID); !ok || token == 0 {
+ t.Fatal("matching replacement token did not release claim")
+ }
+}
+
+func TestSessionUpdatesActivationLeaseRecoversAbandonedClaim(t *testing.T) {
+ sm := NewSessionManager(zaptest.NewLogger(t))
+ key := sessionKey{authKeyID: [8]byte{0x52}, sessionID: 5200}
+ now := time.Unix(1700000000, 0)
+ c := &Conn{authKeyID: key.authKeyID, sessionID: key.sessionID, now: func() time.Time { return now }}
+ if err := sm.Register(c); err != nil {
+ t.Fatal(err)
+ }
+ first, ok := sm.BeginSessionUpdatesActivation(key.authKeyID, key.sessionID)
+ if !ok {
+ t.Fatal("first activation claim rejected")
+ }
+ now = now.Add(updatesActivationClaimTTL - time.Second)
+ if token, ok := sm.BeginSessionUpdatesActivation(key.authKeyID, key.sessionID); ok || token != 0 {
+ t.Fatal("live activation lease was stolen")
+ }
+ now = now.Add(2 * time.Second)
+ second, ok := sm.BeginSessionUpdatesActivation(key.authKeyID, key.sessionID)
+ if !ok || second == 0 || second == first {
+ t.Fatalf("expired activation lease was not replaced: first=%d second=%d", first, second)
+ }
+}
+
+func TestSessionBootstrapProbeIsOneShotRetryableAndGenerationFenced(t *testing.T) {
+ sm := NewSessionManager(zaptest.NewLogger(t))
+ key := sessionKey{authKeyID: [8]byte{0x53}, sessionID: 5300}
+ old := &Conn{authKeyID: key.authKeyID, sessionID: key.sessionID}
+ if err := sm.Register(old); err != nil {
+ t.Fatal(err)
+ }
+ failedToken, ok := sm.BeginSessionBootstrapProbe(key.authKeyID, key.sessionID)
+ if !ok || failedToken == 0 {
+ t.Fatal("first bootstrap probe was not claimed")
+ }
+ if token, ok := sm.BeginSessionBootstrapProbe(key.authKeyID, key.sessionID); ok || token != 0 {
+ t.Fatalf("concurrent bootstrap probe acquired token %d", token)
+ }
+ sm.EndSessionBootstrapProbe(key.authKeyID, key.sessionID, failedToken, false)
+ oldToken, ok := sm.BeginSessionBootstrapProbe(key.authKeyID, key.sessionID)
+ if !ok || oldToken == 0 || oldToken == failedToken {
+ t.Fatalf("failed probe did not become retryable: failed=%d retry=%d", failedToken, oldToken)
+ }
+
+ replacement := &Conn{authKeyID: key.authKeyID, sessionID: key.sessionID}
+ if err := sm.Register(replacement); err != nil {
+ t.Fatal(err)
+ }
+ newToken, ok := sm.BeginSessionBootstrapProbe(key.authKeyID, key.sessionID)
+ if !ok || newToken == 0 || newToken == oldToken {
+ t.Fatalf("replacement bootstrap token = %d, old %d", newToken, oldToken)
+ }
+ sm.EndSessionBootstrapProbe(key.authKeyID, key.sessionID, oldToken, true)
+ if token, ok := sm.BeginSessionBootstrapProbe(key.authKeyID, key.sessionID); ok || token != 0 {
+ t.Fatal("old generation callback cleared replacement probe")
+ }
+ sm.EndSessionBootstrapProbe(key.authKeyID, key.sessionID, newToken, true)
+ if token, ok := sm.BeginSessionBootstrapProbe(key.authKeyID, key.sessionID); ok || token != 0 {
+ t.Fatal("successful bootstrap probe was not one-shot")
+ }
+
+ sm.BindUserForAuthKey(key.authKeyID, key.sessionID, 100)
+ sm.BindUserForAuthKey(key.authKeyID, key.sessionID, 200)
+ if token, ok := sm.BeginSessionBootstrapProbe(key.authKeyID, key.sessionID); !ok || token == 0 {
+ t.Fatal("user identity change did not reset bootstrap probe")
+ }
+}
+
func TestPendingPushBodiesUseGlobalByteBudgetAndReleaseOnDrop(t *testing.T) {
sm := NewSessionManager(zaptest.NewLogger(t))
msg := &tg.UpdateShort{Update: &tg.UpdateLoginToken{}, Date: 1700000000}
@@ -1106,8 +1210,8 @@ func TestPendingFlushGlobalBodyPressureDoesNotTerminateHealthyConnection(t *test
c := &Conn{
authKeyID: key.authKeyID,
sessionID: key.sessionID,
- outbound: make(chan outboundOp, 1),
- outboundControl: make(chan outboundOp, 1),
+ outbound: make(chan *outboundOp, 1),
+ outboundControl: make(chan *outboundOp, 1),
outboundStop: make(chan struct{}),
metrics: NopMetrics{},
outboundTrackedBudget: newOutboundTrackedBudget(1),
diff --git a/internal/mtprotoedge/shutdown_gate_test.go b/internal/mtprotoedge/shutdown_gate_test.go
index fae24f9c..f17282d2 100644
--- a/internal/mtprotoedge/shutdown_gate_test.go
+++ b/internal/mtprotoedge/shutdown_gate_test.go
@@ -22,8 +22,8 @@ func TestTerminalFailurePathsCloseGatesBeforeBlockingTransportClose(t *testing.T
c := &Conn{
transport: tr,
metrics: NopMetrics{},
- outbound: make(chan outboundOp, 1),
- outboundControl: make(chan outboundOp, 1),
+ outbound: make(chan *outboundOp, 1),
+ outboundControl: make(chan *outboundOp, 1),
outboundStop: make(chan struct{}),
}
c.startInboundRPCScheduler(scheduler, 1, 1, time.Second)
diff --git a/internal/mtprotoedge/structural_limits_test.go b/internal/mtprotoedge/structural_limits_test.go
index 6e3ece56..cf30af10 100644
--- a/internal/mtprotoedge/structural_limits_test.go
+++ b/internal/mtprotoedge/structural_limits_test.go
@@ -224,8 +224,8 @@ func TestContainerInvalidSequenceTailIsAtomic(t *testing.T) {
cs := newConnState()
c := &Conn{
metrics: NopMetrics{},
- outbound: make(chan outboundOp, 4),
- outboundControl: make(chan outboundOp, 4),
+ outbound: make(chan *outboundOp, 4),
+ outboundControl: make(chan *outboundOp, 4),
outboundStop: make(chan struct{}),
}
var acks []int64
diff --git a/internal/mtprotoedge/transient_push_test.go b/internal/mtprotoedge/transient_push_test.go
index 931f2a7d..e34d0546 100644
--- a/internal/mtprotoedge/transient_push_test.go
+++ b/internal/mtprotoedge/transient_push_test.go
@@ -21,8 +21,8 @@ func TestPushTransientSkipsNotReadySession(t *testing.T) {
c := &Conn{
sessionID: 7,
authKeyID: [8]byte{7},
- outbound: make(chan outboundOp, 4),
- outboundControl: make(chan outboundOp, 4),
+ outbound: make(chan *outboundOp, 4),
+ outboundControl: make(chan *outboundOp, 4),
outboundStop: make(chan struct{}),
}
c.userID.Store(userID)
@@ -54,16 +54,16 @@ func TestPushTransientSkipsNotReadySession(t *testing.T) {
}
}
-// Layer-228-only transient constructors must be filtered before encoding. A
-// Layer 227 or unknown session is skipped without disconnecting it or queuing
-// an unreplayable update, while the ready Layer 228 session receives it.
-func TestPushTransientAtLeastLayerSkipsOldAndUnknownProfiles(t *testing.T) {
+// Constructor compatibility comes from generated profile metadata, not a
+// hard-coded minimum layer. Old/unknown sessions are skipped without encoding,
+// disconnecting or queuing, while every generated compatible profile receives.
+func TestPushTransientCompatibleSkipsUnavailableAndUnknownProfiles(t *testing.T) {
sm := NewSessionManager(zaptest.NewLogger(t))
const userID = int64(101)
makeConn := func(sessionID int64, profile tlprofile.Profile, known bool) *Conn {
c := &Conn{
sessionID: sessionID, authKeyID: [8]byte{byte(sessionID)},
- outbound: make(chan outboundOp, 2), outboundControl: make(chan outboundOp, 2),
+ outbound: make(chan *outboundOp, 2), outboundControl: make(chan *outboundOp, 2),
outboundStop: make(chan struct{}),
}
c.userID.Store(userID)
@@ -80,25 +80,26 @@ func TestPushTransientAtLeastLayerSkipsOldAndUnknownProfiles(t *testing.T) {
return c
}
old := makeConn(1, tlprofile.Profile227, true)
- current := makeConn(2, tlprofile.Profile228, true)
+ introduced := makeConn(2, tlprofile.Profile228, true)
unknown := makeConn(3, 0, false)
+ newer := makeConn(4, tlprofile.Profile229, true)
message := tg.EphemeralMessage{
ID: 7, FromID: &tg.PeerUser{UserID: 2001}, PeerID: &tg.PeerChannel{ChannelID: 3001},
ReceiverID: userID, Date: 1_900_000_000, Message: "private",
}
updates := &tg.Updates{Updates: []tg.UpdateClass{&tg.UpdateNewEphemeralMessage{Message: message}}, Date: 1_900_000_000}
- sent, err := sm.PushToUserTransientAtLeastLayer(context.Background(), userID, 228, proto.MessageFromServer, updates, time.Second)
- if err != nil || sent != 1 {
+ sent, err := sm.PushToUserTransientCompatible(context.Background(), userID, tlprofile.SemanticTypeUpdateNewEphemeralMessage, proto.MessageFromServer, updates, time.Second)
+ if err != nil || sent != 2 {
t.Fatalf("sent=%d err=%v", sent, err)
}
- if len(old.outbound) != 0 || len(unknown.outbound) != 0 || len(current.outbound) != 1 {
- t.Fatalf("queues old=%d unknown=%d current=%d", len(old.outbound), len(unknown.outbound), len(current.outbound))
+ if len(old.outbound) != 0 || len(unknown.outbound) != 0 || len(introduced.outbound) != 1 || len(newer.outbound) != 1 {
+ t.Fatalf("queues old=%d unknown=%d introduced=%d newer=%d", len(old.outbound), len(unknown.outbound), len(introduced.outbound), len(newer.outbound))
}
if old.isRetired() || unknown.isRetired() {
t.Fatal("unsupported transient update retired an old/unknown session")
}
- for _, c := range []*Conn{old, current, unknown} {
+ for _, c := range []*Conn{old, introduced, unknown, newer} {
sm.mu.RLock()
pending := len(sm.pending[connSessionKey(c)])
sm.mu.RUnlock()
diff --git a/internal/observability/metrics/registry.go b/internal/observability/metrics/registry.go
index c2fe6b0a..3f8f1b80 100644
--- a/internal/observability/metrics/registry.go
+++ b/internal/observability/metrics/registry.go
@@ -227,9 +227,12 @@ func (r *Registry) add(name string, value uint64, labels ...Label) {
}
func (r *Registry) addGauge(name string, delta int64, labels ...Label) {
- if r == nil || delta == 0 {
+ if r == nil {
return
}
+ // A zero delta still touches the series: callers use it to declare an
+ // idle gauge exists (e.g. XxxPending(0) at startup) so /metrics exposes
+ // "0" immediately instead of omitting the line until first activity.
r.gauge(newSeriesKey(name, labels...)).value.Add(delta)
}
@@ -265,6 +268,20 @@ func (r *Registry) RPCHandled(method string, d time.Duration, err error) {
r.observe("telesrv_mtproto_rpc_duration_seconds", d, labels...)
}
+// RPCDatabase implements mtprotoedge.RPCDatabaseMetrics.
+func (r *Registry) RPCDatabase(method string, queries int64, d time.Duration, errors int64) {
+ labels := []Label{{Name: "method", Value: method}}
+ if queries > 0 {
+ r.add("telesrv_rpc_db_queries_total", uint64(queries), labels...)
+ }
+ if errors > 0 {
+ r.add("telesrv_rpc_db_errors_total", uint64(errors), labels...)
+ }
+ if d > 0 {
+ r.observe("telesrv_rpc_db_time_seconds", d, labels...)
+ }
+}
+
// InboundRPCQueued implements mtprotoedge.Metrics.
func (r *Registry) InboundRPCQueued(method string, length, capacity int) {
r.inc("telesrv_mtproto_inbound_rpc_queued_total", Label{Name: "method", Value: method})
@@ -384,6 +401,76 @@ func (r *Registry) OutboxFailed(err error) {
r.inc("telesrv_rpc_outbox_failed_total", Label{Name: "outcome", Value: errorOutcome(err)})
}
+// BootstrapReadyBatch implements postgres.BootstrapReadyBatchMetrics without
+// importing store identities into the observability layer.
+func (r *Registry) BootstrapReadyBatch(inputs int, matched int, d time.Duration, err error) {
+ outcome := errorOutcome(err)
+ r.inc("telesrv_bootstrap_ready_batches_total", Label{Name: "outcome", Value: outcome})
+ r.observe("telesrv_bootstrap_ready_batch_duration_seconds", d, Label{Name: "outcome", Value: outcome})
+ inputs = max(inputs, 0)
+ matched = max(min(matched, inputs), 0)
+ if err != nil {
+ r.add("telesrv_bootstrap_ready_selectors_total", uint64(inputs), Label{Name: "outcome", Value: "error"})
+ return
+ }
+ r.add("telesrv_bootstrap_ready_selectors_total", uint64(matched), Label{Name: "outcome", Value: "matched"})
+ r.add("telesrv_bootstrap_ready_selectors_total", uint64(inputs-matched), Label{Name: "outcome", Value: "miss"})
+}
+
+// BootstrapReadyPending implements postgres.BootstrapReadyBatchMetrics.
+func (r *Registry) BootstrapReadyPending(delta int) {
+ r.addGauge("telesrv_bootstrap_ready_pending", int64(delta))
+}
+
+// ActiveChannelIDsCache implements channels.ActiveChannelIDsReadModelMetrics.
+func (r *Registry) ActiveChannelIDsCache(outcome string) {
+ r.inc("telesrv_active_channel_ids_cache_total", Label{Name: "outcome", Value: outcome})
+}
+
+// ActiveChannelIDsBatch implements postgres.ActiveChannelIDsBatchMetrics.
+func (r *Registry) ActiveChannelIDsBatch(selectors int, rows int, d time.Duration, err error) {
+ outcome := errorOutcome(err)
+ r.inc("telesrv_active_channel_ids_batches_total", Label{Name: "outcome", Value: outcome})
+ r.add("telesrv_active_channel_ids_selectors_total", uint64(max(selectors, 0)), Label{Name: "outcome", Value: outcome})
+ if err == nil {
+ r.add("telesrv_active_channel_ids_rows_total", uint64(max(rows, 0)))
+ }
+ r.observe("telesrv_active_channel_ids_batch_duration_seconds", d, Label{Name: "outcome", Value: outcome})
+}
+
+// ActiveChannelIDsPending implements postgres.ActiveChannelIDsBatchMetrics.
+func (r *Registry) ActiveChannelIDsPending(delta int) {
+ r.addGauge("telesrv_active_channel_ids_pending", int64(delta))
+}
+
+// PresenceLastSeenBatch implements rpc.Metrics.
+func (r *Registry) PresenceLastSeenBatch(count int, d time.Duration, err error) {
+ labels := []Label{{Name: "outcome", Value: errorOutcome(err)}}
+ r.inc("telesrv_presence_last_seen_batches_total", labels...)
+ r.add("telesrv_presence_last_seen_updates_total", uint64(max(count, 0)), labels...)
+ r.observe("telesrv_presence_last_seen_batch_duration_seconds", d, labels...)
+}
+
+// PresenceLastSeenSubmitted implements rpc.Metrics.
+func (r *Registry) PresenceLastSeenSubmitted() {
+ r.inc("telesrv_presence_last_seen_submitted_total")
+}
+
+// PresenceLastSeenPending implements rpc.Metrics.
+func (r *Registry) PresenceLastSeenPending(delta int) {
+ r.addGauge("telesrv_presence_last_seen_pending", int64(delta))
+}
+
+// PresenceLastSeenOverflow implements rpc.Metrics.
+func (r *Registry) PresenceLastSeenOverflow() {
+ r.inc("telesrv_presence_last_seen_overflow_total")
+}
+
+// PresenceLastSeenDrainDropped implements rpc.Metrics.
+func (r *Registry) PresenceLastSeenDrainDropped(count int) {
+ r.add("telesrv_presence_last_seen_drain_dropped_total", uint64(max(count, 0)))
+}
+
// ServeHTTP writes Prometheus text format.
func (r *Registry) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
diff --git a/internal/observability/metrics/registry_test.go b/internal/observability/metrics/registry_test.go
index 01ec80bd..18a9e5f2 100644
--- a/internal/observability/metrics/registry_test.go
+++ b/internal/observability/metrics/registry_test.go
@@ -13,12 +13,111 @@ import (
var (
_ mtprotoedge.Metrics = (*Registry)(nil)
+ _ mtprotoedge.RPCDatabaseMetrics = (*Registry)(nil)
_ mtprotoedge.RPCResultMetrics = (*Registry)(nil)
_ mtprotoedge.LogicalOutboxMetrics = (*Registry)(nil)
_ mtprotoedge.ConnectionIntakeMetrics = (*Registry)(nil)
_ rpc.Metrics = (*Registry)(nil)
)
+func TestRegistryExportsRPCDatabaseWork(t *testing.T) {
+ registry := New()
+ registry.RPCDatabase("messages.getDialogs", 17, 25*time.Millisecond, 0)
+ recorder := httptest.NewRecorder()
+ registry.ServeHTTP(recorder, httptest.NewRequest("GET", "/metrics", nil))
+ body := recorder.Body.String()
+ for _, want := range []string{
+ `telesrv_rpc_db_queries_total{method="messages.getDialogs"} 17`,
+ `telesrv_rpc_db_time_seconds_sum{method="messages.getDialogs"} 0.025`,
+ `telesrv_rpc_db_time_seconds_count{method="messages.getDialogs"} 1`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("missing %q from:\n%s", want, body)
+ }
+ }
+}
+
+func TestRegistryExportsPresenceLastSeenBatchWork(t *testing.T) {
+ registry := New()
+ registry.PresenceLastSeenBatch(37, 25*time.Millisecond, nil)
+ registry.PresenceLastSeenBatch(12, 50*time.Millisecond, errors.New("temporary"))
+ registry.PresenceLastSeenSubmitted()
+ registry.PresenceLastSeenSubmitted()
+ registry.PresenceLastSeenPending(9)
+ registry.PresenceLastSeenPending(-4)
+ registry.PresenceLastSeenOverflow()
+ registry.PresenceLastSeenDrainDropped(3)
+ recorder := httptest.NewRecorder()
+ registry.ServeHTTP(recorder, httptest.NewRequest("GET", "/metrics", nil))
+ body := recorder.Body.String()
+ for _, want := range []string{
+ `telesrv_presence_last_seen_batches_total{outcome="ok"} 1`,
+ `telesrv_presence_last_seen_batches_total{outcome="error"} 1`,
+ `telesrv_presence_last_seen_updates_total{outcome="ok"} 37`,
+ `telesrv_presence_last_seen_updates_total{outcome="error"} 12`,
+ `telesrv_presence_last_seen_submitted_total 2`,
+ `telesrv_presence_last_seen_pending 5`,
+ `telesrv_presence_last_seen_overflow_total 1`,
+ `telesrv_presence_last_seen_drain_dropped_total 3`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("missing %q from:\n%s", want, body)
+ }
+ }
+}
+
+func TestRegistryExportsBootstrapReadyBatchWork(t *testing.T) {
+ registry := New()
+ registry.BootstrapReadyPending(7)
+ registry.BootstrapReadyBatch(7, 2, 25*time.Millisecond, nil)
+ registry.BootstrapReadyPending(-7)
+ registry.BootstrapReadyBatch(3, 0, 50*time.Millisecond, errors.New("temporary"))
+ recorder := httptest.NewRecorder()
+ registry.ServeHTTP(recorder, httptest.NewRequest("GET", "/metrics", nil))
+ body := recorder.Body.String()
+ for _, want := range []string{
+ `telesrv_bootstrap_ready_batches_total{outcome="ok"} 1`,
+ `telesrv_bootstrap_ready_batches_total{outcome="error"} 1`,
+ `telesrv_bootstrap_ready_selectors_total{outcome="matched"} 2`,
+ `telesrv_bootstrap_ready_selectors_total{outcome="miss"} 5`,
+ `telesrv_bootstrap_ready_selectors_total{outcome="error"} 3`,
+ `telesrv_bootstrap_ready_pending 0`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("missing %q from:\n%s", want, body)
+ }
+ }
+}
+
+func TestRegistryExportsActiveChannelIDsReadModelWork(t *testing.T) {
+ registry := New()
+ registry.ActiveChannelIDsCache("hit")
+ registry.ActiveChannelIDsCache("miss")
+ registry.ActiveChannelIDsCache("served")
+ registry.ActiveChannelIDsPending(7)
+ registry.ActiveChannelIDsBatch(7, 19, 25*time.Millisecond, nil)
+ registry.ActiveChannelIDsPending(-7)
+ registry.ActiveChannelIDsBatch(3, 0, 50*time.Millisecond, errors.New("temporary"))
+ recorder := httptest.NewRecorder()
+ registry.ServeHTTP(recorder, httptest.NewRequest("GET", "/metrics", nil))
+ body := recorder.Body.String()
+ for _, want := range []string{
+ `telesrv_active_channel_ids_cache_total{outcome="hit"} 1`,
+ `telesrv_active_channel_ids_cache_total{outcome="miss"} 1`,
+ `telesrv_active_channel_ids_cache_total{outcome="served"} 1`,
+ `telesrv_active_channel_ids_batches_total{outcome="ok"} 1`,
+ `telesrv_active_channel_ids_batches_total{outcome="error"} 1`,
+ `telesrv_active_channel_ids_selectors_total{outcome="ok"} 7`,
+ `telesrv_active_channel_ids_selectors_total{outcome="error"} 3`,
+ `telesrv_active_channel_ids_rows_total 19`,
+ `telesrv_active_channel_ids_pending 0`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("missing %q from:\n%s", want, body)
+ }
+ }
+}
+
func TestRegistryExportsBoundedAggregateMetrics(t *testing.T) {
registry := New()
registry.maxSeries = 2
diff --git a/internal/procctl/admin_restart_test.go b/internal/procctl/admin_restart_test.go
new file mode 100644
index 00000000..c82231be
--- /dev/null
+++ b/internal/procctl/admin_restart_test.go
@@ -0,0 +1,35 @@
+package procctl
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestHandlePendingAdminRestartNoop(t *testing.T) {
+ m := NewManager(t.TempDir())
+ restarted, err := m.HandlePendingAdminRestart(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if restarted {
+ t.Fatal("expected no-op when no restart was requested")
+ }
+}
+
+func TestHandlePendingAdminRestartFlagRoundTrip(t *testing.T) {
+ dir := t.TempDir()
+ statePath := filepath.Join(dir, stateFileName)
+ if err := os.WriteFile(statePath, []byte(`{"server_pid":123,"admin_pid":0,"pending_admin_restart":true}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ m := NewManager(dir)
+ st := m.loadState()
+ if !st.PendingAdminRestart {
+ t.Fatal("expected PendingAdminRestart to round-trip from JSON")
+ }
+ if st.ServerPID != 123 {
+ t.Fatalf("ServerPID = %d, want 123 (existing fields must survive)", st.ServerPID)
+ }
+}
diff --git a/internal/procctl/env_sensitive_test.go b/internal/procctl/env_sensitive_test.go
new file mode 100644
index 00000000..2c3eda19
--- /dev/null
+++ b/internal/procctl/env_sensitive_test.go
@@ -0,0 +1,46 @@
+package procctl
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// TestReadEnvGroupsSensitiveFlag guards against sensitiveKeyRe false-positive
+// matches on the word "SECRET" in a name that means Telegram's secret-chat
+// feature, not a credential (e.g. TELESRV_SECRET_CHAT_DELETE_FILE_AFTER_DOWNLOAD),
+// while still flagging real credential-shaped names as sensitive.
+func TestReadEnvGroupsSensitiveFlag(t *testing.T) {
+ root := t.TempDir()
+ tmpl := "## Storage & Media -- test group\n" +
+ "# desc\n" +
+ "TELESRV_SECRET_CHAT_DELETE_FILE_AFTER_DOWNLOAD=true\n" +
+ "# desc\n" +
+ "TELESRV_ADMIN_PASSWORD=\n" +
+ "# desc\n" +
+ "TELESRV_BOT_API_KEY=\n"
+ if err := os.WriteFile(filepath.Join(root, ".env.example"), []byte(tmpl), 0o644); err != nil {
+ t.Fatalf("write .env.example: %v", err)
+ }
+
+ groups, err := NewManager(root).ReadEnvGroups()
+ if err != nil {
+ t.Fatalf("ReadEnvGroups: %v", err)
+ }
+ got := map[string]bool{}
+ for _, g := range groups {
+ for _, f := range g.Fields {
+ got[f.Key] = f.Sensitive
+ }
+ }
+
+ if got["TELESRV_SECRET_CHAT_DELETE_FILE_AFTER_DOWNLOAD"] {
+ t.Errorf("TELESRV_SECRET_CHAT_DELETE_FILE_AFTER_DOWNLOAD marked sensitive, want not (it's a boolean toggle, not a credential)")
+ }
+ if !got["TELESRV_ADMIN_PASSWORD"] {
+ t.Errorf("TELESRV_ADMIN_PASSWORD not marked sensitive, want sensitive")
+ }
+ if !got["TELESRV_BOT_API_KEY"] {
+ t.Errorf("TELESRV_BOT_API_KEY not marked sensitive, want sensitive")
+ }
+}
diff --git a/internal/procctl/env_test.go b/internal/procctl/env_test.go
new file mode 100644
index 00000000..4ddc2557
--- /dev/null
+++ b/internal/procctl/env_test.go
@@ -0,0 +1,188 @@
+package procctl
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// findRepoRoot walks up from the test's working directory looking for
+// .env.example -- the same file server-panel.py parses -- so this test
+// exercises the real file, not a synthetic fixture that could drift from it.
+func findRepoRoot(t *testing.T) string {
+ t.Helper()
+ dir, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ for {
+ if _, err := os.Stat(filepath.Join(dir, ".env.example")); err == nil {
+ return dir
+ }
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ t.Fatal(".env.example not found above test directory")
+ }
+ dir = parent
+ }
+}
+
+func TestReadEnvGroupsParsesRealTemplate(t *testing.T) {
+ root := findRepoRoot(t)
+ m := NewManager(root)
+ groups, err := m.ReadEnvGroups()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(groups) == 0 {
+ t.Fatal("expected at least one group from .env.example")
+ }
+ found := map[string]bool{}
+ for _, g := range groups {
+ if g.Title == "" {
+ t.Errorf("group with empty title, description=%q", g.Description)
+ }
+ if len(g.Fields) == 0 {
+ t.Errorf("group %q has no fields", g.Title)
+ }
+ for _, f := range g.Fields {
+ if !strings.HasPrefix(f.Key, "TELESRV_") {
+ t.Errorf("field key %q missing TELESRV_ prefix", f.Key)
+ }
+ found[f.Key] = true
+ }
+ }
+ // A couple of fields we know exist in the real file (checked above) --
+ // pins the parser to the actual format, not just "produces something".
+ for _, key := range []string{"TELESRV_LISTEN", "TELESRV_ADVERTISE_IP", "TELESRV_DC"} {
+ if !found[key] {
+ t.Errorf("expected field %s in parsed groups", key)
+ }
+ }
+ // The advanced/internal-tuning section (after the "# ===..." banner) is
+ // deliberately excluded -- mirrors server-panel.py's parse_env_template.
+ if found["TELESRV_MTPROTO_RPC_MAX_INFLIGHT"] {
+ t.Error("advanced/internal field leaked into panel-visible groups")
+ }
+}
+
+func TestWriteEnvValuesRoundTrip(t *testing.T) {
+ root := findRepoRoot(t)
+ tmpl, err := os.ReadFile(filepath.Join(root, ".env.example"))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, ".env.example"), tmpl, 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ m := NewManager(dir)
+ groups, err := m.ReadEnvGroups()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(groups) == 0 {
+ t.Fatal("no groups parsed")
+ }
+
+ if err := m.WriteEnvValues(map[string]string{
+ "TELESRV_ADVERTISE_IP": "203.0.113.5",
+ "TELESRV_DC": "3",
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ groups2, err := m.ReadEnvGroups()
+ if err != nil {
+ t.Fatal(err)
+ }
+ values := map[string]string{}
+ for _, g := range groups2 {
+ for _, f := range g.Fields {
+ values[f.Key] = f.Value
+ }
+ }
+ if values["TELESRV_ADVERTISE_IP"] != "203.0.113.5" {
+ t.Errorf("TELESRV_ADVERTISE_IP = %q, want 203.0.113.5", values["TELESRV_ADVERTISE_IP"])
+ }
+ if values["TELESRV_DC"] != "3" {
+ t.Errorf("TELESRV_DC = %q, want 3", values["TELESRV_DC"])
+ }
+ // A field never touched by WriteEnvValues should still read back its
+ // template default -- confirms the file's other lines/comments/layout
+ // survived the rewrite untouched.
+ if values["TELESRV_LISTEN"] == "" {
+ t.Error("TELESRV_LISTEN lost its default after an unrelated field was written")
+ }
+
+ envPath := filepath.Join(dir, ".env")
+ envData, err := os.ReadFile(envPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(envData), "TELESRV_ADVERTISE_IP=203.0.113.5") {
+ t.Error(".env does not contain the written value verbatim")
+ }
+ // The file's descriptive comments (the reason server-panel.py rewrites
+ // from the template instead of dumping bare key=value pairs) must
+ // survive too.
+ if !strings.Contains(string(envData), "Your server's public IP address.") {
+ t.Error(".env lost .env.example's comment lines on write")
+ }
+}
+
+// TestWriteEnvValuesPreservesUnrelatedCustomValues guards against a real
+// regression: a save that only touches one section's keys used to reset
+// every OTHER already-customized key (e.g. TELESRV_ADMIN_UI_PASSWORD) back
+// to .env.example's bare template default, because the old implementation
+// fell back to the template line instead of the current .env value for any
+// key missing from that save's payload.
+func TestWriteEnvValuesPreservesUnrelatedCustomValues(t *testing.T) {
+ root := findRepoRoot(t)
+ tmpl, err := os.ReadFile(filepath.Join(root, ".env.example"))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, ".env.example"), tmpl, 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ m := NewManager(dir)
+
+ // First save sets the admin password, as a one-time setup step would.
+ if err := m.WriteEnvValues(map[string]string{
+ "TELESRV_ADMIN_UI_PASSWORD": "s3cret",
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ // A later, unrelated save (e.g. the Storage page) that never mentions
+ // the password must not disturb it.
+ if err := m.WriteEnvValues(map[string]string{
+ "TELESRV_STORAGE_MAX_TOTAL_BYTES": "209715200",
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ groups, err := m.ReadEnvGroups()
+ if err != nil {
+ t.Fatal(err)
+ }
+ values := map[string]string{}
+ for _, g := range groups {
+ for _, f := range g.Fields {
+ values[f.Key] = f.Value
+ }
+ }
+ if values["TELESRV_ADMIN_UI_PASSWORD"] != "s3cret" {
+ t.Errorf("TELESRV_ADMIN_UI_PASSWORD = %q, want it preserved as \"s3cret\" after an unrelated save", values["TELESRV_ADMIN_UI_PASSWORD"])
+ }
+ if values["TELESRV_STORAGE_MAX_TOTAL_BYTES"] != "209715200" {
+ t.Errorf("TELESRV_STORAGE_MAX_TOTAL_BYTES = %q, want 209715200", values["TELESRV_STORAGE_MAX_TOTAL_BYTES"])
+ }
+}
diff --git a/internal/procctl/hidewindow_other.go b/internal/procctl/hidewindow_other.go
new file mode 100644
index 00000000..ca39d89d
--- /dev/null
+++ b/internal/procctl/hidewindow_other.go
@@ -0,0 +1,9 @@
+//go:build !windows
+
+package procctl
+
+import "os/exec"
+
+// hideWindow is a no-op on non-Windows -- there is no console window to
+// suppress. See hidewindow_windows.go for why this exists at all.
+func hideWindow(cmd *exec.Cmd) {}
diff --git a/internal/procctl/hidewindow_windows.go b/internal/procctl/hidewindow_windows.go
new file mode 100644
index 00000000..6ab9b847
--- /dev/null
+++ b/internal/procctl/hidewindow_windows.go
@@ -0,0 +1,25 @@
+//go:build windows
+
+package procctl
+
+import (
+ "os/exec"
+ "syscall"
+)
+
+// hideWindow stops the spawned process from popping a console window on
+// Windows. Every exec.Command in this package (tasklist, taskkill, docker,
+// git, go build) is a console-subsystem binary -- when the caller (this
+// admin panel process) has no console of its own to inherit (the normal
+// case once it's running detached, per procctl's own launch()), Windows
+// implicitly creates a brand new one for each child. With the live
+// Services-tab polling calling tasklist + docker compose ps every few
+// seconds, that showed up as a terminal window flashing on screen
+// repeatedly. CREATE_NO_WINDOW suppresses it without changing anything
+// about how the child runs or what output it produces.
+func hideWindow(cmd *exec.Cmd) {
+ if cmd.SysProcAttr == nil {
+ cmd.SysProcAttr = &syscall.SysProcAttr{}
+ }
+ cmd.SysProcAttr.HideWindow = true
+}
diff --git a/internal/procctl/procctl.go b/internal/procctl/procctl.go
new file mode 100644
index 00000000..d1cd85d6
--- /dev/null
+++ b/internal/procctl/procctl.go
@@ -0,0 +1,709 @@
+// Package procctl mirrors the process-management half of tui-panel/
+// server-panel.py (git pull, go build, launch/stop, and the shared
+// .server_panel.json PID state file) so the admin web panel can offer the
+// same Restart/Update actions the TUI already has, without requiring an
+// operator to SSH in and use the TUI for that specifically.
+//
+// Restart/Update never restart the admin binary themselves (the process
+// this code typically runs inside, when called from cmd/telesrv-admin) --
+// self-restarting mid-HTTP-request is a materially different, riskier
+// problem (dropped response, no clean signal to the caller that it actually
+// completed) than the TUI's case, where a human is watching an interactive
+// session and re-exec is transparent. Instead they set
+// State.PendingAdminRestart and let the *next* owpengram-server process
+// pick it up via HandlePendingAdminRestart once it's confirmed serving
+// (cmd/telesrv/main.go's OnServing hook) -- that process is unrelated to
+// whatever admin panel is currently running, so it can safely kill the old
+// admin PID and launch a new one with none of the self-restart risk.
+package procctl
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const stateFileName = ".server_panel.json"
+
+// Manager operates on one repo checkout (Root), the same layout
+// tui-panel/server-panel.py expects: bin/, logs/, .env, .env.example, and
+// .server_panel.json at the root.
+type Manager struct {
+ Root string
+}
+
+func NewManager(root string) *Manager {
+ return &Manager{Root: root}
+}
+
+func (m *Manager) serverExe() string {
+ if runtime.GOOS == "windows" {
+ return filepath.Join(m.Root, "bin", "owpengram-server.exe")
+ }
+ return filepath.Join(m.Root, "bin", "owpengram-server")
+}
+
+func (m *Manager) adminExe() string {
+ if runtime.GOOS == "windows" {
+ return filepath.Join(m.Root, "bin", "owpengram-admin-panel.exe")
+ }
+ return filepath.Join(m.Root, "bin", "owpengram-admin-panel")
+}
+
+func (m *Manager) serverLog() string { return filepath.Join(m.Root, "logs", "owpengram-server.log") }
+func (m *Manager) adminLog() string {
+ return filepath.Join(m.Root, "logs", "owpengram-admin-panel.log")
+}
+
+// --- state file (shared with tui-panel/server-panel.py) --------------------
+
+type State struct {
+ ServerPID int `json:"server_pid"`
+ AdminPID int `json:"admin_pid"`
+ DockerProject string `json:"docker_project"`
+ DockerPrefix string `json:"docker_prefix"`
+ // PendingAdminRestart is how Restart/Update ask the *next*
+ // owpengram-server process to bounce the admin panel for them, instead
+ // of the admin panel trying to restart itself mid-HTTP-request (see the
+ // package doc). Set here, consumed by HandlePendingAdminRestart at the
+ // new owpengram-server's startup. server-panel.py doesn't know this key
+ // exists -- its own save_state() overwrites the file with only its 4
+ // original fields, so a Stop/Start/Restart/Update run from the TUI in
+ // the narrow window before the flag is consumed will silently drop it.
+ // Rare, and the only consequence is the admin panel not restarting that
+ // one time -- not worth coordinating two processes' writes over.
+ PendingAdminRestart bool `json:"pending_admin_restart,omitempty"`
+}
+
+func (m *Manager) loadState() State {
+ var st State
+ data, err := os.ReadFile(filepath.Join(m.Root, stateFileName))
+ if err != nil {
+ return st
+ }
+ _ = json.Unmarshal(data, &st)
+ if st.DockerProject == "" {
+ st.DockerProject = "owpengram"
+ }
+ if st.DockerPrefix == "" {
+ st.DockerPrefix = "owpengram"
+ }
+ return st
+}
+
+func (m *Manager) saveState(st State) error {
+ data, err := json.Marshal(st)
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(filepath.Join(m.Root, stateFileName), data, 0o644)
+}
+
+// Status reports whether the server/admin PIDs recorded in the shared state
+// file are still alive.
+type Status struct {
+ ServerPID int
+ ServerAlive bool
+ AdminPID int
+ AdminAlive bool
+}
+
+func (m *Manager) Status() Status {
+ st := m.loadState()
+ return Status{
+ ServerPID: st.ServerPID,
+ ServerAlive: pidAlive(st.ServerPID),
+ AdminPID: st.AdminPID,
+ AdminAlive: pidAlive(st.AdminPID),
+ }
+}
+
+// --- process control ---------------------------------------------------
+
+func pidAlive(pid int) bool {
+ if pid <= 0 {
+ return false
+ }
+ if runtime.GOOS == "windows" {
+ cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid))
+ hideWindow(cmd)
+ out, err := cmd.Output()
+ if err != nil {
+ return false
+ }
+ return strings.Contains(string(out), strconv.Itoa(pid))
+ }
+ cmd := exec.Command("kill", "-0", strconv.Itoa(pid))
+ hideWindow(cmd)
+ return cmd.Run() == nil
+}
+
+// killPID mirrors kill_pid() in server-panel.py, MINUS its "/T" tree-kill on
+// Windows -- deliberately different here, not an oversight. server-panel.py
+// is always the common parent of both the server and admin processes, so
+// tree-killing one PID never touches the other. This package's callers are
+// not: HandlePendingAdminRestart runs *inside* the freshly launched
+// owpengram-server, which was itself spawned as a child of the *old*
+// owpengram-admin-panel process by the Restart/Update call that got it
+// here. Killing that old admin PID with "/T" would tree-kill its entire
+// descendant chain -- including this very owpengram-server process, since
+// it's a child of the PID being killed. Windows' taskkill walks that chain
+// by recorded parent-PID regardless of any process-group flags on launch,
+// so the only reliable fix is to never tree-kill here: exact-PID kill only,
+// since every process this package launches is started directly via
+// exec.Command (no intermediate shell wrapper), so there's no wrapper-spawned
+// grandchild "/T" would need to catch anyway.
+func killPID(pid int) {
+ if pid <= 0 {
+ return
+ }
+ if runtime.GOOS == "windows" {
+ cmd := exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/F")
+ hideWindow(cmd)
+ _ = cmd.Run()
+ return
+ }
+ term := exec.Command("kill", "-TERM", strconv.Itoa(pid))
+ hideWindow(term)
+ _ = term.Run()
+ time.Sleep(time.Second)
+ kill := exec.Command("kill", "-KILL", strconv.Itoa(pid))
+ hideWindow(kill)
+ _ = kill.Run()
+}
+
+// launch starts exePath detached, cwd=Root, stdout/stderr appended to
+// logPath, and returns its PID. Unlike the Python TUI this does not set a
+// new session/process group (that needs OS-specific SysProcAttr) -- started
+// via Start() (not Run()), the child outlives this function's return either
+// way, which is all a request/response HTTP handler needs.
+func (m *Manager) launch(exePath, logPath string) (int, error) {
+ if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {
+ return 0, fmt.Errorf("mkdir logs: %w", err)
+ }
+ logf, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+ if err != nil {
+ return 0, fmt.Errorf("open log: %w", err)
+ }
+ defer logf.Close()
+ cmd := exec.Command(exePath)
+ cmd.Dir = m.Root
+ cmd.Stdout = logf
+ cmd.Stderr = logf
+ cmd.Stdin = nil
+ hideWindow(cmd)
+ if err := cmd.Start(); err != nil {
+ return 0, fmt.Errorf("start %s: %w", exePath, err)
+ }
+ go func() { _ = cmd.Wait() }() // reap so it doesn't linger as a zombie
+ return cmd.Process.Pid, nil
+}
+
+// --- Docker infrastructure (Postgres/Redis/MinIO) -------------------------
+
+const (
+ postgresWaitTimeout = 60 * time.Second
+ postgresWaitInterval = 2 * time.Second
+)
+
+// ensureDocker mirrors server-panel.py's START_STEPS "docker" + "postgres"
+// steps -- `docker compose up -d` then wait for Postgres to answer
+// pg_isready. Restart/Update run this every time, same as the TUI: it's a
+// no-op when the containers are already up (compose up -d on a running
+// stack just confirms state), but skipping it entirely was the actual bug
+// report this addresses -- a Restart/Update landing while Postgres/Redis/
+// MinIO are down (host reboot, containers manually stopped, etc.) would
+// otherwise relaunch owpengram-server straight into a DB-connect failure
+// with no clear signal why, instead of surfacing "Postgres not ready" here.
+func (m *Manager) ensureDocker(ctx context.Context, st State) (string, error) {
+ composeFile := filepath.Join(m.Root, "deploy", "docker-compose.yml")
+ if _, err := os.Stat(composeFile); os.IsNotExist(err) {
+ return "", nil
+ }
+
+ cmd := exec.CommandContext(ctx, "docker", "compose", "-f", composeFile, "up", "-d")
+ cmd.Dir = m.Root
+ cmd.Env = append(os.Environ(),
+ "TELESRV_DOCKER_PROJECT="+st.DockerProject,
+ "TELESRV_DOCKER_PREFIX="+st.DockerPrefix,
+ )
+ hideWindow(cmd)
+ out, err := cmd.CombinedOutput()
+ log := "$ docker compose up -d\n" + string(out)
+ if err != nil {
+ return log, fmt.Errorf("docker compose up failed: %w", err)
+ }
+
+ deadline := time.Now().Add(postgresWaitTimeout)
+ for {
+ pgCmd := exec.CommandContext(ctx, "docker", "exec", st.DockerPrefix+"-postgres", "pg_isready", "-U", "telesrv", "-d", "telesrv")
+ hideWindow(pgCmd)
+ if pgCmd.Run() == nil {
+ return log + "\nPostgreSQL ready\n", nil
+ }
+ if time.Now().After(deadline) {
+ return log, fmt.Errorf("PostgreSQL not ready after %s", postgresWaitTimeout)
+ }
+ select {
+ case <-ctx.Done():
+ return log, ctx.Err()
+ case <-time.After(postgresWaitInterval):
+ }
+ }
+}
+
+// DockerService is one container's live status, as reported by
+// `docker compose ps`. State is Docker's raw container state ("running",
+// "exited", ...); Health is the healthcheck status ("healthy", "starting",
+// "unhealthy") or "" for a container/image with no healthcheck defined --
+// all three services in deploy/docker-compose.yml (postgres/redis/minio)
+// declare one, so "" in practice means Docker hasn't reported yet.
+type DockerService struct {
+ Name string `json:"name"` // compose service name, e.g. "postgres"
+ State string `json:"state"`
+ Health string `json:"health"`
+}
+
+// dockerComposePsRow mirrors the fields `docker compose ps --format json`
+// emits (one JSON object per line, Compose v2's ndjson convention -- NOT a
+// single JSON array).
+type dockerComposePsRow struct {
+ Service string `json:"Service"`
+ State string `json:"State"`
+ Health string `json:"Health"`
+}
+
+// DockerStatus reports the live state of every service in
+// deploy/docker-compose.yml, for the admin panel's "Services" tab. Returns
+// an empty slice (not an error) when the compose file doesn't exist, same
+// convention as ensureDocker.
+func (m *Manager) DockerStatus(ctx context.Context) ([]DockerService, error) {
+ composeFile := filepath.Join(m.Root, "deploy", "docker-compose.yml")
+ if _, err := os.Stat(composeFile); os.IsNotExist(err) {
+ return nil, nil
+ }
+ st := m.loadState()
+ cmd := exec.CommandContext(ctx, "docker", "compose", "-f", composeFile, "ps", "--all", "--format", "json")
+ cmd.Dir = m.Root
+ cmd.Env = append(os.Environ(),
+ "TELESRV_DOCKER_PROJECT="+st.DockerProject,
+ "TELESRV_DOCKER_PREFIX="+st.DockerPrefix,
+ )
+ hideWindow(cmd)
+ out, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("docker compose ps failed: %w", err)
+ }
+ var services []DockerService
+ for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ var row dockerComposePsRow
+ if err := json.Unmarshal([]byte(line), &row); err != nil {
+ continue
+ }
+ services = append(services, DockerService{Name: row.Service, State: row.State, Health: row.Health})
+ }
+ return services, nil
+}
+
+// CheckUpdates fetches from the remote and reports how many commits the
+// local branch is behind its upstream, WITHOUT pulling or building anything
+// -- the Services tab's "Check updates" button uses this to decide whether
+// to offer a real Update (GitPull + rebuild + restart) or tell the operator
+// they're already current.
+func (m *Manager) CheckUpdates(ctx context.Context) (int, error) {
+ fetchCmd := exec.CommandContext(ctx, "git", "fetch")
+ fetchCmd.Dir = m.Root
+ hideWindow(fetchCmd)
+ if out, err := fetchCmd.CombinedOutput(); err != nil {
+ return 0, fmt.Errorf("git fetch failed: %w: %s", err, strings.TrimSpace(string(out)))
+ }
+ countCmd := exec.CommandContext(ctx, "git", "rev-list", "--count", "HEAD..@{upstream}")
+ countCmd.Dir = m.Root
+ hideWindow(countCmd)
+ out, err := countCmd.Output()
+ if err != nil {
+ return 0, fmt.Errorf("git rev-list failed: %w", err)
+ }
+ n, err := strconv.Atoi(strings.TrimSpace(string(out)))
+ if err != nil {
+ return 0, fmt.Errorf("parse commit count: %w", err)
+ }
+ return n, nil
+}
+
+// --- build steps ---------------------------------------------------------
+
+// GitPull runs `git pull --ff-only`, deliberately never a real merge -- see
+// the identical reasoning in server-panel.py's git_pull().
+func (m *Manager) GitPull(ctx context.Context) (string, error) {
+ cmd := exec.CommandContext(ctx, "git", "pull", "--ff-only")
+ cmd.Dir = m.Root
+ hideWindow(cmd)
+ out, err := cmd.CombinedOutput()
+ log := "$ git pull --ff-only\n" + string(out)
+ return log, err
+}
+
+// buildServer builds only bin/owpengram-server.
+func (m *Manager) buildServer(ctx context.Context) (string, error) {
+ return m.goBuild(ctx, m.serverExe(), "./cmd/telesrv")
+}
+
+// buildBoth builds bin/owpengram-server and bin/owpengram-admin-panel, like
+// server-panel.py's build(). Used by Update, which leaves a fresh admin
+// binary on disk even though it doesn't self-restart into it (see package
+// doc).
+func (m *Manager) buildBoth(ctx context.Context) (string, error) {
+ serverLog, err := m.buildServer(ctx)
+ if err != nil {
+ return serverLog, err
+ }
+ adminLog, err := m.goBuild(ctx, m.adminExe(), "./cmd/telesrv-admin")
+ return serverLog + "\n" + adminLog, err
+}
+
+func (m *Manager) goBuild(ctx context.Context, outPath, pkg string) (string, error) {
+ if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
+ return "", fmt.Errorf("mkdir bin: %w", err)
+ }
+ cmd := exec.CommandContext(ctx, "go", "build", "-o", outPath, pkg)
+ cmd.Dir = m.Root
+ hideWindow(cmd)
+ out, err := cmd.CombinedOutput()
+ return fmt.Sprintf("$ go build -o %s %s\n%s", filepath.Base(outPath), pkg, string(out)), err
+}
+
+// --- high-level actions ----------------------------------------------------
+
+// Restart rebuilds BOTH bin/owpengram-server and bin/owpengram-admin-panel
+// from the current working tree (no git pull -- see Update for that) and
+// relaunches owpengram-server, which then bounces the admin panel onto its
+// freshly built binary. Never self-restarts the admin process handling this
+// request directly -- see HandlePendingAdminRestart's doc comment for why
+// that handoff happens from the newly launched server instead. Returns a
+// combined build/relaunch log for the admin UI.
+func (m *Manager) Restart(ctx context.Context) (string, error) {
+ st := m.loadState()
+ if pidAlive(st.ServerPID) {
+ killPID(st.ServerPID)
+ }
+ dockerLog, err := m.ensureDocker(ctx, st)
+ if err != nil {
+ return dockerLog, err
+ }
+ buildLog, err := m.buildBoth(ctx)
+ fullLog := dockerLog + "\n" + buildLog
+ if err != nil {
+ return fullLog, fmt.Errorf("build failed: %w", err)
+ }
+ pid, err := m.launch(m.serverExe(), m.serverLog())
+ if err != nil {
+ return fullLog, fmt.Errorf("launch failed: %w", err)
+ }
+ st.ServerPID = pid
+ // Ask the process we just launched to bounce the admin panel for us
+ // once it's up -- see HandlePendingAdminRestart's doc comment for why
+ // that's the safe side of this handoff to do it from.
+ st.PendingAdminRestart = true
+ if err := m.saveState(st); err != nil {
+ return fullLog, fmt.Errorf("save state: %w", err)
+ }
+ return fullLog + fmt.Sprintf("\nowpengram-server relaunched, pid=%d. Admin panel will restart shortly onto its freshly built binary.\n", pid), nil
+}
+
+// Update is Restart plus a `git pull --ff-only` first, so a fresh checkout
+// gets built instead of whatever's already on disk.
+func (m *Manager) Update(ctx context.Context) (string, error) {
+ pullLog, err := m.GitPull(ctx)
+ if err != nil {
+ return pullLog, fmt.Errorf("git pull failed: %w", err)
+ }
+ st := m.loadState()
+ if pidAlive(st.ServerPID) {
+ killPID(st.ServerPID)
+ }
+ dockerLog, err := m.ensureDocker(ctx, st)
+ fullLog := pullLog + "\n" + dockerLog
+ if err != nil {
+ return fullLog, err
+ }
+ buildLog, err := m.buildBoth(ctx)
+ fullLog = fullLog + "\n" + buildLog
+ if err != nil {
+ return fullLog, fmt.Errorf("build failed: %w", err)
+ }
+ pid, err := m.launch(m.serverExe(), m.serverLog())
+ if err != nil {
+ return fullLog, fmt.Errorf("launch failed: %w", err)
+ }
+ st.ServerPID = pid
+ st.PendingAdminRestart = true
+ if err := m.saveState(st); err != nil {
+ return fullLog, fmt.Errorf("save state: %w", err)
+ }
+ return fullLog + fmt.Sprintf("\nowpengram-server relaunched, pid=%d. Admin panel will restart shortly onto its freshly built binary.\n", pid), nil
+}
+
+// HandlePendingAdminRestart is called once by owpengram-server itself, right
+// after it confirms it's up and serving (see cmd/telesrv/main.go's
+// OnServing hook) -- never by the admin panel on itself. That ordering is
+// the whole point: by the time this runs, the *new* owpengram-server
+// process already exists and is unrelated to whatever admin panel process
+// is currently running, so killing the old admin PID and launching a new
+// one here carries none of the risk self-restarting mid-HTTP-request would
+// (see the package doc). A no-op when no restart was requested.
+func (m *Manager) HandlePendingAdminRestart(ctx context.Context) (bool, error) {
+ st := m.loadState()
+ if !st.PendingAdminRestart {
+ return false, nil
+ }
+ if pidAlive(st.AdminPID) {
+ killPID(st.AdminPID)
+ }
+ pid, err := m.launch(m.adminExe(), m.adminLog())
+ if err != nil {
+ return false, fmt.Errorf("launch admin panel: %w", err)
+ }
+ st.AdminPID = pid
+ st.PendingAdminRestart = false
+ if err := m.saveState(st); err != nil {
+ return true, fmt.Errorf("save state: %w", err)
+ }
+ return true, nil
+}
+
+// --- .env.example / .env editing -------------------------------------------
+
+var (
+ activeFieldRe = regexp.MustCompile(`^(TELESRV_[A-Z0-9_]+)=(.*)$`)
+ commentedFieldRe = regexp.MustCompile(`^#\s*(TELESRV_[A-Z0-9_]+)=(.*)$`)
+ sensitiveKeyRe = regexp.MustCompile(`(PASSWORD|SECRET|_TOKEN|API_KEY)`)
+ // sensitiveKeyExceptRe excludes names that trip sensitiveKeyRe on the
+ // word "SECRET" while meaning Telegram's secret-chat feature, not a
+ // credential (e.g. TELESRV_SECRET_CHAT_DELETE_FILE_AFTER_DOWNLOAD) --
+ // there's nothing to mask there, it's a plain boolean toggle.
+ sensitiveKeyExceptRe = regexp.MustCompile(`SECRET_CHAT`)
+ groupHeaderRe = regexp.MustCompile(`^##\s*(.+?)\s*--\s*(.+)$`)
+ sectionBreakRe = regexp.MustCompile(`^#\s*={10,}\s*$`)
+)
+
+type EnvField struct {
+ Key string `json:"key"`
+ DefaultValue string `json:"default_value"`
+ Description string `json:"description"`
+ EnabledByDefault bool `json:"enabled_by_default"`
+ Sensitive bool `json:"sensitive"`
+ // Value is the field's current effective value: from .env when set,
+ // otherwise DefaultValue (only when EnabledByDefault), else empty --
+ // exactly current_env_values()'s semantics in server-panel.py.
+ Value string `json:"value"`
+}
+
+type EnvGroup struct {
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Fields []EnvField `json:"fields"`
+}
+
+// ReadEnvGroups parses .env.example into the same panel-visible groups
+// server-panel.py's parse_env_template() does (identical header/format
+// rules -- see that function's docstring), then fills in each field's
+// current effective value from .env.
+func (m *Manager) ReadEnvGroups() ([]EnvGroup, error) {
+ tmplPath := filepath.Join(m.Root, ".env.example")
+ tmplData, err := os.ReadFile(tmplPath)
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("read .env.example: %w", err)
+ }
+ envValues, err := m.readEnvFile()
+ if err != nil {
+ return nil, err
+ }
+
+ var groups []EnvGroup
+ var current *EnvGroup
+ var pending []string
+ inCommentRun := false
+ seen := map[string]bool{}
+
+ appendField := func(key, defaultValue, description string, enabledByDefault bool) {
+ if current == nil || seen[key] {
+ return
+ }
+ seen[key] = true
+ value, has := envValues[key]
+ if !has {
+ if enabledByDefault {
+ value = defaultValue
+ } else {
+ value = ""
+ }
+ }
+ current.Fields = append(current.Fields, EnvField{
+ Key: key,
+ DefaultValue: defaultValue,
+ Description: description,
+ EnabledByDefault: enabledByDefault,
+ Sensitive: sensitiveKeyRe.MatchString(key) && !sensitiveKeyExceptRe.MatchString(key),
+ Value: value,
+ })
+ }
+
+ for _, raw := range strings.Split(string(tmplData), "\n") {
+ line := strings.TrimSpace(raw)
+ if line == "" {
+ pending = nil
+ inCommentRun = false
+ continue
+ }
+ if h := groupHeaderRe.FindStringSubmatch(line); h != nil {
+ groups = append(groups, EnvGroup{Title: strings.TrimSpace(h[1]), Description: strings.TrimSpace(h[2])})
+ current = &groups[len(groups)-1]
+ pending = nil
+ inCommentRun = false
+ continue
+ }
+ if sectionBreakRe.MatchString(line) {
+ current = nil
+ pending = nil
+ inCommentRun = false
+ continue
+ }
+ if a := activeFieldRe.FindStringSubmatch(line); a != nil {
+ appendField(a[1], a[2], strings.Join(pending, " "), true)
+ inCommentRun = false
+ continue
+ }
+ if strings.HasPrefix(line, "#") {
+ if c := commentedFieldRe.FindStringSubmatch(line); c != nil {
+ appendField(c[1], c[2], strings.Join(pending, " "), false)
+ inCommentRun = false
+ continue
+ }
+ text := strings.TrimSpace(strings.TrimLeft(line, "#"))
+ if inCommentRun {
+ pending = append(pending, text)
+ } else {
+ pending = []string{text}
+ }
+ inCommentRun = true
+ continue
+ }
+ inCommentRun = false
+ }
+
+ out := groups[:0]
+ for _, g := range groups {
+ if len(g.Fields) > 0 {
+ out = append(out, g)
+ }
+ }
+ return out, nil
+}
+
+func (m *Manager) readEnvFile() (map[string]string, error) {
+ values := map[string]string{}
+ data, err := os.ReadFile(filepath.Join(m.Root, ".env"))
+ if os.IsNotExist(err) {
+ return values, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("read .env: %w", err)
+ }
+ for _, raw := range strings.Split(string(data), "\n") {
+ line := strings.TrimSpace(raw)
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ idx := strings.IndexByte(line, '=')
+ if idx <= 0 {
+ continue
+ }
+ values[line[:idx]] = strings.TrimSpace(line[idx+1:])
+ }
+ return values, nil
+}
+
+// WriteEnvValues rewrites .env from .env.example's exact text, substituting
+// each known key's value in place -- see save_env()'s docstring in
+// server-panel.py for why this (not a fresh key=value dump) is what
+// preserves comments/layout. Only keys present in values are set to a new
+// value; every other key keeps whatever is already in the current .env
+// (falling back to the template's own default only for a key .env never
+// set) -- previously this fell straight back to the template default for
+// any key not in this particular save's payload, silently wiping out every
+// other customized setting (e.g. TELESRV_ADMIN_UI_PASSWORD) on every save
+// that only touches one section's keys. A template-commented optional field
+// is uncommented when given a non-empty value and left as-is when given an
+// empty one.
+func (m *Manager) WriteEnvValues(values map[string]string) error {
+ tmplPath := filepath.Join(m.Root, ".env.example")
+ tmplData, err := os.ReadFile(tmplPath)
+ if err != nil {
+ return fmt.Errorf("read .env.example: %w", err)
+ }
+ existing, err := m.readEnvFile()
+ if err != nil {
+ return err
+ }
+ lines := strings.Split(string(tmplData), "\n")
+ // Split() on a trailing "\n" leaves one empty trailing element; drop it
+ // so the join below doesn't add a spurious blank line before the final
+ // newline this function appends anyway.
+ if len(lines) > 0 && lines[len(lines)-1] == "" {
+ lines = lines[:len(lines)-1]
+ }
+ out := make([]string, 0, len(lines))
+ seen := map[string]bool{}
+ for _, raw := range lines {
+ line := strings.TrimSpace(raw)
+ if a := activeFieldRe.FindStringSubmatch(line); a != nil && !seen[a[1]] {
+ if v, ok := values[a[1]]; ok {
+ seen[a[1]] = true
+ out = append(out, a[1]+"="+v)
+ continue
+ }
+ if v, ok := existing[a[1]]; ok {
+ seen[a[1]] = true
+ out = append(out, a[1]+"="+v)
+ continue
+ }
+ }
+ if c := commentedFieldRe.FindStringSubmatch(line); c != nil && !seen[c[1]] {
+ if v, ok := values[c[1]]; ok {
+ seen[c[1]] = true
+ if v != "" {
+ out = append(out, c[1]+"="+v)
+ } else {
+ out = append(out, raw)
+ }
+ continue
+ }
+ // A previously-enabled optional field shows up in the current
+ // .env as an active line even though the template still has it
+ // commented out -- keep it enabled with its existing value.
+ if v, ok := existing[c[1]]; ok {
+ seen[c[1]] = true
+ out = append(out, c[1]+"="+v)
+ continue
+ }
+ }
+ out = append(out, raw)
+ }
+ return os.WriteFile(filepath.Join(m.Root, ".env"), []byte(strings.Join(out, "\n")+"\n"), 0o644)
+}
diff --git a/internal/readmodelcache/cache.go b/internal/readmodelcache/cache.go
index c318dc7a..a7ad90aa 100644
--- a/internal/readmodelcache/cache.go
+++ b/internal/readmodelcache/cache.go
@@ -32,6 +32,11 @@ type Config[K comparable, V any] struct {
// MaxEntries 是 LRU 上界。<=0 时 New 返回 nil(等价"禁用缓存",沿用各处
// New*Cache(max<=0)->nil 的惯例;所有方法对 nil 安全,退化为直接 load)。
MaxEntries int
+ // MaxWeight 是可选的第二容量边界。>0 时每个值由 Weight 计算权重,缓存同时
+ // 满足 MaxEntries 与 MaxWeight;单项超过上限时本次仍可返回但不驻留。
+ MaxWeight int64
+ // Weight 计算一个缓存值的相对占用;仅 MaxWeight>0 时使用。nil 时每项权重为 1。
+ Weight func(V) int64
// TTL 仅作安全兜底(漏掉的带外写)。0 = 纯事件驱动,无时间过期。
TTL time.Duration
// Clone 在 store 与返回两个边界上对值做深拷贝,隔离调用方与缓存项的别名突变。
@@ -43,6 +48,10 @@ type Config[K comparable, V any] struct {
// Now 注入时钟,仅用于 TTL 过期判断;nil 时默认 time.Now。生产一律留空,
// 测试可注入假时钟以确定地推进 TTL。
Now func() time.Time
+ // OnStore/OnRemove 供依赖倒排索引同步生命周期。回调在缓存锁内执行,
+ // 不得回调本 Cache 或阻塞;收到的 value 是缓存持有的 immutable clone。
+ OnStore func(K, V)
+ OnRemove func(K, V)
}
type lruEntry[K comparable, V any] struct {
@@ -50,6 +59,7 @@ type lruEntry[K comparable, V any] struct {
value V
hash int64
expireAt time.Time // 零值 = 不过期
+ weight int64
}
// Cache 是泛型 read-model 缓存。零值不可用,必须经 New 构造。nil *Cache 合法:
@@ -59,12 +69,39 @@ type Cache[K comparable, V any] struct {
ll *list.List // LRU 顺序,Front=最近使用
items map[K]*list.Element
cap int
+ maxWeight int64
+ weight int64
ttl time.Duration
epoch uint64
sf singleflight.Group
clone func(V) V
+ weigh func(V) int64
keyString func(K) string
now func() time.Time
+ onStore func(K, V)
+ onRemove func(K, V)
+
+ // batchFlights coordinates individual keys across overlapping concurrent
+ // GetOrLoadBatch calls. A singleflight key for the whole input slice cannot
+ // coalesce {1,2,3} with {2,3,4}; tracking the misses per key lets the first
+ // caller own 2/3 while the second still loads 4 in its own backend batch.
+ batchMu sync.Mutex
+ batchFlights map[batchFlightKey[K]]*batchFlight[V]
+}
+
+type batchFlightKey[K comparable] struct {
+ key K
+ hash int64
+ cacheable bool
+ epoch uint64
+}
+
+type batchFlight[V any] struct {
+ done chan struct{}
+ value V
+ ok bool
+ err error
+ retry bool
}
// New 构造一个 Cache。MaxEntries<=0 时返回 nil(禁用缓存,沿用既有惯例)。
@@ -81,13 +118,18 @@ func New[K comparable, V any](cfg Config[K, V]) *Cache[K, V] {
now = time.Now
}
return &Cache[K, V]{
- ll: list.New(),
- items: make(map[K]*list.Element, initialMapHint(cfg.MaxEntries)),
- cap: cfg.MaxEntries,
- ttl: cfg.TTL,
- clone: cfg.Clone,
- keyString: keyString,
- now: now,
+ ll: list.New(),
+ items: make(map[K]*list.Element, initialMapHint(cfg.MaxEntries)),
+ cap: cfg.MaxEntries,
+ maxWeight: cfg.MaxWeight,
+ ttl: cfg.TTL,
+ clone: cfg.Clone,
+ weigh: cfg.Weight,
+ keyString: keyString,
+ now: now,
+ onStore: cfg.OnStore,
+ onRemove: cfg.OnRemove,
+ batchFlights: make(map[batchFlightKey[K]]*batchFlight[V]),
}
}
@@ -189,19 +231,38 @@ func (c *Cache[K, V]) storeIfEpoch(key K, v V, hash int64, loadEpoch uint64) boo
}
func (c *Cache[K, V]) storeLocked(key K, v V, hash int64) {
+ weight := c.valueWeight(v)
+ if c.maxWeight > 0 && weight > c.maxWeight {
+ if el, ok := c.items[key]; ok {
+ c.removeElement(el)
+ }
+ return
+ }
if el, ok := c.items[key]; ok {
ent := el.Value.(*lruEntry[K, V])
+ if c.onRemove != nil {
+ c.onRemove(ent.key, ent.value)
+ }
+ c.weight -= ent.weight
ent.value = c.cloneValue(v)
ent.hash = hash
ent.expireAt = c.expireAt()
+ ent.weight = weight
+ c.weight += weight
+ if c.onStore != nil {
+ c.onStore(ent.key, ent.value)
+ }
c.ll.MoveToFront(el)
+ c.evictOverflow()
return
}
- ent := &lruEntry[K, V]{key: key, value: c.cloneValue(v), hash: hash, expireAt: c.expireAt()}
+ ent := &lruEntry[K, V]{key: key, value: c.cloneValue(v), hash: hash, expireAt: c.expireAt(), weight: weight}
c.items[key] = c.ll.PushFront(ent)
- if c.ll.Len() > c.cap {
- c.evictOldest()
+ c.weight += weight
+ if c.onStore != nil {
+ c.onStore(ent.key, ent.value)
}
+ c.evictOverflow()
}
// Store 把一个已在手的值写入缓存(warm-from-list 路径)。不自增 epoch:它不是失效,
@@ -292,31 +353,58 @@ func (c *Cache[K, V]) GetOrLoadBatch(
if len(missing) == 0 {
return out, nil
}
- missingKeys := make([]K, len(missing))
- for i := range missing {
- missingKeys[i] = missing[i].key
+ waits, owned := c.claimBatchFlights(missing, loadEpoch)
+ if len(owned) > 0 {
+ ownedKeys := make([]K, len(owned))
+ for i := range owned {
+ ownedKeys[i] = owned[i].miss.key
+ }
+ loaded, loadErr := loadMissing(ctx, ownedKeys)
+ retry := false
+ if loadErr == nil {
+ entries := make([]batchStoreEntry[K, V], 0, len(owned))
+ for _, owner := range owned {
+ value, ok := loaded[owner.miss.key]
+ if ok && owner.miss.cacheable {
+ entries = append(entries, batchStoreEntry[K, V]{
+ key: owner.miss.key, value: value, hash: owner.miss.hash,
+ })
+ }
+ }
+ retry = !c.storeBatchIfEpoch(entries, loadEpoch)
+ }
+ for _, owner := range owned {
+ value, ok := loaded[owner.miss.key]
+ c.completeBatchFlight(owner.key, owner.flight, value, ok, loadErr, retry)
+ }
}
- loaded, err := loadMissing(ctx, missingKeys)
- if err != nil {
- return nil, err
+
+ retry := false
+ for _, wait := range waits {
+ select {
+ case <-wait.flight.done:
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+ if wait.flight.err != nil {
+ return nil, wait.flight.err
+ }
+ if wait.flight.retry {
+ retry = true
+ continue
+ }
+ if wait.flight.ok {
+ out[wait.miss.key] = c.cloneValue(wait.flight.value)
+ }
}
- if c.cacheEpoch() != loadEpoch {
- // 失效在批量 load 期间到达:重试整趟,避免用 pre-invalidation 数据遮蔽它。
+ if retry {
+ // 失效在任一 owner 的批量 load 期间到达:所有参与者重查,
+ // 不让 pre-invalidation 的共享 flight 值越过 epoch 边界。
if err := ctx.Err(); err != nil {
return nil, err
}
continue
}
- for _, m := range missing {
- v, ok := loaded[m.key]
- if !ok {
- continue
- }
- out[m.key] = v
- if m.cacheable {
- c.storeIfEpoch(m.key, v, m.hash, loadEpoch)
- }
- }
return out, nil
}
}
@@ -327,6 +415,76 @@ type batchMiss[K comparable] struct {
cacheable bool
}
+type batchWait[K comparable, V any] struct {
+ miss batchMiss[K]
+ key batchFlightKey[K]
+ flight *batchFlight[V]
+}
+
+type batchStoreEntry[K comparable, V any] struct {
+ key K
+ value V
+ hash int64
+}
+
+func (c *Cache[K, V]) claimBatchFlights(
+ missing []batchMiss[K],
+ epoch uint64,
+) (waits []batchWait[K, V], owned []batchWait[K, V]) {
+ waits = make([]batchWait[K, V], 0, len(missing))
+ owned = make([]batchWait[K, V], 0, len(missing))
+ c.batchMu.Lock()
+ for _, miss := range missing {
+ key := batchFlightKey[K]{key: miss.key, hash: miss.hash, cacheable: miss.cacheable, epoch: epoch}
+ flight, found := c.batchFlights[key]
+ wait := batchWait[K, V]{miss: miss, key: key, flight: flight}
+ if !found {
+ flight = &batchFlight[V]{done: make(chan struct{})}
+ c.batchFlights[key] = flight
+ wait.flight = flight
+ owned = append(owned, wait)
+ }
+ waits = append(waits, wait)
+ }
+ c.batchMu.Unlock()
+ return waits, owned
+}
+
+func (c *Cache[K, V]) completeBatchFlight(
+ key batchFlightKey[K],
+ flight *batchFlight[V],
+ value V,
+ ok bool,
+ err error,
+ retry bool,
+) {
+ c.batchMu.Lock()
+ flight.value = c.cloneValue(value)
+ flight.ok = ok
+ flight.err = err
+ flight.retry = retry
+ if current := c.batchFlights[key]; current == flight {
+ delete(c.batchFlights, key)
+ }
+ close(flight.done)
+ c.batchMu.Unlock()
+}
+
+// storeBatchIfEpoch makes the write side of one batch atomic with respect to
+// invalidation. Besides avoiding partial warm state, this gives every waiter
+// one unambiguous retry decision for the batch generation it joined.
+func (c *Cache[K, V]) storeBatchIfEpoch(entries []batchStoreEntry[K, V], expected uint64) bool {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.epoch != expected {
+ return false
+ }
+ for _, entry := range entries {
+ c.storeLocked(entry.key, entry.value, entry.hash)
+ }
+ return true
+}
+
func dedupeKeys[K comparable](keys []K) []K {
seen := make(map[K]struct{}, len(keys))
out := make([]K, 0, len(keys))
@@ -391,6 +549,26 @@ func (c *Cache[K, V]) InvalidateWhere(pred func(K) bool) {
c.mu.Unlock()
}
+// InvalidateWhereValue is the dependency-aware form of InvalidateWhere. It is
+// intended for bounded composite snapshots whose invalidation key is carried
+// by the immutable cached value (for example channel_id -> owner dialog page).
+// pred runs under the cache lock and therefore must be fast and must not call
+// back into this cache.
+func (c *Cache[K, V]) InvalidateWhereValue(pred func(K, V) bool) {
+ if c == nil || pred == nil {
+ return
+ }
+ c.mu.Lock()
+ c.epoch++
+ for key, el := range c.items {
+ ent := el.Value.(*lruEntry[K, V])
+ if pred(key, ent.value) {
+ c.removeElement(el)
+ }
+ }
+ c.mu.Unlock()
+}
+
// Flush 清空缓存并自增 epoch(监听器断线重连兜底)。
func (c *Cache[K, V]) Flush() {
if c == nil {
@@ -398,8 +576,15 @@ func (c *Cache[K, V]) Flush() {
}
c.mu.Lock()
c.epoch++
+ if c.onRemove != nil {
+ for el := c.ll.Front(); el != nil; el = el.Next() {
+ ent := el.Value.(*lruEntry[K, V])
+ c.onRemove(ent.key, ent.value)
+ }
+ }
c.ll.Init()
c.items = make(map[K]*list.Element, initialMapHint(c.cap))
+ c.weight = 0
c.mu.Unlock()
}
@@ -414,6 +599,19 @@ func (c *Cache[K, V]) Len() int {
return n
}
+// Weight returns the current aggregate configured weight. It is intended for
+// bounded observability and tests; callers must not use it as a correctness
+// input because Weight is deliberately an approximation chosen by each cache.
+func (c *Cache[K, V]) Weight() int64 {
+ if c == nil {
+ return 0
+ }
+ c.mu.Lock()
+ weight := c.weight
+ c.mu.Unlock()
+ return weight
+}
+
func (c *Cache[K, V]) cacheEpoch() uint64 {
c.mu.Lock()
e := c.epoch
@@ -438,9 +636,34 @@ func (c *Cache[K, V]) evictOldest() {
}
}
+func (c *Cache[K, V]) evictOverflow() {
+ for c.ll.Len() > c.cap || (c.maxWeight > 0 && c.weight > c.maxWeight) {
+ if c.ll.Back() == nil {
+ return
+ }
+ c.evictOldest()
+ }
+}
+
func (c *Cache[K, V]) removeElement(el *list.Element) {
+ ent := el.Value.(*lruEntry[K, V])
+ if c.onRemove != nil {
+ c.onRemove(ent.key, ent.value)
+ }
+ c.weight -= ent.weight
c.ll.Remove(el)
- delete(c.items, el.Value.(*lruEntry[K, V]).key)
+ delete(c.items, ent.key)
+}
+
+func (c *Cache[K, V]) valueWeight(v V) int64 {
+ if c.maxWeight <= 0 || c.weigh == nil {
+ return 1
+ }
+ weight := c.weigh(v)
+ if weight <= 0 {
+ return 1
+ }
+ return weight
}
func (c *Cache[K, V]) cloneValue(v V) V {
diff --git a/internal/readmodelcache/cache_test.go b/internal/readmodelcache/cache_test.go
index 7dc4a3af..7f4f7bfc 100644
--- a/internal/readmodelcache/cache_test.go
+++ b/internal/readmodelcache/cache_test.go
@@ -3,6 +3,7 @@ package readmodelcache
import (
"context"
"fmt"
+ "reflect"
"sync"
"sync/atomic"
"testing"
@@ -81,6 +82,28 @@ func TestGetOrLoadSingleflightsConcurrentMiss(t *testing.T) {
}
}
+func TestInvalidateWhereValueUsesImmutableDependency(t *testing.T) {
+ type value struct{ channels []int64 }
+ c := New[int, value](Config[int, value]{MaxEntries: 4})
+ c.Store(1, value{channels: []int64{7, 8}})
+ c.Store(2, value{channels: []int64{9}})
+
+ c.InvalidateWhereValue(func(_ int, v value) bool {
+ for _, id := range v.channels {
+ if id == 8 {
+ return true
+ }
+ }
+ return false
+ })
+ if _, ok := c.Peek(1); ok {
+ t.Fatal("dependency match remained cached")
+ }
+ if got, ok := c.Peek(2); !ok || len(got.channels) != 1 || got.channels[0] != 9 {
+ t.Fatalf("unrelated value = %+v,%v, want cached channel 9", got, ok)
+ }
+}
+
// TestEpochGuardRejectsStaleWriteback 证明 epoch 守卫堵住 lost-update:一次锁外 load
// 期间到达的 Invalidate 不得被这次 load 的(已陈旧)结果覆盖;在飞读者最终拿到的是
// 失效后重载的新值,且缓存未被陈旧值污染。
@@ -178,6 +201,76 @@ func TestLRUTouchOnGet(t *testing.T) {
}
}
+func TestWeightedLRUEvictsByTotalWeightAndSkipsOversize(t *testing.T) {
+ ctx := context.Background()
+ c := New[int, int](Config[int, int]{
+ MaxEntries: 10,
+ MaxWeight: 5,
+ Weight: func(v int) int64 { return int64(v) },
+ })
+ mustLoad(t, c, 1, 2)
+ mustLoad(t, c, 2, 2)
+ mustLoad(t, c, 3, 3)
+ if _, ok := c.Peek(1); ok {
+ t.Fatal("oldest entry should be evicted when aggregate weight exceeds five")
+ }
+ for _, key := range []int{2, 3} {
+ if _, ok := c.Peek(key); !ok {
+ t.Fatalf("weighted LRU lost retained key %d", key)
+ }
+ }
+ loads := 0
+ loadOversize := func() (int, error) { loads++; return 6, nil }
+ if v, err := c.GetOrLoad(ctx, 4, loadOversize); err != nil || v != 6 {
+ t.Fatalf("oversize first load = %d,%v", v, err)
+ }
+ if v, err := c.GetOrLoad(ctx, 4, loadOversize); err != nil || v != 6 {
+ t.Fatalf("oversize second load = %d,%v", v, err)
+ }
+ if loads != 2 {
+ t.Fatalf("oversize value unexpectedly retained: loads=%d, want 2", loads)
+ }
+ if _, ok := c.Peek(4); ok {
+ t.Fatal("single value above MaxWeight must not remain cached")
+ }
+}
+
+func TestLifecycleCallbacksCoverReplaceEvictAndFlush(t *testing.T) {
+ type event struct {
+ op string
+ key int
+ value string
+ }
+ var events []event
+ c := New[int, string](Config[int, string]{
+ MaxEntries: 2,
+ OnStore: func(key int, value string) {
+ events = append(events, event{op: "store", key: key, value: value})
+ },
+ OnRemove: func(key int, value string) {
+ events = append(events, event{op: "remove", key: key, value: value})
+ },
+ })
+ c.Store(1, "a")
+ c.Store(1, "b")
+ c.Store(2, "c")
+ c.Store(3, "d")
+ c.Flush()
+ want := []event{
+ {op: "store", key: 1, value: "a"},
+ {op: "remove", key: 1, value: "a"},
+ {op: "store", key: 1, value: "b"},
+ {op: "store", key: 2, value: "c"},
+ {op: "store", key: 3, value: "d"},
+ {op: "remove", key: 1, value: "b"},
+ {op: "remove", key: 3, value: "d"},
+ {op: "remove", key: 2, value: "c"},
+ }
+ if !reflect.DeepEqual(events, want) {
+ t.Fatalf("lifecycle events = %#v, want %#v", events, want)
+ }
+}
+
func TestVersionGateReloadsOnHashChange(t *testing.T) {
ctx := context.Background()
c := New[int, string](Config[int, string]{MaxEntries: 16})
@@ -389,6 +482,74 @@ func TestGetOrLoadBatchCachesHitsMissesAndNegatives(t *testing.T) {
}
}
+func TestGetOrLoadBatchCoalescesOverlappingConcurrentMissesPerKey(t *testing.T) {
+ ctx := context.Background()
+ c := New[int, batchVal](Config[int, batchVal]{MaxEntries: 64})
+ noVersion := func(int) (int64, bool) { return 0, true }
+
+ firstStarted := make(chan struct{})
+ secondLoaded := make(chan struct{})
+ releaseFirst := make(chan struct{})
+ var calls atomic.Int32
+ var mu sync.Mutex
+ loadedKeys := make(map[int]int)
+ load := func(_ context.Context, missing []int) (map[int]batchVal, error) {
+ call := calls.Add(1)
+ mu.Lock()
+ for _, key := range missing {
+ loadedKeys[key]++
+ }
+ mu.Unlock()
+ if call == 1 {
+ close(firstStarted)
+ <-releaseFirst
+ } else {
+ close(secondLoaded)
+ }
+ out := make(map[int]batchVal, len(missing))
+ for _, key := range missing {
+ out[key] = batchVal{n: key * 10, found: true}
+ }
+ return out, nil
+ }
+
+ firstResult := make(chan map[int]batchVal, 1)
+ firstErr := make(chan error, 1)
+ go func() {
+ got, err := c.GetOrLoadBatch(ctx, []int{1, 2, 3}, noVersion, load)
+ firstResult <- got
+ firstErr <- err
+ }()
+ <-firstStarted
+
+ secondResult := make(chan map[int]batchVal, 1)
+ secondErr := make(chan error, 1)
+ go func() {
+ got, err := c.GetOrLoadBatch(ctx, []int{2, 3, 4}, noVersion, load)
+ secondResult <- got
+ secondErr <- err
+ }()
+ <-secondLoaded
+ close(releaseFirst)
+
+ first, second := <-firstResult, <-secondResult
+ if err := <-firstErr; err != nil {
+ t.Fatal(err)
+ }
+ if err := <-secondErr; err != nil {
+ t.Fatal(err)
+ }
+ if first[1].n != 10 || first[2].n != 20 || first[3].n != 30 ||
+ second[2].n != 20 || second[3].n != 30 || second[4].n != 40 {
+ t.Fatalf("overlapping results first=%+v second=%+v", first, second)
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ if calls.Load() != 2 || loadedKeys[1] != 1 || loadedKeys[2] != 1 || loadedKeys[3] != 1 || loadedKeys[4] != 1 {
+ t.Fatalf("backend calls=%d loaded=%v, want two batches and every key exactly once", calls.Load(), loadedKeys)
+ }
+}
+
func TestGetOrLoadBatchVersionGateReloadsOnHashChange(t *testing.T) {
ctx := context.Background()
c := New[int, batchVal](Config[int, batchVal]{MaxEntries: 64})
diff --git a/internal/rpc/account.go b/internal/rpc/account.go
index 06768f40..bec54ca4 100644
--- a/internal/rpc/account.go
+++ b/internal/rpc/account.go
@@ -1042,6 +1042,7 @@ func (r *Router) onAccountSetGlobalPrivacySettings(ctx context.Context, settings
return nil, internalErr()
}
r.accountSettings.Store(userID, saved)
+ r.invalidateRPCProjectionForUser(userID)
return tgGlobalPrivacySettings(saved.GlobalPrivacy), nil
}
return &settings, nil
@@ -1467,6 +1468,15 @@ func tgGlobalPrivacySettings(gp domain.GlobalPrivacy) *tg.GlobalPrivacySettings
if gp.NoncontactPeersPaidStars > 0 {
out.SetNoncontactPeersPaidStars(gp.NoncontactPeersPaidStars)
}
+ if !gp.DisallowedGifts.Zero() {
+ out.SetDisallowedGifts(tg.DisallowedGiftsSettings{
+ DisallowUnlimitedStargifts: gp.DisallowedGifts.UnlimitedStargifts,
+ DisallowLimitedStargifts: gp.DisallowedGifts.LimitedStargifts,
+ DisallowUniqueStargifts: gp.DisallowedGifts.UniqueStargifts,
+ DisallowPremiumGifts: gp.DisallowedGifts.PremiumGifts,
+ DisallowStargiftsFromChannels: gp.DisallowedGifts.StargiftsFromChannel,
+ })
+ }
return out
}
@@ -1482,6 +1492,15 @@ func domainGlobalPrivacy(settings tg.GlobalPrivacySettings) domain.GlobalPrivacy
if v, ok := settings.GetNoncontactPeersPaidStars(); ok && v > 0 {
gp.NoncontactPeersPaidStars = v
}
+ if gifts, ok := settings.GetDisallowedGifts(); ok {
+ gp.DisallowedGifts = domain.DisallowedGifts{
+ UnlimitedStargifts: gifts.DisallowUnlimitedStargifts,
+ LimitedStargifts: gifts.DisallowLimitedStargifts,
+ UniqueStargifts: gifts.DisallowUniqueStargifts,
+ PremiumGifts: gifts.DisallowPremiumGifts,
+ StargiftsFromChannel: gifts.DisallowStargiftsFromChannels,
+ }
+ }
return gp
}
diff --git a/internal/rpc/account_deletion.go b/internal/rpc/account_deletion.go
index 5bd90c98..0b13acf6 100644
--- a/internal/rpc/account_deletion.go
+++ b/internal/rpc/account_deletion.go
@@ -59,15 +59,11 @@ func (r *Router) onAccountDeleteAccount(ctx context.Context, req *tg.AccountDele
return false, tgerr.New(420, fmt.Sprintf("2FA_CONFIRM_WAIT_%d", wait))
}
r.finishDeletedAccountAuthorizations(ctx, userID, outcome.Deletion.RevokedAuthorizations)
- r.invalidateRPCProjectionForUser(userID)
- dispatchNotifications := func() {
- dispatchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
- r.runAccountLifecycleOnce(dispatchCtx, 500)
- }
- if !postresponse.Register(ctx, dispatchNotifications) {
- go dispatchNotifications()
- }
+ r.invalidateDeletedUserProjectionFacts(userID)
+ // A tombstone changes this target for every viewer. Flushing once is bounded
+ // and avoids four full-cache predicate scans; the PostgreSQL user_deleted
+ // event performs the same coarse invalidation on other instances.
+ r.flushRPCProjectionCache()
return true, nil
}
@@ -130,6 +126,26 @@ func (r *Router) finishDeletedAccountAuthorizations(ctx context.Context, userID
}
}
+// NotifyModerationAccountDeletion completes the runtime half of the durable
+// moderation tombstone. Moderation runs off-request, so every revoked auth key
+// can be disconnected immediately; reconnects then observe the missing durable
+// authorization and the auth service's tombstone guard.
+func (r *Router) NotifyModerationAccountDeletion(ctx context.Context, result domain.AccountDeletionResult) {
+ if r == nil || !result.Changed || result.User.ID == 0 {
+ return
+ }
+ r.finishDeletedAccountAuthorizations(ctx, result.User.ID, result.RevokedAuthorizations)
+ r.invalidateDeletedUserProjectionFacts(result.User.ID)
+ r.flushRPCProjectionCache()
+}
+
+func (r *Router) invalidateDeletedUserProjectionFacts(userID int64) {
+ if r == nil || userID == 0 || r.deps.UserProjectionFacts == nil {
+ return
+ }
+ r.deps.UserProjectionFacts.InvalidateAccountFreezeFact(userID)
+}
+
func accountDeletionErr(err error) error {
switch {
case errors.Is(err, domain.ErrPasswordHashInvalid), errors.Is(err, domain.ErrSRPIDInvalid), errors.Is(err, domain.ErrSRPPasswordChanged):
diff --git a/internal/rpc/account_deletion_rpc_test.go b/internal/rpc/account_deletion_rpc_test.go
index 779e69f3..ae83dd51 100644
--- a/internal/rpc/account_deletion_rpc_test.go
+++ b/internal/rpc/account_deletion_rpc_test.go
@@ -8,7 +8,6 @@ import (
"time"
"github.com/iamxvbaba/td/clock"
- "github.com/iamxvbaba/td/proto"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap/zaptest"
@@ -33,7 +32,8 @@ func TestAccountDeleteRPCDeliversResultBeforeClosingCurrentSession(t *testing.T)
},
}
sessions := &deletionCaptureSessions{}
- r := New(Config{}, Deps{Account: accountSvc, Sessions: sessions}, zaptest.NewLogger(t), clock.System)
+ facts := &recordingUserProjectionFactInvalidator{}
+ r := New(Config{}, Deps{Account: accountSvc, Sessions: sessions, UserProjectionFacts: facts}, zaptest.NewLogger(t), clock.System)
ctx := postresponse.WithCallbacks(WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 42), current), 77))
ok, err := r.onAccountDeleteAccount(ctx, &tg.AccountDeleteAccountRequest{Reason: "manual"})
if err != nil || !ok {
@@ -45,10 +45,19 @@ func TestAccountDeleteRPCDeliversResultBeforeClosingCurrentSession(t *testing.T)
if !sessions.wasClosed(other) {
t.Fatal("other auth key was not revoked immediately")
}
+ if accountSvc.sweepCalls != 0 {
+ t.Fatalf("account lifecycle sweeps before rpc_result delivery = %d, want 0", accountSvc.sweepCalls)
+ }
+ if len(facts.freezes) != 1 || facts.freezes[0] != 42 || len(facts.phones) != 1 || facts.phones[0] != 42 {
+ t.Fatalf("deleted user fact invalidations freezes=%v phones=%v, want [42]/[42]", facts.freezes, facts.phones)
+ }
postresponse.Run(ctx)
if !sessions.wasClosed(current) {
t.Fatal("current auth key not closed after rpc_result delivery")
}
+ if accountSvc.sweepCalls != 0 {
+ t.Fatalf("account lifecycle sweeps after rpc_result delivery = %d, want 0", accountSvc.sweepCalls)
+ }
}
func TestAccountDeleteRPCMapsDelayedTwoFAWait(t *testing.T) {
@@ -73,18 +82,6 @@ func TestDeleteAccountAllowedWithoutFullAuthorization(t *testing.T) {
}
}
-func TestAccountDeletionNotificationCompletesForOfflineTarget(t *testing.T) {
- sessions := &offlineDeletionSessions{}
- svc := &deletionWorkerService{}
- r := New(Config{}, Deps{Sessions: sessions}, zaptest.NewLogger(t), clock.System)
- r.dispatchAccountDeletionNotification(context.Background(), svc, domain.AccountDeletionNotification{
- ID: 9, TargetUserID: 42, DeletedUserID: 77, Attempts: 1,
- })
- if len(svc.completed) != 1 || svc.completed[0] != 9 {
- t.Fatalf("completed notifications = %v, want [9]", svc.completed)
- }
-}
-
func TestAccountLifecyclePartialSweepFinishesCommittedDeletion(t *testing.T) {
revoked := [8]byte{3}
svc := &rpcDeletionAccountService{
@@ -104,12 +101,31 @@ func TestAccountLifecyclePartialSweepFinishesCommittedDeletion(t *testing.T) {
}
}
+func TestModerationAccountDeletionClosesRevokedSessions(t *testing.T) {
+ revoked := [8]byte{4}
+ sessions := &deletionCaptureSessions{}
+ facts := &recordingUserProjectionFactInvalidator{}
+ r := New(Config{}, Deps{Sessions: sessions, UserProjectionFacts: facts}, zaptest.NewLogger(t), clock.System)
+ r.NotifyModerationAccountDeletion(context.Background(), domain.AccountDeletionResult{
+ Changed: true,
+ User: domain.User{ID: 42, Deleted: true},
+ RevokedAuthorizations: []domain.Authorization{{AuthKeyID: revoked, UserID: 42}},
+ })
+ if !sessions.wasClosed(revoked) {
+ t.Fatal("moderation-deleted authorization session was not closed")
+ }
+ if len(facts.freezes) != 1 || facts.freezes[0] != 42 || len(facts.phones) != 1 || facts.phones[0] != 42 {
+ t.Fatalf("moderation-deleted user fact invalidations freezes=%v phones=%v, want [42]/[42]", facts.freezes, facts.phones)
+ }
+}
+
type rpcDeletionAccountService struct {
*appaccount.Service
outcome domain.AccountDeleteOutcome
err error
sweepResults []domain.AccountDeletionResult
sweepErr error
+ sweepCalls int
}
func (s *rpcDeletionAccountService) DeleteAccount(context.Context, int64, [8]byte, string, *domain.PasswordCheck, time.Time) (domain.AccountDeleteOutcome, error) {
@@ -133,6 +149,7 @@ func (*rpcDeletionAccountService) CancelConfirmPhoneCode(context.Context, int64,
}
func (s *rpcDeletionAccountService) SweepDueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionResult, error) {
+ s.sweepCalls++
return s.sweepResults, s.sweepErr
}
@@ -141,27 +158,6 @@ type deletionCaptureSessions struct {
closed [][8]byte
}
-type offlineDeletionSessions struct{ captureSessions }
-
-func (*offlineDeletionSessions) PushToUserExceptAuthKeySession(context.Context, int64, [8]byte, int64, proto.MessageType, tg.UpdatesClass) (int, error) {
- return 0, nil
-}
-
-type deletionWorkerService struct{ completed []int64 }
-
-func (*deletionWorkerService) SweepDueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionResult, error) {
- return nil, nil
-}
-
-func (*deletionWorkerService) ClaimAccountDeletionNotifications(context.Context, time.Time, int, time.Duration) ([]domain.AccountDeletionNotification, error) {
- return nil, nil
-}
-
-func (s *deletionWorkerService) CompleteAccountDeletionNotification(_ context.Context, id int64, _ time.Time) error {
- s.completed = append(s.completed, id)
- return nil
-}
-
func (s *deletionCaptureSessions) CloseSessionsForBusinessAuthKey(id [8]byte) int {
s.closed = append(s.closed, id)
return 1
diff --git a/internal/rpc/account_freeze_worker.go b/internal/rpc/account_freeze_worker.go
index 80ea01cf..03b89b04 100644
--- a/internal/rpc/account_freeze_worker.go
+++ b/internal/rpc/account_freeze_worker.go
@@ -10,11 +10,6 @@ import (
"telesrv/internal/domain"
)
-type accountFreezeNotificationService interface {
- ClaimAccountFreezeNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountFreezeNotification, error)
- CompleteAccountFreezeNotification(ctx context.Context, id, version int64, now time.Time) error
-}
-
// RunAccountFreezeNotifications drains the crash-safe, coalesced non-pts
// updateUser queue. One attempt is enough for online delivery; offline clients
// recover the current state from viewer-scoped user hydration.
@@ -39,8 +34,8 @@ func (r *Router) RunAccountFreezeNotifications(ctx context.Context, interval tim
}
func (r *Router) drainAccountFreezeNotifications(ctx context.Context, batch int) {
- svc, ok := r.deps.AccountFreeze.(accountFreezeNotificationService)
- if !ok || r.deps.Users == nil {
+ svc := r.deps.AccountFreezeNotifications
+ if svc == nil || r.deps.Users == nil {
return
}
for {
@@ -61,7 +56,7 @@ func (r *Router) drainAccountFreezeNotifications(ctx context.Context, batch int)
}
}
-func (r *Router) dispatchAccountFreezeNotification(ctx context.Context, svc accountFreezeNotificationService, notification domain.AccountFreezeNotification) {
+func (r *Router) dispatchAccountFreezeNotification(ctx context.Context, svc AccountFreezeNotificationService, notification domain.AccountFreezeNotification) {
peer := domain.Peer{Type: domain.PeerTypeUser, ID: notification.FrozenUserID}
if contacts, ok := r.deps.Contacts.(interface{ InvalidateViewers(...int64) }); ok {
contacts.InvalidateViewers(notification.TargetUserID)
diff --git a/internal/rpc/account_freeze_worker_test.go b/internal/rpc/account_freeze_worker_test.go
index c9f39f42..eeb7dd38 100644
--- a/internal/rpc/account_freeze_worker_test.go
+++ b/internal/rpc/account_freeze_worker_test.go
@@ -26,9 +26,9 @@ func TestAccountFreezeNotificationPushesCurrentViewerProjection(t *testing.T) {
RestrictionReasons: domain.AccountFrozenRestrictionReasons(),
}}
r := New(Config{}, Deps{
- AccountFreeze: freezeSvc,
- Users: users,
- Sessions: sessions,
+ AccountFreezeNotifications: freezeSvc,
+ Users: users,
+ Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
r.dispatchAccountFreezeNotification(context.Background(), freezeSvc, domain.AccountFreezeNotification{
@@ -67,9 +67,9 @@ func TestAccountFreezeNotificationLoadsCurrentStateAndRetriesLoadFailure(t *test
freezeSvc := &freezeWorkerService{}
users := &freezeWorkerUsers{err: errors.New("projection unavailable")}
r := New(Config{}, Deps{
- AccountFreeze: freezeSvc,
- Users: users,
- Sessions: sessions,
+ AccountFreezeNotifications: freezeSvc,
+ Users: users,
+ Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
notification := domain.AccountFreezeNotification{
ID: 8, TargetUserID: viewerID, FrozenUserID: frozenID, Version: 5, Frozen: true,
diff --git a/internal/rpc/account_lifecycle_worker.go b/internal/rpc/account_lifecycle_worker.go
index a19de648..cd290331 100644
--- a/internal/rpc/account_lifecycle_worker.go
+++ b/internal/rpc/account_lifecycle_worker.go
@@ -4,7 +4,6 @@ import (
"context"
"time"
- "github.com/iamxvbaba/td/tg"
"go.uber.org/zap"
"telesrv/internal/domain"
@@ -12,16 +11,11 @@ import (
type accountLifecycleWorkerService interface {
SweepDueAccountDeletions(ctx context.Context, now time.Time, limit int) ([]domain.AccountDeletionResult, error)
- ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error)
- CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error
}
// RunAccountLifecycle executes all due account deletion sources through one
-// tombstone path and drains the durable non-pts updateUser queue. The queue is
-// a crash-safe, bounded online nudge: offline users are completed after the
-// first attempt because getDialogs/getHistory hydration independently returns
-// the authoritative tombstone. This avoids an immortal retry queue for a
-// non-pts update that cannot participate in getDifference.
+// tombstone path. Deleted-user projections converge from authoritative reads;
+// updateUser is non-PTS and therefore is not queued as a correctness signal.
func (r *Router) RunAccountLifecycle(ctx context.Context, interval time.Duration, batch int) {
if interval <= 0 {
interval = time.Minute
@@ -51,49 +45,24 @@ func (r *Router) runAccountLifecycleOnce(ctx context.Context, batch int) {
sweepCtx, cancel := context.WithTimeout(ctx, 45*time.Second)
results, err := svc.SweepDueAccountDeletions(sweepCtx, now, batch)
cancel()
+ changed := false
for _, result := range results {
if !result.Changed {
continue
}
- r.invalidateRPCProjectionForUser(result.User.ID)
+ changed = true
r.finishDeletedAccountAuthorizations(context.Background(), result.User.ID, result.RevokedAuthorizations)
}
+ if changed {
+ // One flush covers the entire due batch. Per-user predicate invalidation
+ // would scan the same large projection maps four times for every account.
+ r.flushRPCProjectionCache()
+ }
if err != nil {
// SweepDueAccountDeletions may return already-committed results before a
- // later candidate fails. Always finish those sessions/caches and drain
- // their durable notifications; the failed and remaining candidates are
- // retried from their authoritative due rows on the next tick.
+ // later candidate fails. Always finish those sessions/caches; the failed
+ // and remaining candidates are retried from their authoritative due rows
+ // on the next tick.
r.log.Warn("account lifecycle deletion sweep partially failed", zap.Int("completed", len(results)), zap.Error(err))
}
- for {
- claimCtx, claimCancel := context.WithTimeout(ctx, 30*time.Second)
- notifications, err := svc.ClaimAccountDeletionNotifications(claimCtx, now, batch, 2*time.Minute)
- claimCancel()
- if err != nil {
- r.log.Warn("claim account deletion notifications failed", zap.Error(err))
- return
- }
- for _, notification := range notifications {
- r.dispatchAccountDeletionNotification(ctx, svc, notification)
- }
- if len(notifications) < batch {
- return
- }
- }
-}
-
-func (r *Router) dispatchAccountDeletionNotification(ctx context.Context, svc accountLifecycleWorkerService, notification domain.AccountDeletionNotification) {
- now := r.clock.Now().UTC()
- updates := &tg.Updates{
- Updates: []tg.UpdateClass{&tg.UpdateUser{UserID: notification.DeletedUserID}},
- Users: []tg.UserClass{tgUser(domain.User{
- ID: notification.DeletedUserID,
- Deleted: true,
- })},
- Date: int(now.Unix()),
- }
- r.pushUserUpdates(ctx, notification.TargetUserID, updates)
- if err := svc.CompleteAccountDeletionNotification(ctx, notification.ID, now); err != nil {
- r.log.Warn("complete account deletion notification failed", zap.Int64("notification_id", notification.ID), zap.Error(err))
- }
}
diff --git a/internal/rpc/account_notify.go b/internal/rpc/account_notify.go
index b6c7abda..3555b6a7 100644
--- a/internal/rpc/account_notify.go
+++ b/internal/rpc/account_notify.go
@@ -94,7 +94,22 @@ func (r *Router) onAccountUpdateNotifySettings(ctx context.Context, req *tg.Acco
}
r.notifySettings.Delete(userID)
}
- // 推 updateNotifySettings 给本人其它在线设备(多设备静音同步)。
+ // 推 updateNotifySettings 给本人其它在线设备(多设备静音同步)。NotifyScopePeer(静音/
+ // 取消静音某一具体会话,最常见场景)走 durable outbox:对方另一台设备当时不在线也能
+ // 在重连后经 getDifference 追上最新静音状态,而不是停留在旧设置继续弹通知直到重启
+ // app 才刷新。其余全局作用域(NotifyUsers/Chats/Broadcasts)仍走旧的 best-effort 推送。
+ if scope.Kind == domain.NotifyScopePeer && r.deps.Updates != nil {
+ authKeyID, _ := AuthKeyIDFrom(ctx)
+ sessionID, _ := SessionIDFrom(ctx)
+ event, _, err := r.deps.Updates.RecordNotifySettings(ctx, authKeyID, userID, scope.Peer, scope.TopicID, settings, rawAuthKeyIDForOrigin(ctx), sessionID)
+ if err != nil {
+ return false, internalErr()
+ }
+ if sessionID != 0 {
+ r.bookkeepAuxPtsForCurrentSession(ctx, event)
+ }
+ return true, nil
+ }
r.pushUserUpdates(ctx, userID, &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateNotifySettings{
Peer: tgNotifyPeer(scope),
diff --git a/internal/rpc/account_phone.go b/internal/rpc/account_phone.go
index 5597fd7e..a75d7f51 100644
--- a/internal/rpc/account_phone.go
+++ b/internal/rpc/account_phone.go
@@ -3,21 +3,11 @@ package rpc
import (
"context"
- "go.uber.org/zap"
-
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
-type phoneChangeEventConfirmer interface {
- ConfirmEvent(ctx context.Context, authKeyID [8]byte, userID int64, event domain.UpdateEvent) error
-}
-
-type phoneChangeReliableDispatchReporter interface {
- PhoneChangeUsesReliableDispatch() bool
-}
-
func (r *Router) onAccountSendChangePhoneCode(ctx context.Context, req *tg.AccountSendChangePhoneCodeRequest) (tg.AuthSentCodeClass, error) {
userID, found, err := r.currentUserID(ctx)
if err != nil {
@@ -73,22 +63,11 @@ func (r *Router) onAccountChangePhone(ctx context.Context, req *tg.AccountChange
return nil, internalErr()
}
r.invalidateRPCProjectionForUser(result.User.ID)
- if result.Event.Pts > 0 {
- if confirmer, ok := r.deps.Updates.(phoneChangeEventConfirmer); ok {
- if err := confirmer.ConfirmEvent(ctx, authKeyID, userID, result.Event); err != nil {
- // user/event/outbox 已原子提交,不能把已成功改号伪装成失败;当前
- // session 仍会收到 pts 簿记,设备水位存储可由后续 getDifference 自愈。
- r.log.Warn("confirm phone change event", zap.Int64("user_id", userID), zap.Int("pts", result.Event.Pts), zap.Error(err))
- }
- }
- reliable := false
- if reporter, ok := r.deps.Account.(phoneChangeReliableDispatchReporter); ok {
- reliable = reporter.PhoneChangeUsesReliableDispatch()
- }
- if !reliable {
- r.pushUserUpdates(ctx, userID, tgUpdateForOutboxEvent(result.Event))
- }
- r.bookkeepAuxPtsForCurrentSession(ctx, result.Event)
+ if result.Changed {
+ // account.changePhone returns the authoritative self User to the current
+ // session. Other online sessions receive a non-PTS updateUser; offline
+ // sessions converge on their next full-user/startup read.
+ r.pushPremiumStatusUpdate(ctx, result.User)
}
return r.tgSelfUserWithUsernames(ctx, result.User), nil
}
diff --git a/internal/rpc/account_phone_rpc_test.go b/internal/rpc/account_phone_rpc_test.go
index 3fb76dd5..9de721d9 100644
--- a/internal/rpc/account_phone_rpc_test.go
+++ b/internal/rpc/account_phone_rpc_test.go
@@ -15,7 +15,7 @@ import (
"telesrv/internal/store/memory"
)
-func TestAccountChangePhoneRPCReturnsSelfPushesOthersAndReplaysDifference(t *testing.T) {
+func TestAccountChangePhoneRPCReturnsSelfAndPushesNonPTSUpdate(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
auths := memory.NewAuthorizationStore()
@@ -39,7 +39,7 @@ func TestAccountChangePhoneRPCReturnsSelfPushesOthersAndReplaysDifference(t *tes
{Username: "Alice", Editable: true, Active: true, SortOrder: 0},
{Username: "aliceCollect0728b", Active: true, SortOrder: 1, CollectibleID: 2},
}
- sessions := &captureSessions{}
+ sessions := &captureSessions{onlineUserIDs: []int64{user.ID}}
r := New(Config{}, Deps{Account: accountSvc, Sessions: sessions, Usernames: registry}, zaptest.NewLogger(t), clock.System)
reqCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, user.ID), authKeyID), 77)
@@ -70,22 +70,23 @@ func TestAccountChangePhoneRPCReturnsSelfPushesOthersAndReplaysDifference(t *tes
assertVectorOnlyUsernames(t, "account.changePhone", self, []string{"Alice", "aliceCollect0728b"})
otherPush, ok := sessions.lastUserPush().(*tg.Updates)
- if !ok || len(otherPush.Updates) != 2 {
+ if !ok || len(otherPush.Updates) != 1 {
t.Fatalf("other-session push = %T %+v", sessions.lastUserPush(), sessions.lastUserPush())
}
- phoneUpdate, ok := otherPush.Updates[0].(*tg.UpdateUserPhone)
- if !ok || phoneUpdate.UserID != user.ID || phoneUpdate.Phone != "15550013002" {
- t.Fatalf("phone update = %T %+v", otherPush.Updates[0], otherPush.Updates[0])
+ userUpdate, ok := otherPush.Updates[0].(*tg.UpdateUser)
+ if !ok || userUpdate.UserID != user.ID {
+ t.Fatalf("user update = %T %+v", otherPush.Updates[0], otherPush.Updates[0])
}
- if _, ok := otherPush.Updates[1].(*tg.UpdateDeleteMessages); !ok {
- t.Fatalf("pts bookkeeping = %T", otherPush.Updates[1])
+ if len(otherPush.Users) != 1 {
+ t.Fatalf("push users = %+v", otherPush.Users)
}
- currentPush, ok := sessions.snapshot().message.(*tg.Updates)
- if !ok || len(currentPush.Updates) != 1 {
- t.Fatalf("current-session bookkeeping = %T %+v", sessions.snapshot().message, sessions.snapshot().message)
+ pushedSelf, ok := otherPush.Users[0].(*tg.User)
+ if !ok || pushedSelf.Phone != "15550013002" {
+ t.Fatalf("pushed self = %T %+v", otherPush.Users[0], otherPush.Users[0])
}
- if _, ok := currentPush.Updates[0].(*tg.UpdateDeleteMessages); !ok {
- t.Fatalf("current bookkeeping update = %T", currentPush.Updates[0])
+ snapshot := sessions.snapshot()
+ if sessions.rawAuthKeyID != authKeyID || snapshot.sessionID != 77 {
+ t.Fatalf("push exclusion = %x/%d", sessions.rawAuthKeyID, snapshot.sessionID)
}
updateSvc := appupdates.NewService(memory.NewUpdateStateStore(), events)
@@ -93,13 +94,8 @@ func TestAccountChangePhoneRPCReturnsSelfPushesOthersAndReplaysDifference(t *tes
if err != nil {
t.Fatalf("get difference: %v", err)
}
- tgDiff, ok := tgUpdatesDifference(user.ID, diff).(*tg.UpdatesDifference)
- if !ok || len(tgDiff.OtherUpdates) != 1 {
- t.Fatalf("difference = %T %+v", tgUpdatesDifference(user.ID, diff), tgUpdatesDifference(user.ID, diff))
- }
- replayed, ok := tgDiff.OtherUpdates[0].(*tg.UpdateUserPhone)
- if !ok || replayed.UserID != user.ID || replayed.Phone != "15550013002" {
- t.Fatalf("replayed update = %T %+v", tgDiff.OtherUpdates[0], tgDiff.OtherUpdates[0])
+ if diff.State.Pts != 0 || len(diff.Events) != 0 {
+ t.Fatalf("difference unexpectedly changed = %+v", diff)
}
}
diff --git a/internal/rpc/account_settings_rpc_test.go b/internal/rpc/account_settings_rpc_test.go
index 67134377..6f09e80c 100644
--- a/internal/rpc/account_settings_rpc_test.go
+++ b/internal/rpc/account_settings_rpc_test.go
@@ -56,6 +56,10 @@ func TestAccountSettingsRoundTrip(t *testing.T) {
NewNoncontactPeersRequirePremium: true,
}
in.SetNoncontactPeersPaidStars(50)
+ in.SetDisallowedGifts(tg.DisallowedGiftsSettings{
+ DisallowLimitedStargifts: true,
+ DisallowPremiumGifts: true,
+ })
saved, err := r.onAccountSetGlobalPrivacySettings(ctx, in)
if err != nil {
t.Fatalf("set global privacy: %v", err)
@@ -135,4 +139,10 @@ func assertGlobalPrivacy(t *testing.T, got *tg.GlobalPrivacySettings, want tg.Gl
if gotStars != wantStars {
t.Fatalf("noncontact paid stars = %d, want %d", gotStars, wantStars)
}
+ wantGifts, wantGiftsOK := want.GetDisallowedGifts()
+ gotGifts, gotGiftsOK := got.GetDisallowedGifts()
+ if gotGiftsOK != wantGiftsOK || gotGifts != wantGifts {
+ t.Fatalf("disallowed gifts = %+v ok=%v, want %+v ok=%v",
+ gotGifts, gotGiftsOK, wantGifts, wantGiftsOK)
+ }
}
diff --git a/internal/rpc/admin_hooks.go b/internal/rpc/admin_hooks.go
index d0e18ce7..b63c79ce 100644
--- a/internal/rpc/admin_hooks.go
+++ b/internal/rpc/admin_hooks.go
@@ -39,6 +39,9 @@ func (r *Router) NotifyAccountFreezeChanged(_ context.Context, freeze domain.Acc
if r == nil || freeze.UserID == 0 {
return nil
}
+ if r.deps.UserProjectionFacts != nil {
+ r.deps.UserProjectionFacts.InvalidateAccountFreezeFact(freeze.UserID)
+ }
r.invalidateRPCProjectionForUser(freeze.UserID)
if r.accountFreezeWake != nil {
select {
diff --git a/internal/rpc/auth.go b/internal/rpc/auth.go
index a19b9f59..20251f0f 100644
--- a/internal/rpc/auth.go
+++ b/internal/rpc/auth.go
@@ -6,6 +6,7 @@ import (
"crypto/rand"
"crypto/sha256"
"encoding/binary"
+ "encoding/hex"
"errors"
"fmt"
"strings"
@@ -216,29 +217,43 @@ func (r *Router) onAuthBindTempAuthKey(ctx context.Context, req *tg.AuthBindTemp
id, _ = AuthKeyIDFrom(ctx)
}
sessionID, _ := SessionIDFrom(ctx)
- if err := r.deps.Auth.BindTempAuthKey(ctx, sessionID, domain.TempAuthKeyBinding{
+ boundState, err := r.deps.Auth.BindTempAuthKey(ctx, sessionID, domain.TempAuthKeyBinding{
TempAuthKeyID: id,
PermAuthKeyID: req.PermAuthKeyID,
Nonce: req.Nonce,
ExpiresAt: req.ExpiresAt,
EncryptedMessage: append([]byte(nil), req.EncryptedMessage...),
- }); err != nil {
+ })
+ if err != nil {
return false, bindTempAuthKeyErr(err)
}
permID := authKeyIDFromInt64(req.PermAuthKeyID)
- // temp key (re)bind 后立即作废其 temp→perm 解析缓存,确保下一帧按新绑定重新解析,
- // 不被 TTL 内的旧 perm 缓存命中(防跨账号串号)。
+ // The committed bind transaction is authoritative for this immutable
+ // temp→permanent identity. Replace any prior local entry, then publish the
+ // exact positive mapping so Layer publication and the first business RPC do
+ // not re-read the same row. A competing different-permanent bind has already
+ // failed in the store before reaching this point.
if id != ([8]byte{}) {
r.tempKeyResolveCache.Delete(id)
+ r.cacheResolvedAuthKey(id, permID)
}
- // Save atomically merged raw/permanent Layer observations. Both identities
- // must now re-read that durable permanent primary; pre-bind process caches
- // are not ordering evidence and cannot overwrite the transaction's winner.
+ // Save atomically merged raw/permanent Layer observations and returned the
+ // exact committed tuple. Project that generation directly; a post-commit
+ // read could observe a later selector and wrongly attribute it to this bind.
r.invalidateAuthUserCache(id)
r.invalidateAuthUserCache(permID)
unlockLayerCommit := r.lockAuthLayerCommit(id, permID)
defer unlockLayerCommit()
- r.invalidateBoundAuthKeyLayerResolution(id, permID)
+ layer, blocked, err := r.cacheBoundAuthKeyLayerResolution(id, permID, boundState)
+ if err != nil {
+ if r.log != nil {
+ r.log.Error("project committed temp auth key bind Layer failed",
+ zap.String("raw_auth_key_id", fmt.Sprintf("%x", id[:])),
+ zap.String("perm_auth_key_id", fmt.Sprintf("%x", permID[:])),
+ zap.Error(err))
+ }
+ return false, internalErr()
+ }
if r.deps.Sessions != nil {
if all, ok := r.deps.Sessions.(RawAuthKeySessionBinder); ok {
all.BindAuthKeyForRawAuthKey(id, permID)
@@ -246,72 +261,86 @@ func (r *Router) onAuthBindTempAuthKey(ctx context.Context, req *tg.AuthBindTemp
r.deps.Sessions.BindAuthKeyForSession(id, sessionID, permID)
}
}
- layer, _, err := r.resolveAuthKeyLayerDefault(ctx, permID)
- if err != nil {
- if clearer, ok := r.deps.Sessions.(AuthKeyInheritedLayerClearer); ok {
- clearer.ClearInheritedLayerForRawAuthKey(id)
- }
- if r.log != nil {
- r.log.Warn("reload merged permanent layer after temp auth key bind failed",
- zap.String("raw_auth_key_id", fmt.Sprintf("%x", id[:])),
- zap.String("perm_auth_key_id", fmt.Sprintf("%x", permID[:])),
- zap.Error(err))
- }
- return false, internalErr()
- }
- r.cacheBoundAuthKeyLayerResolution(id, permID)
if isSupportedLayer(layer) {
if refresher, ok := r.deps.Sessions.(AuthKeyLayerRefresher); ok {
refresher.RefreshInheritedLayerForRawAuthKey(id, layer)
} else if binder, ok := r.deps.Sessions.(AuthKeyLayerBinder); ok {
binder.SeedInheritedLayerForRawAuthKey(id, layer)
}
- } else if clearer, ok := r.deps.Sessions.(AuthKeyInheritedLayerClearer); ok {
- clearer.ClearInheritedLayerForRawAuthKey(id)
+ } else if blocked || layer == 0 {
+ if clearer, ok := r.deps.Sessions.(AuthKeyInheritedLayerClearer); ok {
+ clearer.ClearInheritedLayerForRawAuthKey(id)
+ }
}
return true, nil
}
-func (r *Router) invalidateBoundAuthKeyLayerResolution(authKeyIDs ...[8]byte) {
+func (r *Router) cacheBoundAuthKeyLayerResolution(
+ rawAuthKeyID, permAuthKeyID [8]byte,
+ result domain.TempAuthKeyBindingResult,
+) (layer int, blocked bool, err error) {
+ if result.Layer < 0 || result.LayerObservationID < 0 ||
+ (result.LayerObservationID > 0 && result.Layer == 0) {
+ return 0, false, fmt.Errorf(
+ "invalid bound auth-key Layer result layer=%d observation=%d",
+ result.Layer, result.LayerObservationID,
+ )
+ }
+ outcome := clientSessionInfo{layerObservationID: result.LayerObservationID}
+ if isSupportedLayer(result.Layer) {
+ outcome.layer = result.Layer
+ } else if result.Layer != 0 {
+ outcome.layerBlocked = true
+ outcome.layerBlockedByAuthKey = true
+ }
+
r.clientInfoMu.Lock()
defer r.clientInfoMu.Unlock()
- for _, authKeyID := range authKeyIDs {
- if info, ok := r.authInfo[authKeyID]; ok {
- info.layer = 0
- info.layerObservationID = 0
- info.layerAdmissionSeq = 0
- info.authKeyInfoChecked = false
- info.authorizationChecked = false
- info.layerBlocked = false
- info.layerBlockedByAuthKey = false
- r.authInfo[authKeyID] = info
+ for _, authKeyID := range [][8]byte{rawAuthKeyID, permAuthKeyID} {
+ current := r.authInfo[authKeyID]
+ switch {
+ case current.layerObservationID > outcome.layerObservationID:
+ outcome.layer = current.layer
+ outcome.layerObservationID = current.layerObservationID
+ outcome.layerBlocked = current.layerBlocked
+ outcome.layerBlockedByAuthKey = current.layerBlockedByAuthKey
+ case current.layerObservationID == outcome.layerObservationID && outcome.layerObservationID > 0:
+ currentBlocked := current.layerBlocked || current.layerBlockedByAuthKey
+ outcomeBlocked := outcome.layerBlocked || outcome.layerBlockedByAuthKey
+ if current.layer != 0 && outcome.layer != 0 && current.layer != outcome.layer {
+ return 0, false, fmt.Errorf(
+ "conflicting cached bound auth-key Layer observation %d: %d != %d",
+ outcome.layerObservationID, current.layer, outcome.layer,
+ )
+ }
+ if currentBlocked != outcomeBlocked &&
+ (current.layer != 0 || outcome.layer != 0 || currentBlocked || outcomeBlocked) {
+ return 0, false, fmt.Errorf(
+ "conflicting cached bound auth-key blocked observation %d",
+ outcome.layerObservationID,
+ )
+ }
+ if outcome.layer == 0 {
+ outcome.layer = current.layer
+ }
}
}
-}
-
-func (r *Router) cacheBoundAuthKeyLayerResolution(rawAuthKeyID, permAuthKeyID [8]byte) {
- r.clientInfoMu.Lock()
- defer r.clientInfoMu.Unlock()
if r.authInfo == nil {
r.authInfo = make(map[[8]byte]clientSessionInfo)
}
- if _, exists := r.authInfo[rawAuthKeyID]; !exists {
- evictMapEntryIfFullLocked(r.authInfo, maxAuthInfoEntries)
+ for _, authKeyID := range [][8]byte{rawAuthKeyID, permAuthKeyID} {
+ if _, exists := r.authInfo[authKeyID]; !exists {
+ evictMapEntryIfFullLocked(r.authInfo, maxAuthInfoEntries)
+ }
+ info := r.authInfo[authKeyID]
+ info.layer = outcome.layer
+ info.layerObservationID = outcome.layerObservationID
+ info.layerAdmissionSeq = 0
+ info.layerBlocked = outcome.layerBlocked
+ info.layerBlockedByAuthKey = outcome.layerBlockedByAuthKey
+ r.authInfo[authKeyID] = info
}
- canonical := r.authInfo[permAuthKeyID]
- info := r.authInfo[rawAuthKeyID]
- // The bind transaction made the permanent row authoritative for both
- // identities. Copy its complete resolution tuple: a Layer without the same
- // observation token (or a stale blocked bit) would let later cache merging
- // manufacture an ordering state that never existed durably.
- info.layer = canonical.layer
- info.layerObservationID = canonical.layerObservationID
- info.layerAdmissionSeq = canonical.layerAdmissionSeq
- info.layerBlocked = canonical.layerBlocked
- info.layerBlockedByAuthKey = canonical.layerBlockedByAuthKey
- info.authKeyInfoChecked = canonical.authKeyInfoChecked
- info.authorizationChecked = canonical.authorizationChecked
- r.authInfo[rawAuthKeyID] = info
+ return outcome.layer, outcome.layerBlocked || outcome.layerBlockedByAuthKey, nil
}
// onAuthExportLoginToken 给 QR 登录请求方返回短期 token;扫码端接受后,同一目标
@@ -473,9 +502,25 @@ func (r *Router) onAuthSendCode(ctx context.Context, req *tg.AuthSendCodeRequest
errors.Is(err, auth.ErrSystemUserLoginForbidden) {
return nil, phoneNumberInvalidErr()
}
+ // The public MTProto error intentionally stays opaque, but operators need
+ // the wrapped store/provider cause to repair an update-related failure.
+ // Hash the normalized phone so neither the number nor the OTP reaches logs.
+ phoneDigest := sha256.Sum256([]byte(domain.NormalizePhone(req.PhoneNumber)))
+ fields := append(r.contextLogFields(ctx),
+ zap.Int("api_id", req.APIID),
+ zap.String("phone_digest", hex.EncodeToString(phoneDigest[:8])),
+ zap.Error(err),
+ )
+ r.log.Error("auth.sendCode failed", fields...)
return nil, internalErr()
}
- return r.tgSentCodeForHash(ctx, hash)
+ sent, err := r.tgSentCodeForHash(ctx, hash)
+ if err != nil {
+ fields := append(r.contextLogFields(ctx), zap.Error(err))
+ r.log.Error("auth.sendCode delivery lookup failed", fields...)
+ return nil, err
+ }
+ return sent, nil
}
func (r *Router) onAuthReportMissingCode(ctx context.Context, req *tg.AuthReportMissingCodeRequest) (bool, error) {
@@ -828,7 +873,7 @@ func (r *Router) completePendingPasswordSignIn(ctx context.Context, authKeyID [8
if r.deps.Auth == nil {
return nil
}
- if err := r.deps.Auth.CompletePasswordSignIn(ctx, authKeyID); err != nil {
+ if err := r.deps.Auth.CompletePasswordSignIn(ctx, authKeyID, userID); err != nil {
return err
}
r.invalidateAuthUserCache(authKeyID)
diff --git a/internal/rpc/auth_code_rate_limit_test.go b/internal/rpc/auth_code_rate_limit_test.go
index f4ed92a6..55b4ee8e 100644
--- a/internal/rpc/auth_code_rate_limit_test.go
+++ b/internal/rpc/auth_code_rate_limit_test.go
@@ -101,6 +101,26 @@ func TestAuthSendCodeRateLimitUsesOpaquePhoneAndRawAuthKeyKeys(t *testing.T) {
}
}
+func TestAuthCodeRateLimitSharesBudgetAcrossNationalTrunkVariants(t *testing.T) {
+ limiter := &captureRateLimiter{}
+ r := New(Config{
+ AuthCodePhoneRateLimit: 5,
+ AuthCodeRateWindow: time.Minute,
+ }, Deps{Limiter: limiter}, zaptest.NewLogger(t), clock.System)
+
+ for _, phone := range []string{"+98 0998 167 9461", "989981679461"} {
+ if err := r.checkAuthCodeRateLimit(context.Background(), phone); err != nil {
+ t.Fatalf("checkAuthCodeRateLimit(%q): %v", phone, err)
+ }
+ }
+ if len(limiter.calls) != 2 {
+ t.Fatalf("limiter calls = %d, want 2", len(limiter.calls))
+ }
+ if limiter.calls[0].key != limiter.calls[1].key {
+ t.Fatalf("equivalent phone variants used different limiter keys: %q != %q", limiter.calls[0].key, limiter.calls[1].key)
+ }
+}
+
func TestAuthSendCodePhoneRateLimitPrecedesBusinessLookupAndWrite(t *testing.T) {
limiter := &captureRateLimiter{block: true, retryAfter: 17}
authService := &authCodeRateTestService{captureAuthService: &captureAuthService{}}
diff --git a/internal/rpc/auth_password_pending_test.go b/internal/rpc/auth_password_pending_test.go
index 9ac599e6..d7212b3e 100644
--- a/internal/rpc/auth_password_pending_test.go
+++ b/internal/rpc/auth_password_pending_test.go
@@ -96,8 +96,9 @@ func TestAuthRecoverPasswordCompletesPendingSignIn(t *testing.T) {
if _, err := router.onAuthRecoverPassword(ctx, &tg.AuthRecoverPasswordRequest{Code: sender.code}); err != nil {
t.Fatalf("auth.recoverPassword: %v", err)
}
- if auth.completePasswordCount != 1 || auth.completedPasswordKey != authKeyID {
- t.Fatalf("CompletePasswordSignIn count=%d key=%x, want one call for %x", auth.completePasswordCount, auth.completedPasswordKey, authKeyID)
+ if auth.completePasswordCount != 1 || auth.completedPasswordKey != authKeyID || auth.completedPasswordUser != userID {
+ t.Fatalf("CompletePasswordSignIn count=%d key=%x user=%d, want one call for %x/%d",
+ auth.completePasswordCount, auth.completedPasswordKey, auth.completedPasswordUser, authKeyID, userID)
}
if snap := sessions.snapshot(); snap.userID != userID || !snap.userResolved {
t.Fatalf("session user = %d resolved=%v, want %d resolved", snap.userID, snap.userResolved, userID)
diff --git a/internal/rpc/bootstrap_updates.go b/internal/rpc/bootstrap_updates.go
index 052dc02a..c38dd61d 100644
--- a/internal/rpc/bootstrap_updates.go
+++ b/internal/rpc/bootstrap_updates.go
@@ -106,26 +106,27 @@ func (r *Router) enqueueLoginMessageBootstrap(ctx context.Context, msg domain.Me
// publishBootstrapAfterBaseline runs only from the ordered post-response plan.
// It must never be called while the baseline rpc_result is merely encoded or
// queued, otherwise the bootstrap update can overtake that baseline on wire.
-func (r *Router) publishBootstrapAfterBaseline(ctx context.Context, userID int64) {
+func (r *Router) publishBootstrapAfterBaseline(ctx context.Context, userID int64) bool {
if r.deps.BootstrapUpdates == nil || userID == 0 {
- return
+ return false
}
authKeyID, hasAuthKeyID := AuthKeyIDFrom(ctx)
sessionID, hasSessionID := SessionIDFrom(ctx)
if !hasAuthKeyID || !hasSessionID {
- return
+ return false
}
cbCtx, cancel := context.WithTimeout(ctx, updatesDeliveryPhaseTimeout)
defer cancel()
ready, err := r.deps.BootstrapUpdates.MarkReadyForSession(cbCtx, userID, authKeyID, sessionID)
if err != nil {
r.log.Warn("mark bootstrap updates ready", zap.Int64("user_id", userID), zap.Int64("session_id", sessionID), zap.Error(err))
- return
+ return false
}
if ready == 0 {
- return
+ return true
}
r.publishReadyBootstrapUpdates(cbCtx, ready, defaultBootstrapLease, r.log.Named("bootstrap"))
+ return true
}
func (r *Router) publishReadyBootstrapUpdates(ctx context.Context, batch int, leaseTimeout time.Duration, log *zap.Logger) int {
diff --git a/internal/rpc/bot_verification_projection.go b/internal/rpc/bot_verification_projection.go
index b7759480..9b40e5b3 100644
--- a/internal/rpc/bot_verification_projection.go
+++ b/internal/rpc/bot_verification_projection.go
@@ -2,6 +2,7 @@ package rpc
import (
"context"
+ "errors"
"strings"
"github.com/iamxvbaba/td/tg"
@@ -103,7 +104,7 @@ func (r *Router) applyBotVerificationIconsToPeerObjects(ctx context.Context, use
peers = append(peers, peer)
}
for _, item := range users {
- if u, ok := item.(*tg.User); ok && u != nil {
+ if u, ok := item.(*tg.User); ok && u != nil && !u.Deleted {
addPeer(domain.Peer{Type: domain.PeerTypeUser, ID: u.ID})
}
}
@@ -119,9 +120,13 @@ func (r *Router) applyBotVerificationIconsToPeerObjects(ctx context.Context, use
if len(byPeer) == 0 {
return
}
+ applyBotVerificationIconsFromMap(users, chats, byPeer)
+}
+
+func applyBotVerificationIconsFromMap(users []tg.UserClass, chats []tg.ChatClass, byPeer map[domain.Peer]domain.CustomVerification) {
for _, item := range users {
u, ok := item.(*tg.User)
- if !ok || u == nil {
+ if !ok || u == nil || u.Deleted {
continue
}
mark, ok := byPeer[domain.Peer{Type: domain.PeerTypeUser, ID: u.ID}]
@@ -152,18 +157,29 @@ func (r *Router) botVerificationMap(ctx context.Context, peers []domain.Peer) ma
if r.deps.BotVerifications == nil || len(peers) == 0 {
return nil
}
+ _, verifications := r.peerIdentityMaps(ctx, peers, false, true)
+ return verifications
+}
+
+func (r *Router) loadBotVerificationMap(ctx context.Context, peers []domain.Peer) (map[domain.Peer]domain.CustomVerification, error) {
if len(peers) == 1 {
mark, err := r.deps.BotVerifications.PeerVerification(ctx, peers[0])
- if err != nil || mark.IconDocumentID <= 0 {
- return nil
+ if err != nil {
+ if errors.Is(err, domain.ErrCustomVerificationNotFound) {
+ return map[domain.Peer]domain.CustomVerification{}, nil
+ }
+ return nil, err
}
- return map[domain.Peer]domain.CustomVerification{peers[0]: mark}
+ if mark.IconDocumentID <= 0 {
+ return map[domain.Peer]domain.CustomVerification{}, nil
+ }
+ return map[domain.Peer]domain.CustomVerification{peers[0]: mark}, nil
}
byPeer, err := r.deps.BotVerifications.PeerVerificationBatch(ctx, peers)
if err != nil {
- return nil
+ return nil, err
}
- return byPeer
+ return byPeer, nil
}
// peerBotVerificationIcon resolves just the icon for one peer, for the update
@@ -189,7 +205,7 @@ func applyBotVerificationIconToUsers(users []tg.UserClass, userID, icon int64) {
return
}
for _, item := range users {
- if u, ok := item.(*tg.User); ok && u != nil && u.ID == userID {
+ if u, ok := item.(*tg.User); ok && u != nil && !u.Deleted && u.ID == userID {
u.SetBotVerificationIcon(icon)
}
}
diff --git a/internal/rpc/bots_callback.go b/internal/rpc/bots_callback.go
index c390c3de..96be6dfb 100644
--- a/internal/rpc/bots_callback.go
+++ b/internal/rpc/bots_callback.go
@@ -59,6 +59,7 @@ func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.Mes
if err != nil {
return nil, err
}
+ callback.ClientSession = clientSessionMetadataFromContext(ctx)
botUserID := callback.BotUserID
// 内置(进程内)service bot 分支:@verifybot 这类 bot 没有 MTProto session、也没有
diff --git a/internal/rpc/bots_inline.go b/internal/rpc/bots_inline.go
index d6fad25c..a33f806c 100644
--- a/internal/rpc/bots_inline.go
+++ b/internal/rpc/bots_inline.go
@@ -128,6 +128,19 @@ func (r *Router) onMessagesGetInlineBotResults(ctx context.Context, req *tg.Mess
results := r.inlines.registerCachedContext(ctx, now, bot.ID, userID, peer, cached)
return r.tgBotInlineResults(ctx, userID, results), nil
}
+ if service := r.deps.ServiceBotInlineResults; service != nil && service.HandlesInlineBot(bot.ID) {
+ results, handled, err := service.OnInlineQuery(ctx, bot.ID, userID, req.Query, req.Offset)
+ if err != nil {
+ return nil, internalErr()
+ }
+ if handled {
+ if len(results.Results) > domain.MaxBotInlineResults {
+ return nil, internalErr()
+ }
+ registered := r.inlines.registerCachedContext(ctx, now, bot.ID, userID, peer, results)
+ return r.tgBotInlineResults(ctx, userID, registered), nil
+ }
+ }
queryID, pending := r.inlines.registerWithCacheKeyContext(ctx, now, bot.ID, userID, peer, cacheKey)
defer r.inlines.deregisterIfUnansweredContext(ctx, queryID)
diff --git a/internal/rpc/bots_inline_rpc_test.go b/internal/rpc/bots_inline_rpc_test.go
index 7e135c7d..cf12c83e 100644
--- a/internal/rpc/bots_inline_rpc_test.go
+++ b/internal/rpc/bots_inline_rpc_test.go
@@ -33,6 +33,43 @@ type inlineBotRPCTestFixture struct {
document domain.Document
}
+type builtinGifCatalogRPCSource struct{ doc domain.Document }
+
+func (s builtinGifCatalogRPCSource) ListGifCatalog(context.Context, bool) ([]domain.GifCatalogEntry, error) {
+ return []domain.GifCatalogEntry{{ID: 91, Title: "Wave", DocumentID: s.doc.ID, Enabled: true}}, nil
+}
+func (s builtinGifCatalogRPCSource) GetDocuments(context.Context, []int64) ([]domain.Document, error) {
+ return []domain.Document{s.doc}, nil
+}
+
+func TestBuiltinGifInlineQueryAcceptsGlobalEmptyPeerAndRegistersQuery(t *testing.T) {
+ ctx := context.Background()
+ users := memory.NewUserStore()
+ botStore := memory.NewBotStore(users)
+ owner, err := users.Create(ctx, domain.User{AccessHash: 7001, Phone: "15550007001", FirstName: "Owner"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ doc := domain.Document{ID: 901, AccessHash: 902, DCID: 2, MimeType: "video/mp4", Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrAnimated}, {Kind: domain.DocAttrVideo, W: 320, H: 240, Duration: 1}}}
+ bots := botsapp.NewService(users, botStore, memory.NewMessageStore(memory.NewDialogStore()), botsapp.WithGifCatalogSource(builtinGifCatalogRPCSource{doc: doc}))
+ router := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{Users: appusers.NewService(users), Bots: bots, ServiceBotInlineResults: bots}, zaptest.NewLogger(t), clock.System)
+ got, err := router.onMessagesGetInlineBotResults(WithUserID(ctx, owner.ID), &tg.MessagesGetInlineBotResultsRequest{Bot: inputUser(domain.GifBotUser()), Peer: &tg.InputPeerEmpty{}, Query: "wave"})
+ if err != nil {
+ t.Fatalf("global @gif query: %v", err)
+ }
+ if got.QueryID == 0 || len(got.Results) != 1 {
+ t.Fatalf("results = query_id %d len %d", got.QueryID, len(got.Results))
+ }
+ media, ok := got.Results[0].(*tg.BotInlineMediaResult)
+ if !ok {
+ t.Fatalf("result type = %T", got.Results[0])
+ }
+ wireDoc, ok := media.Document.(*tg.Document)
+ if !ok || wireDoc.ID != doc.ID {
+ t.Fatalf("document = %#v", media.Document)
+ }
+}
+
func newInlineBotRPCTestFixture(t *testing.T) inlineBotRPCTestFixture {
t.Helper()
ctx := context.Background()
@@ -2066,12 +2103,12 @@ func assertTGInlineReplyMarkup(t *testing.T, msg *tg.Message, wantText string, w
t.Fatalf("reply_markup rows = %+v, want one callback button", markup.Rows)
}
button := markup.Rows[0].Buttons[0]
- buttonType, ok := button.Type.(*tg.InlineButtonTypeCallback)
+ callback, ok := button.Type.(*tg.InlineButtonTypeCallback)
if !ok {
- t.Fatalf("reply_markup button = %T, want callback", button.Type)
+ t.Fatalf("reply_markup button type = %T, want callback", button.Type)
}
- 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)
+ if button.Text != wantText || !bytes.Equal(callback.Data, wantData) {
+ t.Fatalf("reply_markup button = %q/%v, want %q/%v", button.Text, callback.Data, wantText, wantData)
}
}
diff --git a/internal/rpc/bots_longtail.go b/internal/rpc/bots_longtail.go
index 982fc539..0bf4bc27 100644
--- a/internal/rpc/bots_longtail.go
+++ b/internal/rpc/bots_longtail.go
@@ -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.KeyboardButton, error) {
+func (r *Router) onBotsGetRequestedWebViewButton(ctx context.Context, req *tg.BotsGetRequestedWebViewButtonRequest) (*tg.KeyboardButton, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
- return tg.KeyboardButton{}, internalErr()
+ return nil, internalErr()
}
bot, err := r.resolveBotUserForViewer(ctx, userID, req.Bot)
if err != nil {
- return tg.KeyboardButton{}, err
+ return nil, err
}
button, found, err := r.deps.Bots.GetRequestedWebViewButton(ctx, bot.ID, userID, req.WebappReqID)
if err != nil {
- return tg.KeyboardButton{}, internalErr()
+ return nil, internalErr()
}
if !found {
- return tg.KeyboardButton{}, buttonDataInvalidErr()
+ return nil, buttonDataInvalidErr()
}
return tgKeyboardButtonRequestPeer(button), nil
}
@@ -799,15 +799,10 @@ func requestPeerTypeName(peerType tg.RequestPeerTypeClass) string {
}
}
-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,
- },
- }
+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,
+ }}
}
func tgRequestPeerType(kind string) tg.RequestPeerTypeClass {
diff --git a/internal/rpc/channel_fanout_dispatcher.go b/internal/rpc/channel_fanout_dispatcher.go
index a0a19a34..b392f6f7 100644
--- a/internal/rpc/channel_fanout_dispatcher.go
+++ b/internal/rpc/channel_fanout_dispatcher.go
@@ -335,10 +335,11 @@ func (s *channelFanoutShard) signalEligibleOverflow() {
}
// channelFanoutPrefetch 在 worker 解析出最终 recipient 集合后、逐 viewer build 之前调用一次,
-// 用于跨全部 recipient 一次性预热每 viewer 的用户投影(fan-out 模板化,O(owner))。可选:为 nil
-// 时 build 仍逐 viewer 解析(行为不变)。在 worker goroutine 内串行执行,与 build 共享同一
-// viewerPeerCache,无跨 goroutine 竞态。
-type channelFanoutPrefetch func(ctx context.Context, viewers []int64)
+// 用于跨全部 recipient 一次性预热每 viewer 的用户投影(fan-out 模板化,O(owner))。为 nil
+// 表示该 payload 不含需要预热的 user envelope。在 worker goroutine 内串行执行,与 build 共享同一
+// viewerPeerCache,无跨 goroutine 竞态。返回 false 表示批量预热失败;worker 必须 fail-closed,
+// 不能静默退回逐 viewer 投影。
+type channelFanoutPrefetch func(ctx context.Context, viewers []int64) bool
// channelFanoutDispatcher 把频道 payload fan-out 移出发送者 RPC,按 channelID 分片串行处理。
type channelFanoutDispatcher struct {
@@ -881,13 +882,22 @@ func (r *Router) runChannelFanoutJob(ctx context.Context, job channelFanoutJob)
recipients := r.channelFanoutRecipients(ctx, job.scope, job.channelID, job.recipients)
// 预热跨 viewer 用户投影(fan-out 模板化):在逐 viewer build 之前一次性算好每 recipient 的
// 投影并预热共享 cache,使 build 只命中缓存、不再 O(viewer) 逐个 ForViewer。覆盖 recipients +
- // 兜底 origin(无在线 recipient 时 build 会回退给 origin)。失败/未实现时静默退化为逐 viewer。
+ // 兜底 origin(无在线 recipient 时 build 会回退给 origin)。失败时禁止构造真实 payload,
+ // 改发 viewer-independent too-long nudge,让客户端从 durable difference 恢复。
if job.prefetch != nil {
viewers := recipients
if job.originUserID != 0 {
viewers = append(append(make([]int64, 0, len(recipients)+1), recipients...), job.originUserID)
}
- job.prefetch(ctx, viewers)
+ if !job.prefetch(ctx, viewers) {
+ r.log.Warn("channel fanout prefetch failed; replacing online payload with recovery nudge",
+ zap.Int64("channel_id", job.channelID),
+ zap.Int("pts", job.pts),
+ zap.Int("viewers", len(viewers)),
+ )
+ r.recoverFailedChannelFanoutPrefetch(pushCtx, job, recipients)
+ return
+ }
}
seen := make(map[int64]struct{}, len(recipients))
pushed := false
@@ -923,27 +933,82 @@ func (r *Router) runChannelFanoutJob(ctx context.Context, job channelFanoutJob)
}
}
+func (r *Router) recoverFailedChannelFanoutPrefetch(ctx context.Context, job channelFanoutJob, recipients []int64) {
+ if job.channelID == 0 || job.pts <= 0 {
+ return
+ }
+ targets := append([]int64(nil), recipients...)
+ if job.originUserID != 0 {
+ targets = append(targets, job.originUserID)
+ }
+ targets = uniquePeerIDs(targets)
+ delivered := make(map[int64]struct{}, len(targets))
+ date := int(r.clock.Now().Unix())
+ tooLong := &tg.UpdateChannelTooLong{ChannelID: job.channelID}
+ tooLong.SetPts(job.pts)
+ updates := &tg.Updates{
+ Updates: []tg.UpdateClass{tooLong},
+ Users: []tg.UserClass{},
+ Chats: []tg.ChatClass{},
+ Date: date,
+ }
+ for _, userID := range targets {
+ if userID == 0 {
+ continue
+ }
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ }
+ r.pushUserUpdates(ctx, userID, updates)
+ delivered[userID] = struct{}{}
+ }
+ // Explicit monoforum/suggested-post recipients are the full authorized
+ // audience. Member/message-box scopes may have additional online viewers
+ // beyond the full-payload cap, so nudge that recovery audience too.
+ switch job.scope {
+ case channelFanoutMembers:
+ r.nudgeBeyondCapChannelMembers(ctx, job.channelID, job.pts, delivered)
+ case channelFanoutMessageBox:
+ r.nudgeBeyondCapChannelMessageAudience(ctx, job.channelID, job.pts, delivered)
+ }
+}
+
// prefetchChannelFanoutUsers 跨全部 recipient 一次性投影 owner 用户(fan-out 模板化,O(owner)),
// 把结果按 viewer 预热进共享 cache;之后每 viewer 的 build 只命中缓存,不再逐 viewer ForViewer。
-// ownerIDs 由调用方从消息/事件 peer refs 收集。deps.Users 未实现 BatchViewerUsersResolver 或解析
-// 失败时静默跳过——build 回退逐 viewer 解析,行为不变,仅退化为旧的 O(viewer) 成本。
-func (r *Router) prefetchChannelFanoutUsers(ctx context.Context, cache *viewerPeerCache, viewers, ownerIDs []int64) {
- if cache == nil || len(viewers) == 0 || len(ownerIDs) == 0 || r.deps.Users == nil {
- return
+// ownerIDs 由调用方从消息/事件 peer refs 收集。deps.Users 必须实现 BatchViewerUsersResolver;
+// 缺能力、解析失败或 envelope 不完整时返回 false,由 worker fail-closed,禁止逐 viewer 回退。
+func (r *Router) prefetchChannelFanoutUsers(ctx context.Context, cache *viewerPeerCache, viewers, ownerIDs []int64) bool {
+ viewers = uniquePeerIDs(viewers)
+ ownerIDs = uniquePeerIDs(ownerIDs)
+ if len(viewers) == 0 || len(ownerIDs) == 0 {
+ return true
+ }
+ if cache == nil || r.deps.Users == nil {
+ return false
}
resolver, ok := r.deps.Users.(BatchViewerUsersResolver)
if !ok {
- return
+ return false
}
byViewer, err := resolver.ByIDsForViewers(ctx, viewers, ownerIDs)
if err != nil {
- r.log.Warn("channel fanout user prefetch failed; falling back to per-viewer projection",
+ r.log.Warn("channel fanout user prefetch failed",
zap.Int("viewers", len(viewers)), zap.Int("owners", len(ownerIDs)), zap.Error(err))
- return
+ return false
}
- for viewer, users := range byViewer {
- cache.primeUsers(viewer, users)
+ for _, viewer := range viewers {
+ if missingID, missing := missingProjectedUserID(ownerIDs, byViewer[viewer]); missing {
+ r.log.Warn("channel fanout user prefetch returned an incomplete envelope",
+ zap.Int64("viewer_user_id", viewer),
+ zap.Int64("missing_user_id", missingID),
+ zap.Int("owners", len(ownerIDs)))
+ return false
+ }
+ cache.primeExpectedUsers(viewer, ownerIDs, byViewer[viewer])
}
+ return true
}
// channelMessageFanoutOwnerIDs 收集一条频道消息 fan-out 会下发到 Users 数组里的全部 owner 用户 id
@@ -998,9 +1063,12 @@ func (r *Router) enqueueChannelMessageFanout(ctx context.Context, originUserID i
skip := skipDeliverySet(res.SkipDeliveryUserIDs)
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMessageBox, originUserID, res.Channel.ID, res.Event.Pts, res.Recipients,
0,
- func(bgCtx context.Context, viewers []int64) {
- r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs)
+ func(bgCtx context.Context, viewers []int64) bool {
+ if !r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs) {
+ return false
+ }
usernames = r.usernameRegistryMap(bgCtx, usernamePeers)
+ return true
},
func(bgCtx context.Context, viewerUserID int64) *tg.Updates {
// privacy bot 在 send 时被 SkipDeliveryUserIDs 排除(命令/@/回复以外的消息不可见)。
@@ -1021,13 +1089,19 @@ func (r *Router) enqueueChannelMessageFanout(ctx context.Context, originUserID i
func (r *Router) enqueueMonoforumMessageFanout(ctx context.Context, originUserID int64, mono domain.Channel, savedPeer domain.Peer, res domain.SendChannelMessageResult) {
fanoutCache := newViewerPeerCache(r)
ownerIDs := channelMessageFanoutOwnerIDs(res, []int64{savedPeer.ID})
+ projectionPeers := monoforumProjectionPeers(mono.ID, mono.LinkedMonoforumID, ownerIDs)
+ var overlays *monoforumPeerOverlays
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutExplicit, originUserID, mono.ID, res.Event.Pts, res.Recipients,
0,
- func(bgCtx context.Context, viewers []int64) {
- r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs)
+ func(bgCtx context.Context, viewers []int64) bool {
+ if !r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs) {
+ return false
+ }
+ overlays = r.loadMonoforumPeerOverlays(bgCtx, projectionPeers)
+ return true
},
func(bgCtx context.Context, viewerUserID int64) *tg.Updates {
- return r.monoforumDeliveryUpdates(bgCtx, viewerUserID, mono, savedPeer, res)
+ return r.monoforumDeliveryUpdatesWithPeerCacheAndOverlays(bgCtx, viewerUserID, mono, savedPeer, res, fanoutCache, overlays)
})
}
@@ -1099,9 +1173,12 @@ func (r *Router) enqueueChannelEditMessageFanout(ctx context.Context, originUser
nudgePts := max(res.Event.Pts, res.ServiceEvent.Pts)
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMessageBox, originUserID, res.Channel.ID, nudgePts, res.Recipients,
0,
- func(bgCtx context.Context, viewers []int64) {
- r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs)
+ func(bgCtx context.Context, viewers []int64) bool {
+ if !r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs) {
+ return false
+ }
usernames = r.usernameRegistryMap(bgCtx, usernamePeers)
+ return true
},
func(bgCtx context.Context, viewerUserID int64) *tg.Updates {
return r.channelEditMessageUpdatesWithPeerCacheAndUsernames(bgCtx, viewerUserID, res, fanoutCache, usernames)
@@ -1119,9 +1196,12 @@ func (r *Router) enqueueChannelMessagesFanout(ctx context.Context, originUserID,
var usernames map[domain.Peer][]domain.Username
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMessageBox, originUserID, channelID, pts, recipients,
int64(len(results))*(64<<10),
- func(bgCtx context.Context, viewers []int64) {
- r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs)
+ func(bgCtx context.Context, viewers []int64) bool {
+ if !r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs) {
+ return false
+ }
usernames = r.usernameRegistryMap(bgCtx, usernamePeers)
+ return true
},
func(bgCtx context.Context, viewerUserID int64) *tg.Updates {
return r.channelMessagesUpdatesWithPeerCacheAndUsernames(bgCtx, viewerUserID, results, nil, false, extraUserIDs, fanoutCache, usernames)
diff --git a/internal/rpc/channel_fanout_dispatcher_test.go b/internal/rpc/channel_fanout_dispatcher_test.go
index ebd9e87d..9df93f42 100644
--- a/internal/rpc/channel_fanout_dispatcher_test.go
+++ b/internal/rpc/channel_fanout_dispatcher_test.go
@@ -249,8 +249,9 @@ func TestChannelFanoutDispatcherInvokesPrefetch(t *testing.T) {
var gotViewers []int64
job := fanoutTestJob([]int64{2001, 2002}, 5, 99, nil)
- job.prefetch = func(_ context.Context, viewers []int64) {
+ job.prefetch = func(_ context.Context, viewers []int64) bool {
gotViewers = append([]int64(nil), viewers...)
+ return true
}
// deps.Channels=nil → channelFanoutRecipients 返回 explicit recipients=[2001 2002];origin=5 兜底追加。
r.channelFanout.Enqueue(context.Background(), job)
@@ -266,6 +267,36 @@ func TestChannelFanoutDispatcherInvokesPrefetch(t *testing.T) {
}
}
+func TestChannelFanoutJobPrefetchFailureSendsRecoveryNudge(t *testing.T) {
+ sessions := &captureSessions{}
+ r := New(Config{}, Deps{Sessions: sessions}, zaptest.NewLogger(t), clock.System)
+ built := make(map[int64]bool)
+ job := fanoutTestJob([]int64{20}, 10, 0, built)
+ job.pts = 7
+ job.prefetch = func(context.Context, []int64) bool { return false }
+
+ r.runChannelFanoutJob(context.Background(), job)
+
+ if len(built) != 0 {
+ t.Fatalf("build called after prefetch failure: %v", built)
+ }
+ if got := sessions.pushedUserIDs(); len(got) != 2 || got[0] != 20 || got[1] != 10 {
+ t.Fatalf("pushes after prefetch failure = %v, want recovery nudge to recipient 20 and origin 10", got)
+ }
+ updates, ok := sessions.lastUserPush().(*tg.Updates)
+ if !ok || len(updates.Updates) != 1 {
+ t.Fatalf("recovery payload = %#v, want one UpdateChannelTooLong", sessions.lastUserPush())
+ }
+ nudge, ok := updates.Updates[0].(*tg.UpdateChannelTooLong)
+ if !ok {
+ t.Fatalf("recovery update = %T, want UpdateChannelTooLong", updates.Updates[0])
+ }
+ pts, present := nudge.GetPts()
+ if !present || nudge.ChannelID != 1001 || pts != 7 {
+ t.Fatalf("recovery nudge = %+v pts_present=%v, want channel=1001 pts=7", nudge, present)
+ }
+}
+
// editFanoutTestResult 构造一条覆盖两容器的 EditChannelMessageResult:主容器(Event/Message)带
// sender A + reply B,服务消息容器(ServiceEvent/ServiceMessage)带 sender C + Action.UserIDs=[D]。
func editFanoutTestResult(eventPts, servicePts int) domain.EditChannelMessageResult {
@@ -326,6 +357,16 @@ type prefetchRecordingUsersService struct {
gotViewers []int64
gotOwnerIDs []int64
forViewerCall int
+ byIDsCalls int
+ omitViewer int64
+ omitOwner int64
+}
+
+func (s *prefetchRecordingUsersService) ByIDs(ctx context.Context, viewerUserID int64, userIDs []int64) ([]domain.User, error) {
+ s.mu.Lock()
+ s.byIDsCalls++
+ s.mu.Unlock()
+ return s.mapUsersService.ByIDs(ctx, viewerUserID, userIDs)
}
func (s *prefetchRecordingUsersService) ByIDsForViewers(_ context.Context, viewerUserIDs, userIDs []int64) (map[int64][]domain.User, error) {
@@ -336,11 +377,46 @@ func (s *prefetchRecordingUsersService) ByIDsForViewers(_ context.Context, viewe
s.mu.Unlock()
out := make(map[int64][]domain.User, len(viewerUserIDs))
for _, v := range viewerUserIDs {
- out[v] = nil
+ if v == s.omitViewer {
+ continue
+ }
+ for _, id := range userIDs {
+ if id == s.omitOwner {
+ continue
+ }
+ user, ok := s.mapUsersService.users[id]
+ if !ok {
+ user = domain.User{ID: id}
+ }
+ out[v] = append(out[v], user)
+ }
}
return out, nil
}
+func (s *prefetchRecordingUsersService) snapshot() (forViewerCall int, viewers, ownerIDs []int64) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.forViewerCall, append([]int64(nil), s.gotViewers...), append([]int64(nil), s.gotOwnerIDs...)
+}
+
+func TestPrefetchChannelFanoutUsersRejectsMissingViewersAndOwners(t *testing.T) {
+ users := &prefetchRecordingUsersService{omitViewer: 3002, mapUsersService: mapUsersService{users: map[int64]domain.User{
+ 2001: {ID: 2001, FirstName: "must not scalar load"},
+ 2002: {ID: 2002, FirstName: "must not scalar load"},
+ }}}
+ r := New(Config{}, Deps{Users: users}, zaptest.NewLogger(t), clock.System)
+ cache := newViewerPeerCache(r)
+ if r.prefetchChannelFanoutUsers(context.Background(), cache, []int64{3001, 3002}, []int64{2001, 2002}) {
+ t.Fatal("prefetch accepted a response that omitted an entire viewer")
+ }
+ users.omitViewer = 0
+ users.omitOwner = 2002
+ if r.prefetchChannelFanoutUsers(context.Background(), cache, []int64{3001}, []int64{2001, 2002}) {
+ t.Fatal("prefetch accepted a response that omitted an owner")
+ }
+}
+
// TestChannelEditMessageFanoutInvokesPrefetch:enqueueChannelEditMessageFanout 在逐 viewer build
// 前用「channelEditMessageFanoutOwnerIDs(res) + recipients+origin」预热(dispatcher 未启动→同步
// 回退,prefetch 同步执行)。锁定 edit 路径接入了 O(owner) 预热而非逐 viewer 投影。
@@ -353,19 +429,20 @@ func TestChannelEditMessageFanoutInvokesPrefetch(t *testing.T) {
res := editFanoutTestResult(5, 6)
r.enqueueChannelEditMessageFanout(context.Background(), 5, res)
- if users.forViewerCall != 1 {
- t.Fatalf("ByIDsForViewers called %d times, want 1 (prefetch must run once before per-viewer build)", users.forViewerCall)
+ forViewerCall, viewers, ownerIDs := users.snapshot()
+ if forViewerCall != 1 {
+ t.Fatalf("ByIDsForViewers called %d times, want 1 (prefetch must run once before per-viewer build)", forViewerCall)
}
- gotViewers := ownerIDSet(users.gotViewers)
+ gotViewers := ownerIDSet(viewers)
for _, want := range []int64{3001, 3002, 5} {
if !gotViewers[want] {
- t.Fatalf("prefetch viewers %v missing %d (recipients+origin)", users.gotViewers, want)
+ t.Fatalf("prefetch viewers %v missing %d (recipients+origin)", viewers, want)
}
}
- gotOwners := ownerIDSet(users.gotOwnerIDs)
+ gotOwners := ownerIDSet(ownerIDs)
for _, want := range []int64{2001, 2002, 2003, 2004} {
if !gotOwners[want] {
- t.Fatalf("prefetch owner ids %v missing %d (must equal channelEditMessageFanoutOwnerIDs)", users.gotOwnerIDs, want)
+ t.Fatalf("prefetch owner ids %v missing %d (must equal channelEditMessageFanoutOwnerIDs)", ownerIDs, want)
}
}
if registry.batchCalls != 1 || registry.peerCalls != 0 {
diff --git a/internal/rpc/channel_fanout_privacy_bot_rpc_test.go b/internal/rpc/channel_fanout_privacy_bot_rpc_test.go
index be0fb052..8a592604 100644
--- a/internal/rpc/channel_fanout_privacy_bot_rpc_test.go
+++ b/internal/rpc/channel_fanout_privacy_bot_rpc_test.go
@@ -61,7 +61,10 @@ func TestChannelMessageFanoutSkipsPrivacyBotOnlinePush(t *testing.T) {
sessions := &captureSessions{
channelMembers: map[int64][]int64{created.Channel.ID: {1002, 1003}},
}
- r := New(Config{}, Deps{Channels: channelService, Sessions: sessions}, zaptest.NewLogger(t), clock.System)
+ users := &prefetchRecordingUsersService{mapUsersService: mapUsersService{users: map[int64]domain.User{
+ 1001: {ID: 1001, FirstName: "sender"},
+ }}}
+ r := New(Config{}, Deps{Channels: channelService, Sessions: sessions, Users: users}, zaptest.NewLogger(t), clock.System)
// 复核前置:修复前后 channelFanoutRecipients 都会把 1003 列进 recipients(在线活跃成员),
// 漏洞/修复的差异在 build 是否对它返回 nil。
diff --git a/internal/rpc/channels_core.go b/internal/rpc/channels_core.go
index 6b8b090b..cd1ca475 100644
--- a/internal/rpc/channels_core.go
+++ b/internal/rpc/channels_core.go
@@ -39,13 +39,33 @@ func (r *Router) onChannelsCreateChannel(ctx context.Context, req *tg.ChannelsCr
return nil, channelInvalidErr(err)
}
r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...)
- updates := r.channelOperationUpdates(ctx, userID, res)
+ updates, err := r.channelCreationResponseUpdates(ctx, userID, res)
+ if err != nil {
+ return nil, err
+ }
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
return r.channelOperationUpdates(ctx, viewerUserID, res)
})
return updates, nil
}
+// channelCreationResponseUpdates adds the response-only message mapping TDLib
+// requires to recognize a channels.createChannel result. The mapping is never
+// reused by fan-out or difference; the create service message remains the sole
+// durable, PTS-bearing fact.
+func (r *Router) channelCreationResponseUpdates(ctx context.Context, viewerUserID int64, res domain.CreateChannelResult) (*tg.Updates, error) {
+ if res.Message.ID <= 0 || res.Message.Action == nil || res.Message.Action.Type != domain.ChannelActionCreate {
+ return nil, internalErr()
+ }
+ updates := r.channelOperationUpdates(ctx, viewerUserID, res)
+ if updates == nil {
+ return nil, internalErr()
+ }
+ mapping := &tg.UpdateMessageID{ID: res.Message.ID, RandomID: randomNonZeroInt64()}
+ updates.Updates = append([]tg.UpdateClass{mapping}, updates.Updates...)
+ return updates, nil
+}
+
func validateChannelsCreateChannelOptions(req *tg.ChannelsCreateChannelRequest) error {
if req == nil {
return inputRequestInvalidErr()
@@ -168,6 +188,9 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
return nil, channelInvalidErr(domain.ErrChannelPrivate)
}
full := cached.full
+ if err := r.applyWelcomeMessagesToFullChat(ctx, ref.ID, &full); err != nil {
+ return nil, err
+ }
if err := r.applyTranslationDisabledToChannelFull(ctx, userID, ref.ID, &full); err != nil {
return nil, err
}
@@ -191,6 +214,7 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
return nil, err
}
full := tgChannelFull(view, r.cfg.PublicBaseURL)
+ r.applyChannelStatsCapability(full)
userIDs := []int64{view.Channel.CreatorUserID, view.Self.UserID}
// 注:Bots 过滤实际会返回群内 bot(TestGroupBotRPCShape 覆盖),这里据此富化 full.BotInfo。
// (此前审计误判为死代码,已由单测纠正——勿删。)
@@ -217,6 +241,9 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
chats: append([]tg.ChatClass(nil), chats...),
userIDs: userIDs,
}, loadEpoch)
+ if err := r.applyWelcomeMessagesToFullChat(ctx, view.Channel.ID, full); err != nil {
+ return nil, err
+ }
if err := r.applyTranslationDisabledToChannelFull(ctx, userID, view.Channel.ID, full); err != nil {
return nil, err
}
@@ -235,6 +262,23 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
}, nil
}
+func (r *Router) applyWelcomeMessagesToFullChat(ctx context.Context, channelID int64, full tg.ChatFullClass) error {
+ if r.deps.WelcomeMessages == nil || channelID <= 0 || full == nil {
+ return nil
+ }
+ hasAny, err := r.deps.WelcomeMessages.HasAny(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID})
+ if err != nil {
+ return internalErr()
+ }
+ switch value := full.(type) {
+ case *tg.ChannelFull:
+ value.HasWelcomeMessages = hasAny
+ case *tg.ChatFull:
+ value.HasWelcomeMessages = hasAny
+ }
+ return nil
+}
+
type channelReadModelResolver interface {
GetChannelReadModel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error)
}
@@ -290,27 +334,48 @@ func (r *Router) onChannelsGetSendAs(ctx context.Context, req *tg.ChannelsGetSen
}
}
chats = []tg.ChatClass{tgChannelChatForView(userID, view)}
- // 以「当前频道/群本身」发言:广播频道自帖、匿名管理员等(canCurrentChannelSendAs 判定)。
- if canCurrentChannelSendAs(view) {
- peers = append(peers, tg.SendAsPeer{Peer: &tg.PeerChannel{ChannelID: view.Channel.ID}})
- }
- // 以「用户自己拥有的其它广播频道」身份在本群发言。非本群关联频道的个人频道需会员
- // (premium_required,对齐官方:仅本群的 linked 讨论频道免会员),客户端据此置灰/引导开会员,
- // 服务端在发送侧用 PremiumActiveAt 兜底门控。
- if owned, err := r.deps.Channels.ListSendAsChannels(ctx, userID); err == nil && len(owned) > 0 {
- extras := make([]domain.Channel, 0, len(owned))
+ owned, ownedErr := r.deps.Channels.ListSendAsChannels(ctx, userID)
+ if req.ForPaidReactions {
+ // Paid reaction identities are self plus currently owned/postable
+ // broadcast channels. They are not message send-as candidates and do
+ // not carry the unrelated premium_required gate.
+ seen := make(map[int64]struct{}, len(owned))
for _, ch := range owned {
- if ch.ID == 0 || ch.ID == view.Channel.ID {
+ if ch.ID == 0 || ch.Deleted || !ch.Broadcast || ch.CreatorUserID != userID {
continue
}
- sendAs := tg.SendAsPeer{Peer: &tg.PeerChannel{ChannelID: ch.ID}}
- if ch.ID != view.Channel.LinkedChatID {
- sendAs.PremiumRequired = true
+ if _, ok := seen[ch.ID]; ok {
+ continue
+ }
+ seen[ch.ID] = struct{}{}
+ peers = append(peers, tg.SendAsPeer{Peer: &tg.PeerChannel{ChannelID: ch.ID}})
+ if ch.ID != view.Channel.ID {
+ chats = append(chats, tgChannels(userID, []domain.Channel{ch})...)
}
- peers = append(peers, sendAs)
- extras = append(extras, ch)
}
- chats = append(chats, tgChannels(userID, extras)...)
+ } else {
+ // 以「当前频道/群本身」发言:广播频道自帖、匿名管理员等(canCurrentChannelSendAs 判定)。
+ if canCurrentChannelSendAs(view) {
+ peers = append(peers, tg.SendAsPeer{Peer: &tg.PeerChannel{ChannelID: view.Channel.ID}})
+ }
+ // 以「用户自己拥有的其它广播频道」身份在本群发言。非本群关联频道的个人频道需会员
+ // (premium_required,对齐官方:仅本群的 linked 讨论频道免会员),客户端据此置灰/引导开会员,
+ // 服务端在发送侧用 PremiumActiveAt 兜底门控。
+ if ownedErr == nil && len(owned) > 0 {
+ extras := make([]domain.Channel, 0, len(owned))
+ for _, ch := range owned {
+ if ch.ID == 0 || ch.ID == view.Channel.ID {
+ continue
+ }
+ sendAs := tg.SendAsPeer{Peer: &tg.PeerChannel{ChannelID: ch.ID}}
+ if ch.ID != view.Channel.LinkedChatID {
+ sendAs.PremiumRequired = true
+ }
+ peers = append(peers, sendAs)
+ extras = append(extras, ch)
+ }
+ chats = append(chats, tgChannels(userID, extras)...)
+ }
}
}
out := &tg.ChannelsSendAsPeers{
diff --git a/internal/rpc/channels_create_response_rpc_test.go b/internal/rpc/channels_create_response_rpc_test.go
new file mode 100644
index 00000000..8f5572d9
--- /dev/null
+++ b/internal/rpc/channels_create_response_rpc_test.go
@@ -0,0 +1,97 @@
+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"
+)
+
+func TestChannelsCreateChannelResponseCarriesTdlibMessageMappingOnlyForCaller(t *testing.T) {
+ ctx := context.Background()
+ userStore := memory.NewUserStore()
+ owner, err := userStore.Create(ctx, domain.User{
+ AccessHash: 88001,
+ Phone: "15550088001",
+ FirstName: "Owner",
+ })
+ if err != nil {
+ t.Fatalf("create owner: %v", err)
+ }
+ sessions := &captureSessions{onlineUserIDs: []int64{owner.ID}}
+ r := New(Config{}, Deps{
+ Users: appusers.NewService(userStore),
+ Channels: appchannels.NewService(memory.NewChannelStore()),
+ Sessions: sessions,
+ }, zaptest.NewLogger(t), clock.System)
+
+ tests := []struct {
+ name string
+ req *tg.ChannelsCreateChannelRequest
+ }{
+ {name: "broadcast", req: &tg.ChannelsCreateChannelRequest{Title: "TDLib broadcast", Broadcast: true}},
+ {name: "megagroup", req: &tg.ChannelsCreateChannelRequest{Title: "TDLib group", Megagroup: true}},
+ {name: "forum", req: &tg.ChannelsCreateChannelRequest{Title: "TDLib forum", Megagroup: true, Forum: true}},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ sessions.clearMessages()
+ created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), test.req)
+ if err != nil {
+ t.Fatalf("create channel: %v", err)
+ }
+ updates, ok := created.(*tg.Updates)
+ if !ok || len(updates.Updates) != 3 {
+ t.Fatalf("response = %T %+v, want mapping, create message, and channel refresh", created, created)
+ }
+ mapping, ok := updates.Updates[0].(*tg.UpdateMessageID)
+ if !ok || mapping.ID <= 0 || mapping.RandomID == 0 {
+ t.Fatalf("mapping = %#v, want positive message id and non-zero random id", updates.Updates[0])
+ }
+ create, ok := updates.Updates[1].(*tg.UpdateNewChannelMessage)
+ if !ok || create.Pts != domain.FirstChannelEventPts || create.PtsCount != 1 {
+ t.Fatalf("create update = %#v, want pts=2 pts_count=1", updates.Updates[1])
+ }
+ service, ok := create.Message.(*tg.MessageService)
+ if !ok || service.ID != mapping.ID {
+ t.Fatalf("create service = %#v, want mapped id %d", create.Message, mapping.ID)
+ }
+ if _, ok := service.Action.(*tg.MessageActionChannelCreate); !ok {
+ t.Fatalf("create action = %T, want messageActionChannelCreate", service.Action)
+ }
+ if refresh, ok := updates.Updates[2].(*tg.UpdateChannel); !ok || refresh.ChannelID == 0 {
+ t.Fatalf("refresh = %#v, want updateChannel", updates.Updates[2])
+ }
+
+ pushed, ok := sessions.lastUserPush().(*tg.Updates)
+ if !ok || len(pushed.Updates) != 2 {
+ t.Fatalf("fan-out = %T %+v, want create message and channel refresh only", sessions.lastUserPush(), sessions.lastUserPush())
+ }
+ for _, update := range pushed.Updates {
+ if _, ok := update.(*tg.UpdateMessageID); ok {
+ t.Fatalf("response-only updateMessageID leaked into fan-out: %+v", pushed.Updates)
+ }
+ }
+ })
+ }
+}
+
+func TestChannelCreationResponseRejectsNonCreationResult(t *testing.T) {
+ r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
+ for _, result := range []domain.CreateChannelResult{
+ {},
+ {Message: domain.ChannelMessage{ID: 1}},
+ {Message: domain.ChannelMessage{ID: 1, Action: &domain.ChannelMessageAction{Type: domain.ChannelActionChatAddUser}}},
+ } {
+ if updates, err := r.channelCreationResponseUpdates(context.Background(), 1, result); err == nil || updates != nil {
+ t.Fatalf("invalid creation result = %+v produced updates=%+v err=%v", result, updates, err)
+ }
+ }
+}
diff --git a/internal/rpc/channels_discussion_top_msg_id_rpc_test.go b/internal/rpc/channels_discussion_top_msg_id_rpc_test.go
new file mode 100644
index 00000000..e8c82106
--- /dev/null
+++ b/internal/rpc/channels_discussion_top_msg_id_rpc_test.go
@@ -0,0 +1,140 @@
+package rpc
+
+import (
+ "context"
+ "testing"
+
+ "github.com/iamxvbaba/td/clock"
+ "github.com/iamxvbaba/td/tg"
+ "github.com/iamxvbaba/td/tgerr"
+ "go.uber.org/zap/zaptest"
+
+ appchannels "telesrv/internal/app/channels"
+ appdialogs "telesrv/internal/app/dialogs"
+ appusers "telesrv/internal/app/users"
+ "telesrv/internal/domain"
+ "telesrv/internal/store/memory"
+)
+
+// forumGeneralTopMsgID is what stock tdesktop puts in top_msg_id when it has no
+// topic to name -- Data::ForumTopic::kGeneralId. Attaching a file to a comment
+// on a channel post takes that path even though a linked discussion group is a
+// plain megagroup, so the client sends reply_to_msg_id=
just falls back to initials; a mutation
+ action surfaces a "connection refused") until someone notices and sets
+ it -- there's no reason to make a self-hoster running both binaries
+ together, which is exactly what quickstart does, discover and fix that
+ by hand.
+
+ Returns the freshly generated admin password if one was generated
+ (fresh install, or an existing .env that had neither a password nor a
+ token), or None if nothing needed generating -- an existing password is
+ never read back for display either way.
+
+ SETUP_PENDING_FILE (which gates the first-run wizard) and
+ PASSWORD_TEMPORARY_FILE (which makes that generated password stop
+ working once the wizard finishes -- see
+ identity.Store.TemporaryPasswordMatches) are only ever written on the
+ fresh-install branch. Patching secrets into an .env that was already
+ there isn't a first run -- there is likely already a real identity,
+ real data, real users behind it -- so it must never force that install
+ through the wizard, and a password generated to plug that gap has to
+ go on working indefinitely (nothing will ever run MarkSetupComplete to
+ retire it, since SetupPending was never true for it in the first
+ place)."""
+ fresh_install = not is_initialized()
+ values = current_env_values(parse_env_template())
+
+ generated_password = None
+ changed = False
+ if not values.get("TELESRV_ADMIN_UI_PASSWORD") and not values.get("TELESRV_ADMIN_UI_TOKEN"):
+ generated_password = secrets.token_urlsafe(12)
+ values["TELESRV_ADMIN_UI_PASSWORD"] = generated_password
+ changed = True
+ if not values.get("TELESRV_ADMIN_API_TOKEN"):
+ values["TELESRV_ADMIN_API_TOKEN"] = secrets.token_hex(32)
+ changed = True
+ if not values.get("TELESRV_ADMIN_SESSION_KEY"):
+ values["TELESRV_ADMIN_SESSION_KEY"] = secrets.token_urlsafe(32)
+ changed = True
+ if not values.get("TELESRV_ADMIN_API_ADDR"):
+ values["TELESRV_ADMIN_API_ADDR"] = "127.0.0.1:2599"
+ changed = True
+
+ if not fresh_install and not changed:
+ return None # existing .env, and every required secret was already set
+
+ save_env(values)
+ if fresh_install:
+ IDENTITY_DIR.mkdir(parents=True, exist_ok=True)
+ SETUP_PENDING_FILE.touch()
+ if generated_password:
+ PASSWORD_TEMPORARY_FILE.write_text(generated_password)
+ return generated_password
+
+
+def _run_naming_helper(cmd: list[str]) -> str:
+ """Runs a Docker/DB naming-migration helper directly against the real
+ console. StartupProgressScreen's equivalent (_run_interactive) goes
+ through self.app.suspend() because Textual owns the terminal at that
+ point; quickstart never starts Textual, so there is nothing to suspend.
+ Safe to call unconditionally, even on a first-ever run: both scripts
+ only prompt when they find pre-existing 'telesrv_*' Docker state to
+ migrate, and fall back cleanly rather than hang when stdin isn't a real
+ terminal -- see their own doc comments."""
+ proc = subprocess.run(cmd, cwd=ROOT, stdout=subprocess.PIPE, text=True)
+ return proc.stdout or ""
+
+
+def resolve_naming_headless() -> tuple[str, str]:
+ cached = MANAGER.cached_docker_naming()
+ if cached is not None:
+ project, prefix = cached, cached
+ else:
+ project, prefix = MANAGER.resolve_docker_naming(_run_naming_helper)
+ if prefix == "owpengram" and MANAGER.cached_db_naming() is None:
+ MANAGER.resolve_db_naming(_run_naming_helper, f"{prefix}-postgres")
+ return project, prefix
+
+
+def quickstart() -> int:
+ """The default entry point: bootstrap, start, print where to go, exit --
+ no menu, no prompts. First-run configuration (branding, SMTP, the admin
+ password itself) all happens afterward in the web admin panel instead
+ of here; the interactive TUI (stop/restart/logs/.env editing) is still
+ available with the 'panel' argument for anyone who wants it. Returns a
+ process exit code."""
+ generated_password = bootstrap_env()
+
+ status = MANAGER.status()
+ if status.running and status.docker_healthy:
+ print("[ok] Already running.")
+ else:
+ if status.running:
+ # The binaries are alive but Postgres/Redis aren't -- Docker
+ # Desktop not up yet, a container that crashed, whatever the
+ # cause, the running processes have been failing every database
+ # call. Stop them first: launching the fresh ones below without
+ # this would try to bind the ports the stuck ones are still
+ # holding, rather than actually fixing anything.
+ print("[..] Processes are running but Docker isn't -- stopping before restarting cleanly...")
+ MANAGER.stop()
+ print("== Starting OwpenGram ==")
+ print()
+ try:
+ project, prefix = resolve_naming_headless()
+ except Exception as exc: # noqa: BLE001 - report and exit, no traceback
+ print(f"[ERROR] Docker naming resolution failed: {exc}")
+ return 1
+
+ print("[..] Starting Docker infrastructure...")
+ ok, out = MANAGER.docker_compose_up(project, prefix)
+ if not ok:
+ print(f"[ERROR] docker compose up failed:\n{out.strip()}")
+ return 1
+ print("[ok] Docker infrastructure up.")
+
+ print("[..] Waiting for PostgreSQL...")
+ if not MANAGER.wait_postgres(prefix):
+ print("[ERROR] PostgreSQL did not become ready within 60s.")
+ return 1
+ print("[ok] PostgreSQL ready.")
+
+ print("[..] Building binaries (go build)...")
+ ok, out = MANAGER.build()
+ if not ok:
+ print(f"[ERROR] Build failed:\n{out.strip()}")
+ return 1
+ print("[ok] Build complete.")
+
+ print("[..] Launching owpengram-server and owpengram-admin-panel...")
+ server_pid = MANAGER.launch(SERVER_EXE, SERVER_LOG)
+ admin_pid = MANAGER.launch(ADMIN_EXE, ADMIN_LOG)
+ save_state({
+ "server_pid": server_pid,
+ "admin_pid": admin_pid,
+ "docker_project": project,
+ "docker_prefix": prefix,
+ })
+ print("[ok] Launched.")
+
+ print()
+ info = admin_ui_info()
+ if info is None:
+ print("[WARN] TELESRV_ADMIN_UI_ADDR is not set -- can't show the admin panel URL.")
+ else:
+ url, _ = info
+ print(f"Open {url} to finish setting up your server.")
+ if generated_password:
+ print(f"Login: {ADMIN_BREAK_GLASS_USERNAME}")
+ print(f"Initial admin password: {generated_password}")
+ print()
+ print("For stop/restart/logs/.env editing from the terminal instead: owpengram-server.bat panel")
+ return 0
+
+
def copy_to_clipboard(text: str) -> bool:
if IS_WINDOWS:
candidates = [["clip"]]
@@ -1731,7 +1993,14 @@ class ServerPanelApp(App):
if __name__ == "__main__":
- app = ServerPanelApp()
- app.run()
- if app.request_restart:
- os.execv(sys.executable, [sys.executable] + sys.argv)
+ # No argument (the normal `owpengram-server.bat` / .sh invocation):
+ # quickstart -- bootstrap, start, print the admin panel URL, exit.
+ # `panel`: the interactive TUI this file used to always open, still
+ # available for stop/restart/logs/.env editing from the terminal.
+ if len(sys.argv) > 1 and sys.argv[1] == "panel":
+ app = ServerPanelApp()
+ app.run()
+ if app.request_restart:
+ os.execv(sys.executable, [sys.executable] + sys.argv)
+ else:
+ sys.exit(quickstart())