merged with fixes

This commit is contained in:
onysd 2026-09-09 02:49:30 +03:00
parent a9e758b712
commit 2f1818d656
176 changed files with 9000 additions and 907 deletions

View file

@ -119,6 +119,7 @@ func (s *server) routes() http.Handler {
mux.Handle("POST /api/actions/set-account-profile", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetProfileAPI)))
mux.Handle("POST /api/actions/set-account-phone", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetPhoneAPI)))
mux.Handle("POST /api/actions/set-account-avatar", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetAccountAvatarAPI)))
mux.Handle("POST /api/actions/set-account-avatar-video", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetAccountAvatarVideoAPI)))
mux.Handle("POST /api/actions/set-account-login-email", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetLoginEmailAPI)))
mux.Handle("POST /api/actions/set-account-color", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetUserColorAPI)))
mux.Handle("POST /api/actions/set-account-emoji-status", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetUserEmojiStatusAPI)))
@ -1527,6 +1528,52 @@ func (s *server) handleSetAccountAvatarAPI(w http.ResponseWriter, r *http.Reques
writeCommandResultAPI(w, result, err)
}
type setAccountAvatarVideoAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
VideoStartTs float64 `json:"video_start_ts"`
}
func (s *server) handleSetAccountAvatarVideoAPI(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
r.Body = http.MaxBytesReader(w, r.Body, admin.MaxAccountAvatarVideoBytes+(1<<20))
if err := r.ParseMultipartForm(1 << 20); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
var body setAccountAvatarVideoAPIRequest
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
dec.DisallowUnknownFields()
if err := dec.Decode(&body); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeAPIError(w, http.StatusBadRequest, "avatar video file is required")
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, admin.MaxAccountAvatarVideoBytes+1))
if err != nil || len(data) == 0 || int64(len(data)) > admin.MaxAccountAvatarVideoBytes {
writeAPIError(w, http.StatusBadRequest, "avatar video file is empty or too large")
return
}
req := admin.SetAccountAvatarVideoRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-avatar-video"),
UserID: body.UserID,
FileName: header.Filename,
VideoStartTs: body.VideoStartTs,
}
result, err := s.callAdminMultipart(r.Context(), "/v1/accounts/set-avatar-video", req, header.Filename, data)
writeCommandResultAPI(w, result, err)
}
type setUserColorAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -242,6 +242,7 @@ export const api = {
gifCatalogDocumentPreviewURL: (documentID: string) => `/api/gif-catalog/documents/${encodeURIComponent(documentID)}/preview`,
createStickerSet: (form: FormData) => request<CommandResult>("/api/actions/create-sticker-set", { method: "POST", body: form }),
setAccountAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-account-avatar", { method: "POST", body: form }),
setAccountAvatarVideo: (form: FormData) => request<CommandResult>("/api/actions/set-account-avatar-video", { method: "POST", body: form }),
setChannelAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-channel-avatar", { method: "POST", body: form }),
addStickerToSet: (form: FormData) => request<CommandResult>("/api/actions/add-sticker-to-set", { method: "POST", body: form }),
gifCatalog: () => request<GifCatalogListResponse>("/api/gif-catalog"),

View file

@ -13,10 +13,13 @@ type AvatarModalKind = "user" | "channel";
export function AvatarModal({ kind, id, onClose, onDone }: { kind: AvatarModalKind; id: number; onClose: () => void; onDone: () => void }) {
const [file, setFile] = useState<File | null>(null);
const [previewURL, setPreviewURL] = useState("");
const [videoStartTs, setVideoStartTs] = useState("0");
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const isVideo = kind === "user" && !!file && file.type.startsWith("video/");
useEffect(() => {
if (!file) {
setPreviewURL("");
@ -29,7 +32,7 @@ export function AvatarModal({ kind, id, onClose, onDone }: { kind: AvatarModalKi
async function submit() {
if (!file) {
setError("Choose an image file first.");
setError("Choose an image or video file first.");
return;
}
if (!reason.trim()) {
@ -41,9 +44,13 @@ export function AvatarModal({ kind, id, onClose, onDone }: { kind: AvatarModalKi
try {
const idField = kind === "channel" ? "channel_id" : "user_id";
const form = new FormData();
form.set("metadata", JSON.stringify({ command_id: "", reason: reason.trim(), confirm: true, [idField]: id }));
const metadata: Record<string, unknown> = { command_id: "", reason: reason.trim(), confirm: true, [idField]: id };
if (isVideo) {
metadata.video_start_ts = Number(videoStartTs) || 0;
}
form.set("metadata", JSON.stringify(metadata));
form.set("file", file, file.name);
const result = kind === "channel" ? await api.setChannelAvatar(form) : await api.setAccountAvatar(form);
const result = kind === "channel" ? await api.setChannelAvatar(form) : isVideo ? await api.setAccountAvatarVideo(form) : await api.setAccountAvatar(form);
if (result.error) {
setError(result.error);
return;
@ -71,11 +78,32 @@ export function AvatarModal({ kind, id, onClose, onDone }: { kind: AvatarModalKi
</div>
<div className="command-body">
<label className={`gift-file-picker ${file ? "has-file" : ""}`}>
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
{previewURL ? <img className="gift-file-icon" src={previewURL} alt="" style={{ objectFit: "cover" }} /> : <ImagePlus size={22} />}
<span className="gift-file-copy"><span className="gift-field-label">{"New avatar"}</span><strong>{file ? file.name : "Choose a JPEG, PNG, or WebP image"}</strong></span>
<input
type="file"
accept={kind === "user" ? "image/png,image/jpeg,image/webp,video/mp4" : "image/png,image/jpeg,image/webp"}
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
/>
{previewURL ? (
isVideo ? (
<video className="gift-file-icon" src={previewURL} style={{ objectFit: "cover" }} muted loop autoPlay />
) : (
<img className="gift-file-icon" src={previewURL} alt="" style={{ objectFit: "cover" }} />
)
) : (
<ImagePlus size={22} />
)}
<span className="gift-file-copy">
<span className="gift-field-label">{"New avatar"}</span>
<strong>{file ? file.name : kind === "user" ? "Choose a JPEG, PNG, WebP image, or MP4 video" : "Choose a JPEG, PNG, or WebP image"}</strong>
</span>
<span className="gift-file-action">{file ? "Change file" : "Choose file"}</span>
</label>
{isVideo && (
<label className="gift-reason-field">
<span>{"Video start (seconds)"}</span>
<input type="number" min="0" step="0.1" value={videoStartTs} onChange={(event) => setVideoStartTs(event.target.value)} />
</label>
)}
<label className="gift-reason-field"><span>{"Audit reason"}</span><input value={reason} placeholder={"Briefly describe why this avatar is being changed"} onChange={(event) => setReason(event.target.value)} /></label>
{error && <Alert>{error}</Alert>}
</div>

View file

@ -0,0 +1,4 @@
-- No-op: see 0181_sticker_set_system_key_unique.up.sql. The real schema
-- change (and its reversal) lives in
-- 20260901000006_sticker_set_system_key_unique.down.sql.
SELECT 1;

View file

@ -0,0 +1,12 @@
-- No-op placeholder. This migration's real content (sticker_sets.system_key
-- uniqueness) already applies via 20260901000006_sticker_set_system_key_unique
-- (identical SQL, applied earlier under our fork's own timestamp-based
-- migration numbering). The version number 181 itself must still exist as a
-- migration, though: internal/store/postgres/postgres.go's Migrate() calls
-- m.Migrate(phoneIdentityPredecessorVersion) (=181) to step a fresh database
-- to exactly this version before running the Go-side phone-identity
-- canonicalization pass, and golang-migrate requires that version to be a
-- real, reachable migration file. Do not reapply the sticker_sets change here
-- -- 20260901000006 already owns it, and repeating it would fail on an
-- already-unique index for any install that runs both.
SELECT 1;

View file

@ -0,0 +1,3 @@
-- E.164 canonicalization is intentionally irreversible: the removed national
-- trunk presentation is not part of the account identity.
SELECT 1;

View file

@ -0,0 +1,5 @@
-- The country-aware data rewrite is executed transactionally by
-- postgres.canonicalizeStoredPhoneIdentities before this durable version marker.
-- Keeping it in Go avoids incorrect SQL-only stripping of significant leading
-- zeroes (for example Italian fixed-line numbers).
SELECT 1;

View file

@ -0,0 +1,8 @@
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM private_messages WHERE reply_external <> '{}'::jsonb)
OR EXISTS (SELECT 1 FROM message_boxes WHERE reply_external <> '{}'::jsonb) THEN
RAISE EXCEPTION 'cannot discard durable external reply snapshots';
END IF;
END $$;
ALTER TABLE message_boxes DROP COLUMN reply_external;
ALTER TABLE private_messages DROP COLUMN reply_external;

View file

@ -0,0 +1,7 @@
ALTER TABLE private_messages ADD COLUMN reply_external jsonb NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE message_boxes ADD COLUMN reply_external jsonb NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE private_messages ADD CONSTRAINT private_messages_reply_external_object
CHECK (jsonb_typeof(reply_external) = 'object' AND octet_length(reply_external::text) <= 1048576);
ALTER TABLE message_boxes ADD CONSTRAINT message_boxes_reply_external_object
CHECK (jsonb_typeof(reply_external) = 'object' AND octet_length(reply_external::text) <= 1048576);

3
go.mod
View file

@ -15,6 +15,7 @@ require (
github.com/klauspost/compress v1.19.1
github.com/lestrrat-go/jwx/v3 v3.1.1
github.com/minio/minio-go/v7 v7.2.1
github.com/nyaruka/phonenumbers v1.8.1
github.com/pion/datachannel v1.6.2
github.com/pion/dtls/v3 v3.1.5
github.com/pion/ice/v4 v4.3.0
@ -58,7 +59,6 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.19.1 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
@ -99,6 +99,7 @@ require (
golang.org/x/mod v0.38.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.48.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
rsc.io/qr v0.2.0 // indirect

View file

@ -35,6 +35,7 @@ const (
ActionSetPhone = "account.set_phone"
ActionSetLoginEmail = "account.set_login_email"
ActionSetAccountAvatar = "account.set_avatar"
ActionSetAccountAvatarVideo = "account.set_avatar_video"
ActionSetChannelAvatar = "channel.set_avatar"
ActionSetChannelUsername = "channel.set_username"
ActionSetChannelSettings = "channel.set_settings"
@ -288,6 +289,9 @@ type AvatarResolver interface {
// preview before CreateAvatarFromBytes actually materializes the avatar.
ValidateAvatarUpload(data []byte) bool
CreateAvatarFromBytes(ctx context.Context, data []byte, ownerUserID int64) (domain.Photo, error)
// CreateAvatarVideoFromBytes is CreateAvatarFromBytes's video counterpart,
// for the admin console's animated-avatar upload.
CreateAvatarVideoFromBytes(ctx context.Context, data []byte, ownerUserID int64, videoStartTs float64) (domain.Photo, error)
SetCurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoID int64, date int) (domain.Photo, bool, error)
// GetPhoto looks up a photo by id directly -- used to read a channel's
// current avatar, which is denormalized on the channel row as a bare
@ -873,6 +877,14 @@ type SetAccountAvatarRequest struct {
Data []byte `json:"-"`
}
type SetAccountAvatarVideoRequest struct {
CommandMeta
UserID int64 `json:"user_id"`
FileName string `json:"file_name"`
VideoStartTs float64 `json:"video_start_ts"`
Data []byte `json:"-"`
}
type SetChannelAvatarRequest struct {
CommandMeta
ChannelID int64 `json:"channel_id"`
@ -1607,6 +1619,52 @@ func (s *Service) SetAccountAvatar(ctx context.Context, req SetAccountAvatarRequ
})
}
// SetAccountAvatarVideo is SetAccountAvatar's video counterpart: force-sets a
// user's current profile photo from raw uploaded animated-video bytes,
// reusing the same still-frame + s/a/c rendition pipeline as
// photos.uploadProfilePhoto's video path. Unlike SetAccountAvatar's
// ValidateAvatarUpload (an image-header decode), video bytes are only
// size-bounded here -- the admin console does not decode video containers.
func (s *Service) SetAccountAvatarVideo(ctx context.Context, req SetAccountAvatarVideoRequest) (CommandResult, error) {
if req.UserID <= 0 {
return CommandResult{}, fmt.Errorf("user_id is required")
}
if domain.IsSystemUserID(req.UserID) {
return CommandResult{}, fmt.Errorf("system user avatar cannot be changed")
}
if s == nil || s.users == nil || s.photos == nil {
return CommandResult{}, fmt.Errorf("admin avatar dependencies are not configured")
}
if len(req.Data) == 0 || len(req.Data) > MaxAccountAvatarVideoBytes {
return CommandResult{}, domain.ErrPhotoInvalid
}
return s.runCommand(ctx, req.CommandMeta, ActionSetAccountAvatarVideo, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
u, found, err := s.users.AdminUser(ctx, req.UserID)
if err != nil {
return CommandResult{}, err
}
if !found {
return CommandResult{}, domain.ErrUserNotFound
}
details := map[string]any{"file_name": req.FileName, "bytes": len(req.Data), "bot": u.Bot}
if req.DryRun {
return CommandResult{Message: "avatar video update validated", Details: details}, nil
}
photo, err := s.photos.CreateAvatarVideoFromBytes(ctx, req.Data, req.UserID, req.VideoStartTs)
if err != nil {
return CommandResult{Details: details}, err
}
if _, _, err := s.photos.SetCurrentProfilePhotoKind(ctx, domain.PeerTypeUser, req.UserID, domain.ProfilePhotoKindProfile, photo.ID, int(s.now().Unix())); err != nil {
return CommandResult{Details: details}, err
}
details["photo_id"] = strconv.FormatInt(photo.ID, 10)
if err := s.notifyUserChanged(ctx, u); err != nil {
details["notify_error"] = err.Error()
}
return CommandResult{Message: "avatar video updated", Details: details}, nil
})
}
// SetChannelAvatar force-sets a channel's avatar from raw uploaded image
// bytes, reusing the same avatar rendition pipeline (s/a/c sizes) as
// SetAccountAvatar, but attaching the resulting photo directly to the
@ -2646,6 +2704,13 @@ func (s *Service) DeletePrivateHistory(ctx context.Context, req DeletePrivateHis
// (SetAccountAvatar) a user's profile photo through the admin console.
const MaxAccountAvatarBytes = 4 << 20
// MaxAccountAvatarVideoBytes bounds SetAccountAvatarVideo uploads. Video
// avatars are official Telegram's short (~a few seconds) looping clips, so
// their encoded size runs well above a static photo's while still being
// bounded -- a real duration/dimension cap would need decoding the video,
// which the admin console deliberately does not do.
const MaxAccountAvatarVideoBytes = 10 << 20
// AccountAvatar returns an account's current profile photo bytes and detected
// MIME type, mirroring internal/web's public avatar serving (same size
// selection and safe-image-type checks) so the admin console shows exactly

View file

@ -56,6 +56,7 @@ type Service interface {
SetPhone(ctx context.Context, req admin.SetPhoneRequest) (admin.CommandResult, error)
SetLoginEmail(ctx context.Context, req admin.SetLoginEmailRequest) (admin.CommandResult, error)
SetAccountAvatar(ctx context.Context, req admin.SetAccountAvatarRequest) (admin.CommandResult, error)
SetAccountAvatarVideo(ctx context.Context, req admin.SetAccountAvatarVideoRequest) (admin.CommandResult, error)
ChannelAvatar(ctx context.Context, channelID int64) ([]byte, string, bool, error)
SetChannelAvatar(ctx context.Context, req admin.SetChannelAvatarRequest) (admin.CommandResult, error)
SetUserColor(ctx context.Context, req admin.SetUserColorRequest) (admin.CommandResult, error)
@ -188,6 +189,7 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /v1/accounts/set-phone", s.authenticated(s.handleSetPhone))
mux.HandleFunc("POST /v1/accounts/set-login-email", s.authenticated(s.handleSetLoginEmail))
mux.HandleFunc("POST /v1/accounts/set-avatar", s.authenticated(s.handleSetAccountAvatar))
mux.HandleFunc("POST /v1/accounts/set-avatar-video", s.authenticated(s.handleSetAccountAvatarVideo))
mux.HandleFunc("POST /v1/accounts/set-color", s.authenticated(s.handleSetUserColor))
mux.HandleFunc("POST /v1/accounts/set-emoji-status", s.authenticated(s.handleSetUserEmojiStatus))
mux.HandleFunc("POST /v1/accounts/revoke-sessions", s.authenticated(s.handleRevokeSessions))
@ -393,7 +395,7 @@ func (s *Server) handleSetLoginEmail(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleSetAccountAvatar(w http.ResponseWriter, r *http.Request) {
var req admin.SetAccountAvatarRequest
if !s.decodeAvatarUpload(w, r, &req.FileName, &req.Data) {
if !s.decodeAvatarUpload(w, r, &req.FileName, &req.Data, admin.MaxAccountAvatarBytes) {
return
}
if !decodeMultipartMetadata(w, r, &req) {
@ -403,6 +405,18 @@ func (s *Server) handleSetAccountAvatar(w http.ResponseWriter, r *http.Request)
writeCommandResult(w, result, err)
}
func (s *Server) handleSetAccountAvatarVideo(w http.ResponseWriter, r *http.Request) {
var req admin.SetAccountAvatarVideoRequest
if !s.decodeAvatarUpload(w, r, &req.FileName, &req.Data, admin.MaxAccountAvatarVideoBytes) {
return
}
if !decodeMultipartMetadata(w, r, &req) {
return
}
result, err := s.svc.SetAccountAvatarVideo(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleChannelAvatar(w http.ResponseWriter, r *http.Request) {
channelID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || channelID <= 0 {
@ -427,7 +441,7 @@ func (s *Server) handleChannelAvatar(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleSetChannelAvatar(w http.ResponseWriter, r *http.Request) {
var req admin.SetChannelAvatarRequest
if !s.decodeAvatarUpload(w, r, &req.FileName, &req.Data) {
if !s.decodeAvatarUpload(w, r, &req.FileName, &req.Data, admin.MaxAccountAvatarBytes) {
return
}
if !decodeMultipartMetadata(w, r, &req) {
@ -439,8 +453,10 @@ func (s *Server) handleSetChannelAvatar(w http.ResponseWriter, r *http.Request)
// decodeAvatarUpload parses a multipart avatar-upload form shared by the
// account and channel avatar endpoints, reading the uploaded file into data.
func (s *Server) decodeAvatarUpload(w http.ResponseWriter, r *http.Request, fileName *string, data *[]byte) bool {
r.Body = http.MaxBytesReader(w, r.Body, admin.MaxAccountAvatarBytes+(1<<20))
// maxBytes is the caller's own ceiling (MaxAccountAvatarBytes for a static
// image, the larger MaxAccountAvatarVideoBytes for a video avatar).
func (s *Server) decodeAvatarUpload(w http.ResponseWriter, r *http.Request, fileName *string, data *[]byte, maxBytes int64) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes+(1<<20))
if err := r.ParseMultipartForm(1 << 20); err != nil {
writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
return false
@ -454,8 +470,8 @@ func (s *Server) decodeAvatarUpload(w http.ResponseWriter, r *http.Request, file
return false
}
defer file.Close()
raw, err := io.ReadAll(io.LimitReader(file, admin.MaxAccountAvatarBytes+1))
if err != nil || len(raw) == 0 || int64(len(raw)) > admin.MaxAccountAvatarBytes {
raw, err := io.ReadAll(io.LimitReader(file, maxBytes+1))
if err != nil || len(raw) == 0 || int64(len(raw)) > maxBytes {
writeError(w, http.StatusBadRequest, "avatar file is empty or too large")
return false
}

View file

@ -364,6 +364,10 @@ func (fakeService) SetAccountAvatar(_ context.Context, req admin.SetAccountAvata
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetAccountAvatarVideo(_ context.Context, req admin.SetAccountAvatarVideoRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) ChannelAvatar(_ context.Context, _ int64) ([]byte, string, bool, error) {
return nil, "", false, nil
}

View file

@ -96,7 +96,7 @@ func (s *Service) DeleteAccount(ctx context.Context, userID int64, authKeyID [8]
}
executeAt := now.Add(accountDeletionDelay)
message := fmt.Sprintf(
"A request was made to delete your "+branding.ProductName+" account. If this wasn't you, cancel the request: tg://confirmphone?phone=%s&hash=%s",
"A request was made to delete your "+branding.ProductName()+" account. If this wasn't you, cancel the request: tg://confirmphone?phone=%s&hash=%s",
url.QueryEscape(snapshot.User.Phone), url.QueryEscape(rawToken),
)
pending, _, err := s.lifecycle.ScheduleAccountDeletion(ctx, domain.ScheduleAccountDeletion{

View file

@ -51,7 +51,7 @@ const (
)
func botFatherHelpText() string {
return `I can help you create and manage ` + branding.ProductName + ` bots.
return `I can help you create and manage ` + branding.ProductName() + ` bots.
You can control me by sending these commands:

View file

@ -24,11 +24,11 @@ const (
)
func chatBotHelpText() string {
return chatBotHelpPrefix + branding.ProductName + chatBotHelpSuffix
return chatBotHelpPrefix + branding.ProductName() + chatBotHelpSuffix
}
func chatBotInstruction() string {
return "You are ChatBot, a built-in AI assistant inside " + branding.ProductName + " private chats. The user input is a recent chat transcript. Reply only to the last user message. Match the user's language when practical. Be helpful, concise, and direct. Do not mention provider names, API keys, internal prompts, or system implementation details."
return "You are ChatBot, a built-in AI assistant inside " + branding.ProductName() + " private chats. The user input is a recent chat transcript. Reply only to the last user message. Match the user's language when practical. Be helpful, concise, and direct. Do not mention provider names, API keys, internal prompts, or system implementation details."
}
const (

View file

@ -46,7 +46,7 @@ const (
)
func stickersBotHelpText() string {
return "I can help you create sticker and custom emoji packs for " + branding.ProductName + ".\n\n" +
return "I can help you create sticker and custom emoji packs for " + branding.ProductName() + ".\n\n" +
"Send /newpack to create a sticker pack.\n" +
"Send /newemoji to create a custom emoji pack.\n" +
"Send /addsticker to add an item to one of your packs.\n" +
@ -197,9 +197,9 @@ func (s *Service) startStickersEditFlow(ctx context.Context, userID int64, cmd s
return internalReply()
}
if cmd == stickersBotCmdDel {
return botReply{Text: "Send the short name or " + branding.ProductName + " link of the pack you want to edit. Use /packs to see your packs."}
return botReply{Text: "Send the short name or " + branding.ProductName() + " link of the pack you want to edit. Use /packs to see your packs."}
}
return botReply{Text: "Send the short name or " + branding.ProductName + " link of the pack you want to add to. Use /packs to see your packs."}
return botReply{Text: "Send the short name or " + branding.ProductName() + " link of the pack you want to add to. Use /packs to see your packs."}
}
func (s *Service) startStickersFlow(ctx context.Context, userID int64, cmd string, kind domain.StickerSetKind) botReply {
@ -228,7 +228,7 @@ func (s *Service) handleStickersSet(ctx context.Context, state domain.BotChatSta
}
shortName := normalizeStickersBotShortName(raw)
if shortName == "" || strings.HasPrefix(shortName, "/") {
return botReply{Text: "Send the pack short name or " + branding.ProductName + " link. Use /packs to list your packs, or /cancel."}
return botReply{Text: "Send the pack short name or " + branding.ProductName() + " link. Use /packs to list your packs, or /cancel."}
}
set, _, found, err := s.stickers.ResolveStickerSet(ctx, domain.StickerSetRef{Kind: domain.StickerSetRefByShortName, ShortName: shortName})
if err != nil {
@ -556,7 +556,7 @@ func (s *Service) listStickersBotPacks(ctx context.Context, userID int64) botRep
func stickersBotStepPrompt(state domain.BotChatState) botReply {
switch state.Step {
case stickersBotStepSet:
return botReply{Text: "Send the pack short name or " + branding.ProductName + " link, or /cancel."}
return botReply{Text: "Send the pack short name or " + branding.ProductName() + " link, or /cancel."}
case stickersBotStepTitle:
return botReply{Text: "Send a title for this pack, or /cancel."}
case stickersBotStepDocument:

View file

@ -199,11 +199,11 @@ func verifierBotWhatText() string {
A third-party mark is a verifier's own icon, shown right before the name of a bot, a channel or an account, plus one line of description in its profile. It means "this verifier vouches for this peer" -- nothing more.
It is NOT the official ` + branding.ProductName + ` checkmark. The platform badge is granted by the platform itself (@verifybot collects those applications); a third-party mark is granted by the company running a verifier bot. The two are stored, shown and taken away separately, and neither one implies the other.`
It is NOT the official ` + branding.ProductName() + ` checkmark. The platform badge is granted by the platform itself (@verifybot collects those applications); a third-party mark is granted by the company running a verifier bot. The two are stored, shown and taken away separately, and neither one implies the other.`
}
func verifierBotHelpText() string {
return `I am a verifier bot. I grant third-party marks: my icon before the name of your bot, channel or account, plus a description in its profile. This is not the official ` + branding.ProductName + ` checkmark.
return `I am a verifier bot. I grant third-party marks: my icon before the name of your bot, channel or account, plus a description in its profile. This is not the official ` + branding.ProductName() + ` checkmark.
/start - what a third-party mark is and who grants it
/verify - apply for the mark
@ -1125,7 +1125,7 @@ func verifierSummaryText(state domain.BotChatState, settings domain.BotVerifierS
b.WriteString("\n\nWhy:\n")
b.WriteString(state.Draft[verifierDraftReason])
b.WriteString("\n\nThis is a third-party mark, not the official ")
b.WriteString(branding.ProductName)
b.WriteString(branding.ProductName())
b.WriteString(" checkmark, and I do not decide: an operator reads the application and either grants the mark or refuses it. I will message you here either way.")
return b.String()
}
@ -1174,7 +1174,7 @@ func verifierDecisionText(req domain.CustomVerificationRequest) (string, bool) {
switch req.Status {
case domain.CustomVerificationApproved:
return fmt.Sprintf("Application #%d is approved: %s now carries my mark -- my icon before the name and my description in the profile.\n\nThis is a third-party mark, not the official %s checkmark. Send /revoke if you ever want it removed.",
req.ID, label, branding.ProductName), true
req.ID, label, branding.ProductName()), true
case domain.CustomVerificationRejected:
text := fmt.Sprintf("Application #%d for %s was not approved, so no mark was granted.", req.ID, label)
if reason := strings.TrimSpace(req.DecisionReason); reason != "" {

View file

@ -115,7 +115,7 @@ const (
)
func verifyBotStartText() string {
return `I collect applications for official ` + branding.ProductName + ` verification: the badge shown next to the name of a channel, supergroup or bot whose identity has been confirmed.
return `I collect applications for official ` + branding.ProductName() + ` verification: the badge shown next to the name of a channel, supergroup or bot whose identity has been confirmed.
Before you apply, check that the subject of the application:
- is a channel, supergroup or bot with a public @username;
@ -129,7 +129,7 @@ Tap the button below, or send /new, to start. Send /help for the full list of co
}
func verifyBotHelpText() string {
return `I collect official ` + branding.ProductName + ` verification applications.
return `I collect official ` + branding.ProductName() + ` verification applications.
/new - file a verification application
/status - list your applications and their status

View file

@ -42,7 +42,7 @@ func (s *Service) SeedDefaultVerifier(ctx context.Context) (bool, error) {
if _, err := s.GrantVerifier(ctx, domain.BotVerifierSettings{
BotID: domain.VerifierBotUserID,
IconDocumentID: icon.DocumentID,
CompanyName: branding.ProductName,
CompanyName: branding.ProductName(),
DefaultDescription: "Bundled reference verifier -- auto-granted on first boot.",
CanModifyCustomDescription: false,
Enabled: true,

View file

@ -189,7 +189,9 @@ func (c *blobBytesCache) get(key string) ([]byte, bool) {
if el, ok := c.m[key]; ok {
c.ll.MoveToFront(el)
entry := el.Value.(*blobBytesEntry)
return append([]byte(nil), entry.bytes...), true
// Cache entries are immutable after publication. GetFile returns a
// capacity-clipped read-only view of the requested range.
return entry.bytes, true
}
return nil, false
}

View file

@ -1,6 +1,11 @@
package files
import "sync/atomic"
import (
"io"
"sync/atomic"
"telesrv/internal/domain"
)
// SpaceGuard bounds how much more may be written to the permanent blob
// backend. LocalDiskSpaceGuard checks real OS free disk bytes;
@ -24,6 +29,41 @@ type NoopSpaceGuard struct{}
func (NoopSpaceGuard) Allow(int64) (bool, error) { return true, nil }
func (NoopSpaceGuard) Usage() (int64, int64, bool) { return 0, 0, false }
// requireSpace maps a SpaceGuard rejection to domain.ErrStorageFull. A nil
// guard always allows the write.
func requireSpace(guard SpaceGuard, additional int64) error {
if guard == nil {
return nil
}
ok, err := guard.Allow(additional)
if err != nil {
return err
}
if !ok {
return domain.ErrStorageFull
}
return nil
}
// capacityReader stops a streaming permanent write before the backend can
// publish an object larger than the current capacity snapshot permits.
type capacityReader struct {
src io.Reader
guard SpaceGuard
total int64
}
func (r *capacityReader) Read(p []byte) (int, error) {
n, err := r.src.Read(p)
if n > 0 {
if guardErr := requireSpace(r.guard, r.total+int64(n)); guardErr != nil {
return 0, guardErr
}
r.total += int64(n)
}
return n, err
}
// LocalDiskSpaceGuard rejects writes once cached free disk bytes fall below
// minFreeBytes (<=0 disables the check). The free-bytes figure is
// refreshed by DiskUsageWorker, not recomputed per call, to avoid a statfs

View file

@ -0,0 +1,72 @@
package files
import (
"context"
"io"
"time"
)
// GuardedBlobBackend applies one capacity policy to every permanent write,
// including seeds and non-upload media paths, instead of relying on individual
// RPC handlers to remember a check.
type GuardedBlobBackend struct {
backend BlobBackend
guard SpaceGuard
}
func NewGuardedBlobBackend(backend BlobBackend, guard SpaceGuard) *GuardedBlobBackend {
return &GuardedBlobBackend{backend: backend, guard: guard}
}
func (g *GuardedBlobBackend) Name() string { return g.backend.Name() }
func (g *GuardedBlobBackend) Put(ctx context.Context, data []byte) (string, error) {
if err := requireSpace(g.guard, int64(len(data))); err != nil {
return "", err
}
return g.backend.Put(ctx, data)
}
func (g *GuardedBlobBackend) PutReader(ctx context.Context, r io.Reader) (string, int64, []byte, error) {
return g.backend.PutReader(ctx, &capacityReader{src: r, guard: g.guard})
}
func (g *GuardedBlobBackend) Get(ctx context.Context, key string) ([]byte, error) {
return g.backend.Get(ctx, key)
}
func (g *GuardedBlobBackend) GetRange(ctx context.Context, key string, offset, limit int64) ([]byte, int64, error) {
return g.backend.GetRange(ctx, key, offset, limit)
}
type GuardedUploadPartBackend struct {
backend UploadPartBackend
guard SpaceGuard
}
func NewGuardedUploadPartBackend(backend UploadPartBackend, guard SpaceGuard) *GuardedUploadPartBackend {
return &GuardedUploadPartBackend{backend: backend, guard: guard}
}
func (g *GuardedUploadPartBackend) PutUploadPart(ctx context.Context, ownerUserID, fileID int64, part int, data []byte) (uploadPartObject, error) {
if err := requireSpace(g.guard, int64(len(data))); err != nil {
return uploadPartObject{}, err
}
return g.backend.PutUploadPart(ctx, ownerUserID, fileID, part, data)
}
func (g *GuardedUploadPartBackend) GetUploadPart(ctx context.Context, key string) ([]byte, error) {
return g.backend.GetUploadPart(ctx, key)
}
func (g *GuardedUploadPartBackend) OpenUploadPart(ctx context.Context, key string) (io.ReadCloser, error) {
return g.backend.OpenUploadPart(ctx, key)
}
func (g *GuardedUploadPartBackend) DeleteUploadPart(ctx context.Context, key string) error {
return g.backend.DeleteUploadPart(ctx, key)
}
func (g *GuardedUploadPartBackend) DeleteExpiredUploadParts(ctx context.Context, before time.Time, limit int) (int64, error) {
return g.backend.DeleteExpiredUploadParts(ctx, before, limit)
}

View file

@ -188,11 +188,51 @@ func (s *Service) CreateAvatarVideoMarkupFromUpload(ctx context.Context, file do
return s.createAvatarVideoFromUpload(ctx, file, videoStartTs, []domain.PhotoSize{markup})
}
// CreateAvatarVideoFromBytes stores already-in-hand animated-video bytes as an
// avatar Photo, for callers that skip the chunked upload.saveFilePart
// transfer regular clients use (e.g. the admin console, which already has the
// full file from a browser upload) -- the video counterpart of
// CreateAvatarFromBytes.
func (s *Service) CreateAvatarVideoFromBytes(ctx context.Context, data []byte, ownerUserID int64, videoStartTs float64) (domain.Photo, error) {
if len(data) == 0 {
return domain.Photo{}, domain.ErrPhotoInvalid
}
objectKey, size, sha256sum, err := s.blobs.PutReader(ctx, bytes.NewReader(data))
if err != nil {
return domain.Photo{}, err
}
if size == 0 {
return domain.Photo{}, domain.ErrPhotoInvalid
}
body := assembledUploadBlob{ObjectKey: objectKey, Size: size, SHA256: sha256sum}
return s.createAvatarVideoFromBlob(ctx, body, ownerUserID, videoStartTs, nil)
}
func (s *Service) createAvatarVideoFromUpload(ctx context.Context, file domain.UploadedFileRef, videoStartTs float64, extraSizes []domain.PhotoSize) (domain.Photo, error) {
body, err := s.assembleUploadBlob(ctx, file.OwnerUserID, file.FileID, file.Parts)
if err != nil {
return domain.Photo{}, err
}
photo, err := s.createAvatarVideoFromBlob(ctx, body, file.OwnerUserID, videoStartTs, extraSizes)
if err != nil {
return domain.Photo{}, err
}
if err := s.cleanupUploadParts(ctx, file.OwnerUserID, file.FileID); err != nil {
s.log.Warn("cleanup assembled avatar video upload parts failed",
zap.Int64("owner_user_id", file.OwnerUserID),
zap.Int64("file_id", file.FileID),
zap.Int64("photo_id", photo.ID),
zap.Error(err))
}
return photo, nil
}
// createAvatarVideoFromBlob turns an already-durable video blob (from either
// the chunked-upload assembly path or a direct in-hand byte slice) into an
// avatar Photo. Shared by createAvatarVideoFromUpload and
// CreateAvatarVideoFromBytes so the still-frame extraction and photo/blob
// record construction stay in exactly one place.
func (s *Service) createAvatarVideoFromBlob(ctx context.Context, body assembledUploadBlob, ownerUserID int64, videoStartTs float64, extraSizes []domain.PhotoSize) (domain.Photo, error) {
if body.Size == 0 {
return domain.Photo{}, domain.ErrPhotoInvalid
}
@ -230,18 +270,11 @@ func (s *Service) createAvatarVideoFromUpload(ctx context.Context, file domain.U
Date: int(time.Now().Unix()),
DCID: s.dc,
Sizes: sizes,
OwnerUserID: file.OwnerUserID,
OwnerUserID: ownerUserID,
}
if err := s.media.PutPhoto(ctx, photo); err != nil {
return domain.Photo{}, err
}
if err := s.cleanupUploadParts(ctx, file.OwnerUserID, file.FileID); err != nil {
s.log.Warn("cleanup assembled avatar video upload parts failed",
zap.Int64("owner_user_id", file.OwnerUserID),
zap.Int64("file_id", file.FileID),
zap.Int64("photo_id", photoID),
zap.Error(err))
}
return photo, nil
}

View file

@ -15,6 +15,7 @@ import (
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// fakeMediaStore 是 store.MediaStore 的内存替身,用于在无 PG 时验证 seed 导入器。
@ -29,6 +30,8 @@ type fakeMediaStore struct {
webPages map[int64]domain.MessageWebPage
seedState map[string]string
receipts map[string]domain.UploadedMediaReceipt
// profilePhotos[ownerID|kind] 保存某 owner 当前 profile/fallback 照片引用。
profilePhotos map[string]domain.ProfilePhotoRef
}
func newFakeMediaStore() *fakeMediaStore {
@ -40,9 +43,14 @@ func newFakeMediaStore() *fakeMediaStore {
parts: map[string][]domain.UploadPart{},
seedState: map[string]string{},
receipts: map[string]domain.UploadedMediaReceipt{},
profilePhotos: map[string]domain.ProfilePhotoRef{},
}
}
func fakeProfilePhotoKey(ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) string {
return fmt.Sprintf("%s:%d:%s", ownerType, ownerID, kind)
}
func fakeUploadReceiptKey(ownerUserID, fileID int64) string {
return fmt.Sprintf("%d/%d", ownerUserID, fileID)
}
@ -414,29 +422,101 @@ func (f *fakeMediaStore) CountAvailableReactions(_ context.Context) (int, error)
defer f.mu.Unlock()
return len(f.reactions), nil
}
func (f *fakeMediaStore) AddProfilePhotoKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind, _ int64, _ int) error {
func (f *fakeMediaStore) AddProfilePhotoKind(_ context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoID int64, date int) error {
f.mu.Lock()
defer f.mu.Unlock()
key := fakeProfilePhotoKey(ownerType, ownerID, kind)
existing := f.profilePhotos[key]
ref := domain.ProfilePhotoRef{PhotoID: photoID}
if p, ok := f.photos[photoID]; ok {
ref.DCID = p.DCID
ref.Stripped = domain.StrippedFromSizes(p.Sizes)
ref.HasVideo = domain.PhotoHasVideo(p.Sizes)
}
if existing.PhotoID != photoID {
f.profilePhotos[key] = ref
}
return nil
}
func (f *fakeMediaStore) CurrentProfilePhotoKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind) (int64, bool, error) {
func (f *fakeMediaStore) CurrentProfilePhotoKind(_ context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (int64, bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
ref, ok := f.profilePhotos[fakeProfilePhotoKey(ownerType, ownerID, kind)]
if !ok {
return 0, false, nil
}
return ref.PhotoID, true, nil
}
func (f *fakeMediaStore) CurrentProfilePhotos(_ context.Context, _ domain.PeerType, _ []int64) (map[int64]domain.ProfilePhotoRef, error) {
return map[int64]domain.ProfilePhotoRef{}, nil
func (f *fakeMediaStore) CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ids []int64) (map[int64]domain.ProfilePhotoRef, error) {
return f.CurrentProfilePhotosKind(ctx, ownerType, ids, domain.ProfilePhotoKindProfile)
}
func (f *fakeMediaStore) CurrentProfilePhotosKind(_ context.Context, _ domain.PeerType, _ []int64, _ domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) {
return map[int64]domain.ProfilePhotoRef{}, nil
func (f *fakeMediaStore) CurrentProfilePhotosKind(_ context.Context, ownerType domain.PeerType, ids []int64, kind domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) {
f.mu.Lock()
defer f.mu.Unlock()
out := make(map[int64]domain.ProfilePhotoRef, len(ids))
for _, id := range ids {
if ref, ok := f.profilePhotos[fakeProfilePhotoKey(ownerType, id, kind)]; ok {
out[id] = ref
}
}
return out, nil
}
func (f *fakeMediaStore) ListProfilePhotosKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind, _, _ int, _ int64) ([]int64, int, error) {
return nil, 0, nil
func (f *fakeMediaStore) ListProfilePhotosKind(_ context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, offset, limit int, maxID int64) ([]int64, int, error) {
f.mu.Lock()
defer f.mu.Unlock()
var ids []int64
if ref, ok := f.profilePhotos[fakeProfilePhotoKey(ownerType, ownerID, kind)]; ok {
ids = append(ids, ref.PhotoID)
}
return ids, len(ids), nil
}
func (f *fakeMediaStore) ListProfilePhotoDetailsKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind, _, _ int, _ int64) ([]domain.Photo, int, error) {
return nil, 0, nil
func (f *fakeMediaStore) ListProfilePhotoDetailsKind(_ context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, offset, limit int, maxID int64) ([]domain.Photo, int, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []domain.Photo
if ref, ok := f.profilePhotos[fakeProfilePhotoKey(ownerType, ownerID, kind)]; ok {
if p, ok := f.photos[ref.PhotoID]; ok {
out = append(out, p)
}
}
return out, len(out), nil
}
func (f *fakeMediaStore) DeleteProfilePhotos(_ context.Context, _ domain.PeerType, _ int64, _ []int64) ([]int64, error) {
return nil, nil
func (f *fakeMediaStore) DeleteProfilePhotos(_ context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) ([]int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
var deleted []int64
key := fakeProfilePhotoKey(ownerType, ownerID, domain.ProfilePhotoKindProfile)
if ref, ok := f.profilePhotos[key]; ok {
for _, id := range photoIDs {
if id == ref.PhotoID {
deleted = append(deleted, id)
}
}
}
if len(deleted) > 0 {
delete(f.profilePhotos, key)
}
return deleted, nil
}
func (f *fakeMediaStore) DeleteProfilePhotosKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind, _ []int64) ([]int64, error) {
return nil, nil
func (f *fakeMediaStore) DeleteProfilePhotosKind(_ context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoIDs []int64) ([]int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
var deleted []int64
key := fakeProfilePhotoKey(ownerType, ownerID, kind)
if ref, ok := f.profilePhotos[key]; ok {
for _, id := range photoIDs {
if id == ref.PhotoID {
deleted = append(deleted, id)
}
}
}
if len(deleted) > 0 {
delete(f.profilePhotos, key)
}
return deleted, nil
}
func (f *fakeMediaStore) WithTx(_ context.Context, fn func(ctx context.Context, txMedia store.MediaStore) error) error {
return fn(context.Background(), f)
}
func TestSeedMediaRepairsPartialReactionBlobs(t *testing.T) {

View file

@ -257,6 +257,8 @@ func TestMaxUploadFileBytesUnlimitedByDefault(t *testing.T) {
type countingUploadPartBackend struct {
*LocalFS
getUploadPartCalls int
putUploadPartCalls int
deleteUploadPartCalls int
}
func (c *countingUploadPartBackend) GetUploadPart(ctx context.Context, objectKey string) ([]byte, error) {
@ -264,6 +266,16 @@ func (c *countingUploadPartBackend) GetUploadPart(ctx context.Context, objectKey
return c.LocalFS.GetUploadPart(ctx, objectKey)
}
func (c *countingUploadPartBackend) PutUploadPart(ctx context.Context, ownerUserID, fileID int64, part int, data []byte) (uploadPartObject, error) {
c.putUploadPartCalls++
return c.LocalFS.PutUploadPart(ctx, ownerUserID, fileID, part, data)
}
func (c *countingUploadPartBackend) DeleteUploadPart(ctx context.Context, objectKey string) error {
c.deleteUploadPartCalls++
return c.LocalFS.DeleteUploadPart(ctx, objectKey)
}
func newUploadPartTestService(t *testing.T, media *fakeMediaStore, quota domain.UploadPartQuota) (*Service, *LocalFS) {
t.Helper()
blobs, err := NewLocalFS(t.TempDir())

View file

@ -54,7 +54,7 @@ func newServiceWithCacheLimits(packs store.LangPackStore, maxBytes int64, maxEnt
packs: packs,
packCache: newLangPackCache(maxBytes, maxEntries),
languageCache: newLanguageListCache(languageEntries),
publicBaseURL: branding.DefaultPublicURL,
publicBaseURL: branding.PublicBaseURL(),
}
}

View file

@ -71,7 +71,7 @@ func NewService(creds store.PasskeyStore, challenges store.PasskeyChallengeStore
creds: creds,
challenges: challenges,
rpID: rpID,
rpName: branding.ProductName,
rpName: branding.ProductName(),
dcID: dcID,
challengeTTL: defaultChallengeTTL,
now: time.Now,

View file

@ -18,9 +18,11 @@ import (
// 强制重新拉取 p/g而不是信任本地缓存。用于失效任何账号本地可能缓存的陈旧/错误
// p/g例如账号早年间对接过其它后端、缓存版本号恰好等于当时的 DHConfigVersion
// 此后再也不会刷新——版本号是纯常量,服务端自己永远不会主动使旧缓存过期)。
// 本次从 1→2 是为诊断一例「A 拨 B 接通即断key fingerprint/Ga hash 不合)」而提升,
// 与本次通话 bug 排查同批次的服务端改动一起看。
const DHConfigVersion = 2
// 早先从 1→2 是为诊断一例「A 拨 B 接通即断key fingerprint/Ga hash 不合)」而提升。
// 上游又发现了同类问题的另一诱因:私有 DC 客户端若带着同为某个旧 version、但来自
// 另一配置 profile 的缓存,服务端错误返回 NotModified 会让密聊两端用不同 p/g
// 这里直接取上游更新、更高的版本号,一次性使两类陈旧缓存都失效。
const DHConfigVersion = 20260811
// DHG 是 DH generator。与官方一致取 3TDesktop MTP::IsPrimeAndGood 对
// 「官方 2048-bit prime + g∈{3,4,5,7}」有白名单快速通过路径DrKLO native 同。

View file

@ -487,43 +487,7 @@ func dedupNonZero(ids []int64) []int64 {
}
func Evaluate(rules domain.PrivacyRules, ctx domain.PrivacyContext) bool {
if ctx.OwnerUserID != 0 && ctx.OwnerUserID == ctx.ViewerUserID {
return true
}
if len(rules.Rules) == 0 {
rules.Rules = domain.DefaultPrivacyRules(rules.Key)
}
for _, rule := range rules.Rules {
if explicitDisallowMatches(rule, ctx) {
return false
}
}
for _, rule := range rules.Rules {
if explicitAllowMatches(rule, ctx) {
return true
}
}
for _, rule := range rules.Rules {
switch rule.Kind {
case domain.PrivacyRuleDisallowContacts:
if ctx.ViewerIsContact {
return false
}
case domain.PrivacyRuleAllowContacts:
if ctx.ViewerIsContact {
return true
}
}
}
for _, rule := range rules.Rules {
switch rule.Kind {
case domain.PrivacyRuleDisallowAll:
return false
case domain.PrivacyRuleAllowAll:
return true
}
}
return false
return domain.EvaluatePrivacy(rules, ctx)
}
func ValidKey(key domain.PrivacyKey) bool {
@ -588,52 +552,6 @@ func validateRules(rules []domain.PrivacyRule) error {
return nil
}
func explicitDisallowMatches(rule domain.PrivacyRule, ctx domain.PrivacyContext) bool {
switch rule.Kind {
case domain.PrivacyRuleDisallowUsers:
return slices.Contains(rule.UserIDs, ctx.ViewerUserID)
case domain.PrivacyRuleDisallowChatParticipants:
return intersects(rule.ChatIDs, ctx.SharedChatIDs)
case domain.PrivacyRuleDisallowBots:
return ctx.ViewerIsBot
default:
return false
}
}
func explicitAllowMatches(rule domain.PrivacyRule, ctx domain.PrivacyContext) bool {
switch rule.Kind {
case domain.PrivacyRuleAllowUsers:
return slices.Contains(rule.UserIDs, ctx.ViewerUserID)
case domain.PrivacyRuleAllowChatParticipants:
return intersects(rule.ChatIDs, ctx.SharedChatIDs)
case domain.PrivacyRuleAllowCloseFriends:
return ctx.ViewerCloseFriend
case domain.PrivacyRuleAllowPremium:
return ctx.ViewerIsPremium
case domain.PrivacyRuleAllowBots:
return ctx.ViewerIsBot
default:
return false
}
}
func intersects(a, b []int64) bool {
if len(a) == 0 || len(b) == 0 {
return false
}
set := make(map[int64]struct{}, len(a))
for _, id := range a {
set[id] = struct{}{}
}
for _, id := range b {
if _, ok := set[id]; ok {
return true
}
}
return false
}
func defaultRules(ownerUserID int64, key domain.PrivacyKey) domain.PrivacyRules {
return domain.PrivacyRules{
OwnerUserID: ownerUserID,

View file

@ -193,6 +193,12 @@ func (s *Service) PrivacyBaseUsers(ctx context.Context, userIDs []int64) ([]doma
return s.loadBaseUsersByIDs(ctx, userIDs)
}
// BaseUsersByIDs returns viewer-independent identities for bounded write/read
// validation paths without constructing full viewer projections.
func (s *Service) BaseUsersByIDs(ctx context.Context, userIDs []int64) ([]domain.User, error) {
return s.loadBaseUsersByIDs(ctx, userIDs)
}
// ByIDs 批量返回指定用户。调用方必须已登录;缺失用户不会出现在结果中。
func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error) {
if currentUserID == 0 {

View file

@ -6,44 +6,133 @@
package branding
import (
"fmt"
"net/url"
"regexp"
"strings"
"sync/atomic"
"unicode"
"telesrv/internal/links"
)
const (
ProductName = "OwpenGram"
ProductUsername = "owpengram"
DesktopAppName = "OwpenGram Desktop"
AndroidAppName = "OwpenGram Android"
IOSAppName = "OwpenGram iOS"
MacOSAppName = "OwpenGram macOS"
WebAAppName = "OwpenGram Web A"
WebKAppName = "OwpenGram Web K"
PremiumName = "OwpenGram Premium"
StarsName = "OwpenGram Stars"
DefaultPublicURL = "https://owpengram.org"
// Config is the deployment-wide, user-visible product identity. It is loaded
// once during process startup; protocol identifiers and client detection
// tokens deliberately remain outside this structure.
type Config struct {
ProductName string
ProductUsername string
DesktopAppName string
AndroidAppName string
IOSAppName string
MacOSAppName string
WebAAppName string
WebKAppName string
PremiumName string
StarsName string
PublicBaseURL string
}
var (
defaultConfig = Config{
ProductName: "OwpenGram",
ProductUsername: "owpengram",
DesktopAppName: "OwpenGram Desktop",
AndroidAppName: "OwpenGram Android",
IOSAppName: "OwpenGram iOS",
MacOSAppName: "OwpenGram macOS",
WebAAppName: "OwpenGram Web A",
WebKAppName: "OwpenGram Web K",
PremiumName: "OwpenGram Premium",
StarsName: "OwpenGram Stars",
PublicBaseURL: links.DefaultDownloadURL,
}
configured atomic.Pointer[Config]
)
// DefaultConfig returns a copy of the default product identity.
func DefaultConfig() Config { return defaultConfig }
// Validate normalizes and validates a product identity without installing it.
func Validate(cfg Config) (Config, error) {
for _, field := range []struct {
name string
value *string
}{
{name: "product name", value: &cfg.ProductName},
{name: "desktop app name", value: &cfg.DesktopAppName},
{name: "Android app name", value: &cfg.AndroidAppName},
{name: "iOS app name", value: &cfg.IOSAppName},
{name: "macOS app name", value: &cfg.MacOSAppName},
{name: "Web A app name", value: &cfg.WebAAppName},
{name: "Web K app name", value: &cfg.WebKAppName},
{name: "Premium name", value: &cfg.PremiumName},
{name: "Stars name", value: &cfg.StarsName},
} {
normalized, err := validateDisplayName(*field.value)
if err != nil {
return Config{}, fmt.Errorf("%s: %w", field.name, err)
}
*field.value = normalized
}
cfg.ProductUsername = strings.TrimPrefix(strings.TrimSpace(cfg.ProductUsername), "@")
if !validProductUsername(cfg.ProductUsername) {
return Config{}, fmt.Errorf("product username must be 5-32 ASCII username characters and start with a letter")
}
cfg.ProductUsername = strings.ToLower(cfg.ProductUsername)
var err error
cfg.PublicBaseURL, err = links.ValidateBaseURL(cfg.PublicBaseURL)
if err != nil {
return Config{}, fmt.Errorf("public base URL: %w", err)
}
return cfg, nil
}
// Configure installs the validated process-wide identity before services are
// constructed. Readers only ever observe complete immutable snapshots.
func Configure(cfg Config) error {
normalized, err := Validate(cfg)
if err != nil {
return err
}
configured.Store(&normalized)
return nil
}
// Current returns a copy of the installed product identity.
func Current() Config {
if cfg := configured.Load(); cfg != nil {
return *cfg
}
return defaultConfig
}
func ProductName() string { return Current().ProductName }
func ProductUsername() string { return Current().ProductUsername }
func PremiumName() string { return Current().PremiumName }
func StarsName() string { return Current().StarsName }
func PublicBaseURL() string { return Current().PublicBaseURL }
// ClientAppName returns the branded display name for a stored client platform.
// Stored detection tokens remain unchanged; this is only used at presentation
// boundaries such as account.getAuthorizations.
func ClientAppName(platform string) string {
cfg := Current()
switch strings.ToLower(strings.TrimSpace(platform)) {
case "android":
return AndroidAppName
return cfg.AndroidAppName
case "ios":
return IOSAppName
return cfg.IOSAppName
case "macos":
return MacOSAppName
return cfg.MacOSAppName
case "telegram-tt", "weba":
return WebAAppName
return cfg.WebAAppName
case "tweb", "webk":
return WebKAppName
return cfg.WebKAppName
case "tdesktop", "desktop", "windows":
return DesktopAppName
return cfg.DesktopAppName
default:
return ProductName
return cfg.ProductName
}
}
@ -78,18 +167,49 @@ func UserVisibleText(value, publicBaseURL string) string {
if technicalIDRE.MatchString(value) {
return value
}
return officialBrandRE.ReplaceAllString(value, ProductName)
return officialBrandRE.ReplaceAllString(value, ProductName())
}
func publicDestination(raw string) (string, string) {
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
if raw == "" {
raw = DefaultPublicURL
raw = PublicBaseURL()
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme == "" || parsed.Hostname() == "" {
raw = DefaultPublicURL
raw = PublicBaseURL()
parsed, _ = url.Parse(raw)
}
return raw, parsed.Host
}
func validateDisplayName(raw string) (string, error) {
name := strings.TrimSpace(raw)
if name == "" {
return "", fmt.Errorf("must not be empty")
}
if len([]rune(name)) > 64 {
return "", fmt.Errorf("must not exceed 64 characters")
}
for _, r := range name {
if unicode.IsControl(r) {
return "", fmt.Errorf("must not contain control characters")
}
}
return name, nil
}
func validProductUsername(username string) bool {
if len(username) < 5 || len(username) > 32 {
return false
}
for i, r := range username {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z':
case i > 0 && (r >= '0' && r <= '9' || r == '_'):
default:
return false
}
}
return true
}

View file

@ -46,13 +46,14 @@ func TestUserVisibleTextRebrandsLocalizedProductNames(t *testing.T) {
}
func TestClientPresentationNames(t *testing.T) {
cfg := Current()
for platform, want := range map[string]string{
"tdesktop": DesktopAppName,
"android": AndroidAppName,
"ios": IOSAppName,
"macos": MacOSAppName,
"telegram-tt": WebAAppName,
"tweb": WebKAppName,
"tdesktop": cfg.DesktopAppName,
"android": cfg.AndroidAppName,
"ios": cfg.IOSAppName,
"macos": cfg.MacOSAppName,
"telegram-tt": cfg.WebAAppName,
"tweb": cfg.WebKAppName,
} {
if got := ClientAppName(platform); got != want {
t.Fatalf("ClientAppName(%q) = %q, want %q", platform, got, want)
@ -62,3 +63,55 @@ func TestClientPresentationNames(t *testing.T) {
t.Fatalf("UserVisibleClientPlatform() = %q, want weba", got)
}
}
func TestConfigureInstallsCompleteBrandSnapshot(t *testing.T) {
previous := Current()
t.Cleanup(func() {
if err := Configure(previous); err != nil {
t.Fatalf("restore branding: %v", err)
}
})
cfg := Config{
ProductName: "Example Chat",
ProductUsername: "@Example_Chat",
DesktopAppName: "Example Workstation",
AndroidAppName: "Example Droid",
IOSAppName: "Example Phone",
MacOSAppName: "Example Mac",
WebAAppName: "Example Web Alpha",
WebKAppName: "Example Web Kappa",
PremiumName: "Example Plus",
StarsName: "Example Credits",
PublicBaseURL: "https://links.example.test/root/",
}
if err := Configure(cfg); err != nil {
t.Fatalf("Configure: %v", err)
}
if got := Current(); got.ProductUsername != "example_chat" || got.PublicBaseURL != "https://links.example.test/root" {
t.Fatalf("Current() = %+v", got)
}
if got := ClientAppName("android"); got != "Example Droid" {
t.Fatalf("ClientAppName(android) = %q", got)
}
if got := UserVisibleText("Telegram at t.me/example", ""); got != "Example Chat at links.example.test/example" {
t.Fatalf("UserVisibleText() = %q", got)
}
}
func TestValidateRejectsIncompleteOrUnsafeBranding(t *testing.T) {
for name, mutate := range map[string]func(*Config){
"blank product": func(cfg *Config) { cfg.ProductName = " " },
"control": func(cfg *Config) { cfg.StarsName = "bad\nname" },
"username": func(cfg *Config) { cfg.ProductUsername = "3bad" },
"public URL": func(cfg *Config) { cfg.PublicBaseURL = "file:///tmp/brand" },
} {
t.Run(name, func(t *testing.T) {
cfg := DefaultConfig()
mutate(&cfg)
if _, err := Validate(cfg); err == nil {
t.Fatal("Validate accepted invalid branding")
}
})
}
}

View file

@ -349,7 +349,7 @@ func StickerSet(req *tg.MessagesGetStickerSetRequest) tg.MessagesStickerSetClass
if req != nil && req.Hash == emptyStickerSetHash {
return &tg.MessagesStickerSetNotModified{}
}
title, shortName := branding.ProductName+" Empty Sticker Set", "owpengram_empty"
title, shortName := branding.ProductName()+" Empty Sticker Set", "owpengram_empty"
if req != nil {
switch set := req.Stickerset.(type) {
case *tg.InputStickerSetAnimatedEmoji:

View file

@ -994,7 +994,7 @@ func Load() (Config, error) {
SMTPUsername: envOr("TELESRV_SMTP_USERNAME", ""),
SMTPPassword: envOr("TELESRV_SMTP_PASSWORD", ""),
SMTPFrom: envOr("TELESRV_SMTP_FROM", ""),
SMTPFromName: envOr("TELESRV_SMTP_FROM_NAME", branding.ProductName),
SMTPFromName: envOr("TELESRV_SMTP_FROM_NAME", branding.ProductName()),
SMTPTLSMode: strings.ToLower(strings.TrimSpace(envOr("TELESRV_SMTP_TLS", "starttls"))),
SMTPTimeout: envDurationOr("TELESRV_SMTP_TIMEOUT", 10*time.Second),
LangPackSeedDir: envOr("TELESRV_LANGPACK_SEED_DIR", "data/langpack"),

View file

@ -4,6 +4,9 @@ import (
"errors"
"strings"
"time"
"unicode"
"github.com/nyaruka/phonenumbers"
)
var (
@ -260,38 +263,105 @@ func MaskEmail(email string) string {
return name[:1] + "***" + name[len(name)-1:] + email[at:]
}
// NormalizePhone 仅保留手机号中的数字(与 users.phone 的存储形态一致)。全部被过滤
// 掉时返回原串,便于上层做 validPhone 拒绝。auth/account 两域共用同一规则避免漂移。
//
// Email-signup 合成号码EncodeEmailPhone 生成,"888" 前缀 + 至少一个字母)是唯一例外:
// 原样保留(仅 lower+trim不剥离字母——否则 DecodeEmailPhone 会因编码内容被剥空而
// 永远解不出邮箱。真实手机号恒为纯数字,不含字母,故这个判定不会误伤任何真实号码。
func NormalizePhone(phone string) string {
if IsEmailSignupPhone(phone) {
return strings.ToLower(strings.TrimSpace(phone))
}
// virtualLoginPhoneMinDigits/virtualLoginPhoneMaxDigits bound the "888"-prefixed
// virtual login identity range NormalizePhone accepts without going through
// libphonenumber (real E.164 numbers never start with 888). This is a login
// identity concept only -- distinct from, and independent of, ownership of any
// purchasable collectible-phone asset with the same digit shape.
const (
virtualLoginPhoneMinDigits = 7
virtualLoginPhoneMaxDigits = 15
)
// PhoneDigits removes presentation punctuation from a phone number. It is
// intentionally not an identity canonicalizer: callers that select accounts,
// issue codes, or persist users must use NormalizePhone and ValidPhone.
func PhoneDigits(phone string) string {
var b strings.Builder
b.Grow(len(phone))
seenDigit := false
seenPlus := false
for _, r := range phone {
if r >= '0' && r <= '9' {
switch {
case r >= '0' && r <= '9':
b.WriteRune(r)
seenDigit = true
case r == '+':
if seenPlus || seenDigit {
return ""
}
seenPlus = true
case unicode.IsSpace(r), r == '-', r == '(', r == ')', r == '.', r == '/':
// Presentation separators accepted by official clients and contact UIs.
default:
return ""
}
if b.Len() == 0 {
return phone
}
return b.String()
}
// ValidPhone 校验 NormalizePhone 后的持久化形态:真实手机号是 5-200 位纯数字;
// email-signup 合成号码额外允许小写字母EncodeEmailPhone 的转义字符集)。
// 上限与 users.phone 列宽一致;当前开发登录/改号链路不强制精确 E.164 长度,
// 但拒绝空串、非法字符和会截断的超长输入。
// NormalizePhone returns the one persisted login identity. Virtual +888
// identities are independent of the collectible-phone registry and accept
// 7-15 canonical digits. Ordinary international numbers use E.164 digits
// without the leading '+'. Their parsing is deliberately country-aware so a
// national trunk prefix is removed only where the numbering plan says it is a
// prefix. For example, both +98 0998 167 9461 and +98 998 167 9461 become
// 989981679461, while Italy's significant leading zero in +39 02 ... is retained.
//
// Email-signup synthetic numbers (EncodeEmailPhone, "888" prefix plus at least
// one letter) are a separate exception, kept as-is (lower+trim only, no digit
// stripping) -- otherwise DecodeEmailPhone could never recover the email from
// an already letter-stripped value. A real phone is always pure digits, so
// this check never misclassifies one.
//
// IsPossibleNumber is the structural gate rather than IsValidNumber. It keeps
// syntactically possible reserved/test ranges usable without accepting local
// numbers that omit their country calling code or numbers outside E.164's
// length/plan metadata.
func NormalizePhone(phone string) string {
if IsEmailSignupPhone(phone) {
return strings.ToLower(strings.TrimSpace(phone))
}
digits := PhoneDigits(phone)
if digits == "" {
return ""
}
// Every syntactically valid +888 virtual number is an independent login
// identity; minting or owning the same collectible-phone value is not a
// prerequisite. users.phone therefore takes lookup precedence over any
// optional collectible alias registry.
if len(digits) >= virtualLoginPhoneMinDigits &&
len(digits) <= virtualLoginPhoneMaxDigits &&
strings.HasPrefix(digits, "888") {
return digits
}
// 42777 is the reserved, non-login phone of the built-in service identity.
// It predates the ordinary E.164 user invariant and remains resolvable only
// so auth can reject it as a system account instead of treating it as free.
if digits == OfficialSystemPhone {
return digits
}
number, err := phonenumbers.Parse("+"+digits, phonenumbers.UNKNOWN_REGION)
if err != nil || !phonenumbers.IsPossibleNumber(number) {
return ""
}
canonical := strings.TrimPrefix(phonenumbers.Format(number, phonenumbers.E164), "+")
if canonical == "" || len(canonical) > 15 {
return ""
}
return canonical
}
// ValidPhone reports whether phone is already in the persisted canonical form.
// Callers accepting user input normalize first, then validate, so equivalent
// international spellings converge before lookup, rate limiting, OTP delivery,
// and uniqueness checks. Email-signup synthetic numbers keep their own
// lower+trim canonical form (see NormalizePhone).
func ValidPhone(phone string) bool {
if IsEmailSignupPhone(phone) {
if len(phone) < 5 || len(phone) > 200 {
return false
}
if IsEmailSignupPhone(phone) {
for _, r := range phone {
if !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') {
return false
@ -299,10 +369,6 @@ func ValidPhone(phone string) bool {
}
return true
}
for _, r := range phone {
if r < '0' || r > '9' {
return false
}
}
return true
canonical := NormalizePhone(phone)
return canonical != "" && canonical == phone
}

View file

@ -43,4 +43,7 @@ type AuthKeyClientInfo struct {
SystemVersion string
APIID int
AppVersion string
// IP 是最近一次会话建立的客户端对端地址host-only。只做 metadata 级别的
// 合并刷新,绝不当作登录/绑定的身份证据,也不会触碰 created_at。
IP string
}

View file

@ -104,8 +104,13 @@ func TestNewEmailSignupDisplayPhoneHonorsConfiguredPrefix(t *testing.T) {
if !strings.HasPrefix(phone, prefix) {
t.Fatalf("phone %q missing configured prefix %q", phone, prefix)
}
if !ValidPhone(phone) {
t.Fatalf("phone %q fails ValidPhone", phone)
// A display phone is cosmetic only (see assignEmailSignupDisplayPhone --
// it never goes through ValidPhone in production, only a uniqueness
// check): random digits after a real prefix essentially never form a
// libphonenumber-possible number, so the invariant worth checking here
// is "still a plain digit string", not full E.164 validity.
if PhoneDigits(phone) != phone {
t.Fatalf("phone %q is not a plain digit string", phone)
}
}
}

View file

@ -41,7 +41,7 @@ func TestRenderWelcomeMessageTemplateSubstitutesServerName(t *testing.T) {
got := RenderWelcomeMessageTemplate("Hello from {{server_name}}!")
if got != "Hello from OwpenGram!" {
t.Fatalf("expected default branding.ProductName substitution, got %q", got)
t.Fatalf("expected default branding.ProductName() substitution, got %q", got)
}
SetOfficialSystemUserDisplayName("Custom Server")

View file

@ -49,7 +49,8 @@ func (c MediaCategoryCounts) CountAny(categories []MediaCategory) int {
// MediaSearchRequest 是共享媒体标签页分页查询的入参messages.search 媒体过滤分支)。
// Categories 是该标签页映射到的基础类别并集PhotoVideo→[Photo,Video]、RoundVoice→[Voice,RoundVideo])。
// 分页对齐历史语义OffsetID 为游标(返回 id 严格小于它、AddOffset 为额外偏移、MaxID/MinID 为闭区间。
// OffsetID 定位第一条严格更旧的消息;负 AddOffset 向更新侧取数(可含游标本身)。
// MaxID/MinID、MaxDate/MinDate 均为开区间;计数忽略分页偏移。
type MediaSearchRequest struct {
Categories []MediaCategory
Query string

View file

@ -35,7 +35,7 @@ const (
// MaxMessageReplyQuoteLength matches TDesktop's quote_length_max app config default.
MaxMessageReplyQuoteLength = 1024
// MaxMessageReplyQuoteOffset bounds quote_offset, which is an offset inside message text, not a message id.
MaxMessageReplyQuoteOffset = MaxMessageTextLength
MaxMessageReplyQuoteOffset = 2 * MaxMessageTextLength // UTF-16 units, including surrogate pairs.
// MaxMessageEntityCount limits styled text entity vectors in message text and quotes.
MaxMessageEntityCount = 256
// MaxMessageBoxID 是 TL int / PostgreSQL int4 可安全表达的最大 message id。
@ -81,7 +81,7 @@ func ValidateMessageReplyBounds(reply *MessageReply) error {
return ErrReplyMessageIDInvalid
}
// story 回复StoryID>0不携带 MessageID/TopMessageID普通回复至少有其一。
if reply.MessageID == 0 && reply.TopMessageID == 0 && reply.StoryID == 0 {
if reply.MessageID == 0 && reply.TopMessageID == 0 && reply.StoryID == 0 && reply.External == nil {
return ErrReplyMessageIDInvalid
}
if reply.QuoteOffset < 0 || reply.QuoteOffset > MaxMessageReplyQuoteOffset {
@ -250,6 +250,9 @@ type MessageReply struct {
QuoteText string
QuoteEntities []MessageEntity
QuoteOffset int
// External is an immutable source snapshot resolved by the owning store.
// It is not client input and does not authorize a source message lookup.
External *MessageReplyExternal `json:",omitempty"`
// StoryID > 0 表示这是一条对 story 的回复评论MessageID 为 0Peer 为 story 作者,
// 投影为 messageReplyStoryHeader 而非普通 messageReplyHeader。
StoryID int
@ -287,6 +290,11 @@ type MessageFilter struct {
MaxID int
MinID int
Hash int64
// SenderUserID intersects all other predicates; zero means no sender filter.
SenderUserID int64
// CountOnly ignores pagination and returns the exact filtered total without
// loading message payloads or users. History's default limit is separate.
CountOnly bool
// PinnedOnly 仅返回置顶消息messages.search filterPinned 与
// userFull.pinned_msg_id 的查询路径)。
PinnedOnly bool
@ -718,6 +726,8 @@ type PinPrivateMessageRequest struct {
// 不向对端翻转、不生成服务消息。unpin 无此语义,恒双侧清除。
PmOneside bool
Silent bool
// RecipientBlocked applies the normal private-service delivery policy.
RecipientBlocked bool
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64

View file

@ -26,6 +26,7 @@ var (
// undisclosed delivery and make a retry impossible to reconcile.
ErrLoginCodeDeliveryCommitAmbiguous = errors.New("login code delivery commit ambiguous")
ErrReplyMessageIDInvalid = errors.New("reply message id invalid")
ErrQuoteTextInvalid = errors.New("quote text invalid")
ErrChatForwardsRestricted = errors.New("chat forwards restricted")
ErrNoForwardsRequestExpired = errors.New("no forwards request expired")
// ErrPinnedSavedDialogsTooMuch 映射 PINNED_TOO_MUCH收藏夹子会话置顶

View file

@ -0,0 +1,193 @@
package domain
import (
"bytes"
"encoding/json"
"fmt"
"io"
"reflect"
"unicode/utf16"
"unicode/utf8"
)
const MaxMessageReplyExternalBytes = 1 << 20
// MessageReplyExternal retains the source at send time, independently of its
// later edit/deletion. It never contains another reply or owner-local jump IDs.
type MessageReplyExternal struct {
From MessageForward `json:"from"`
Text string `json:"text"`
Entities []MessageEntity `json:"entities,omitempty"`
Media *MessageMedia `json:"media,omitempty"`
}
// Apply the same validation when this value is nested in an immutable send
// receipt. Otherwise json.Unmarshal there would discard unknown fields or
// accept an invalid author even though the message-box decoder rejects it.
func (v *MessageReplyExternal) UnmarshalJSON(b []byte) error {
if len(b) > MaxMessageReplyExternalBytes {
return fmt.Errorf("external reply snapshot exceeds size bound")
}
type snapshot MessageReplyExternal
var out snapshot
d := json.NewDecoder(bytes.NewReader(b))
d.DisallowUnknownFields()
if err := d.Decode(&out); err != nil {
return fmt.Errorf("decode external reply: %w", err)
}
if err := d.Decode(new(any)); err != io.EOF {
return fmt.Errorf("external reply trailing JSON")
}
if _, err := EncodeMessageReplyExternal((*MessageReplyExternal)(&out)); err != nil {
return err
}
*v = MessageReplyExternal(out)
return nil
}
func EncodeMessageReplyExternal(v *MessageReplyExternal) ([]byte, error) {
if v == nil {
return []byte("{}"), nil
}
if v.From.Date <= 0 || v.From.From.ID <= 0 || (v.From.From.Type != PeerTypeUser && v.From.From.Type != PeerTypeChannel) || v.From.SavedFrom.ID != 0 || v.From.SavedFromMsgID != 0 || len(v.Entities) > MaxMessageEntityCount || !utf8.ValidString(v.Text) || utf8.RuneCountInString(v.Text) > MaxMessageTextLength {
return nil, fmt.Errorf("invalid external reply snapshot")
}
b, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("encode external reply: %w", err)
}
if len(b) > MaxMessageReplyExternalBytes {
return nil, fmt.Errorf("external reply snapshot exceeds size bound")
}
return b, nil
}
func DecodeMessageReplyExternal(b []byte) (*MessageReplyExternal, error) {
if len(b) == 0 || bytes.Equal(bytes.TrimSpace(b), []byte("{}")) {
return nil, nil
}
if len(b) > MaxMessageReplyExternalBytes {
return nil, fmt.Errorf("external reply snapshot exceeds size bound")
}
var out MessageReplyExternal
if err := out.UnmarshalJSON(b); err != nil {
return nil, err
}
return &out, nil
}
func NewMessageReplyExternal(source Message) (*MessageReplyExternal, error) {
v := &MessageReplyExternal{From: MessageForward{From: source.From, Date: source.Date}, Text: source.Body, Entities: source.Entities, Media: source.Media}
b, err := EncodeMessageReplyExternal(v)
if err != nil {
return nil, err
}
// A codec round trip detaches the source media, including nested slices.
return DecodeMessageReplyExternal(b)
}
func ValidateExternalReplyQuote(reply *MessageReply, text string) error {
if reply == nil {
return nil
}
if len(reply.QuoteText) > MaxMessageReplyQuoteLength || !utf8.ValidString(reply.QuoteText) || len(reply.QuoteEntities) > MaxMessageEntityCount {
return ErrQuoteTextInvalid
}
if reply.QuoteText == "" {
if reply.QuoteOffset != 0 || len(reply.QuoteEntities) != 0 {
return ErrQuoteTextInvalid
}
return nil
}
source, quote := utf16.Encode([]rune(text)), utf16.Encode([]rune(reply.QuoteText))
start := reply.QuoteOffset
if start < 0 || start > len(source) || len(quote) > len(source)-start {
return ErrQuoteTextInvalid
}
for i, r := range quote {
if source[start+i] != r {
return ErrQuoteTextInvalid
}
}
for _, e := range reply.QuoteEntities {
if e.Offset < 0 || e.Length <= 0 || e.Offset > len(quote) || e.Length > len(quote)-e.Offset {
return ErrQuoteTextInvalid
}
}
return nil
}
func CloneMessageReply(in *MessageReply) *MessageReply {
if in == nil {
return nil
}
out := *in
out.QuoteEntities = append([]MessageEntity(nil), in.QuoteEntities...)
if in.External != nil {
x := *in.External
x.Entities = append([]MessageEntity(nil), x.Entities...)
if x.Media != nil {
x.Media = cloneReplyData(reflect.ValueOf(x.Media)).Interface().(*MessageMedia)
}
out.External = &x
}
return &out
}
// Clone only our data model. This is not a protocol codec: it neither interprets
// TL fields nor converts bytes. Copying nested pointer/slice fields keeps media
// added to MessageMedia from silently becoming shared between owner snapshots.
func cloneReplyData(v reflect.Value) reflect.Value {
switch v.Kind() {
case reflect.Pointer:
if v.IsNil() {
return reflect.Zero(v.Type())
}
out := reflect.New(v.Type().Elem())
out.Elem().Set(cloneReplyData(v.Elem()))
return out
case reflect.Slice:
if v.IsNil() {
return reflect.Zero(v.Type())
}
out := reflect.MakeSlice(v.Type(), v.Len(), v.Len())
for i := 0; i < v.Len(); i++ {
out.Index(i).Set(cloneReplyData(v.Index(i)))
}
return out
case reflect.Map:
if v.IsNil() {
return reflect.Zero(v.Type())
}
out := reflect.MakeMapWithSize(v.Type(), v.Len())
iter := v.MapRange()
for iter.Next() {
out.SetMapIndex(iter.Key(), cloneReplyData(iter.Value()))
}
return out
case reflect.Interface:
if v.IsNil() {
return reflect.Zero(v.Type())
}
out := reflect.New(v.Type()).Elem()
out.Set(cloneReplyData(v.Elem()))
return out
case reflect.Struct:
out := reflect.New(v.Type()).Elem()
out.Set(v)
for i := 0; i < v.NumField(); i++ {
if v.Type().Field(i).IsExported() {
out.Field(i).Set(cloneReplyData(v.Field(i)))
}
}
return out
case reflect.Array:
out := reflect.New(v.Type()).Elem()
for i := 0; i < v.Len(); i++ {
out.Index(i).Set(cloneReplyData(v.Index(i)))
}
return out
default:
return v
}
}

View file

@ -0,0 +1,92 @@
package domain
import (
"errors"
"reflect"
"strings"
"testing"
)
func TestExternalReplySnapshotIsolationAndInvalidPayload(t *testing.T) {
source := Message{From: Peer{Type: PeerTypeUser, ID: 42}, Date: 1700000000, Body: "🌕 quote", Media: &MessageMedia{Kind: MessageMediaKindPhoto, Photo: &Photo{ID: 7, FileReference: []byte{1, 2, 3}}}}
x, err := NewMessageReplyExternal(source)
if err != nil {
t.Fatal(err)
}
source.Media.Photo.FileReference[0] = 9
if x.Media.Photo.FileReference[0] != 1 {
t.Fatal("source can mutate persisted snapshot")
}
r := &MessageReply{External: x}
if err := ValidateMessageReplyBounds(r); err != nil {
t.Fatal("snapshot-only recipient header rejected", err)
}
copy := CloneMessageReply(r)
copy.External.Media.Photo.FileReference[0] = 8
if x.Media.Photo.FileReference[0] != 1 {
t.Fatal("owner clone aliases media snapshot")
}
b, err := EncodeMessageReplyExternal(x)
if err != nil {
t.Fatal(err)
}
restored, err := DecodeMessageReplyExternal(b)
if err != nil || !reflect.DeepEqual(restored, x) {
t.Fatalf("snapshot roundtrip: %v", err)
}
for _, bad := range []string{"null", "[]", `{"from":{}}`, string(b) + ` {}`, strings.Replace(string(b), `"text":`, `"unrecognized":1,"text":`, 1), strings.Repeat("x", MaxMessageReplyExternalBytes+1)} {
if _, err := DecodeMessageReplyExternal([]byte(bad)); err == nil {
t.Fatalf("invalid external snapshot accepted: %.50s", bad)
}
}
}
func TestExternalReplyQuoteUsesUTF16AndExactSubstring(t *testing.T) {
for _, tc := range []struct {
text, quote string
offset int
valid bool
}{
{"a🌕 quote", "quote", 4, true}, {"a🌕 quote", "quote", 3, false}, {"a🌕 quote", "🌕", 1, true}, {"a🌕 quote", "🌕", 2, false}, {"source", "invented", 0, false}, {"source", "source", 0, true}, {"source", "", 0, true}, {"source", "", 1, false},
} {
err := ValidateExternalReplyQuote(&MessageReply{QuoteText: tc.quote, QuoteOffset: tc.offset}, tc.text)
if (err == nil) != tc.valid || (err != nil && !errors.Is(err, ErrQuoteTextInvalid)) {
t.Fatalf("%+v: %v", tc, err)
}
}
text := strings.Repeat("🌕", 3000) + "quote"
if err := ValidateExternalReplyQuote(&MessageReply{QuoteText: "quote", QuoteOffset: 6000}, text); err != nil {
t.Fatal(err)
}
if err := ValidateMessageReplyBounds(&MessageReply{MessageID: 1, QuoteOffset: 6000}); err != nil {
t.Fatal(err)
}
}
func TestExternalReplyDocumentNestedSlicesRemainIndependent(t *testing.T) {
source := Message{From: Peer{Type: PeerTypeUser, ID: 42}, Date: 1700000000,
Media: &MessageMedia{Kind: MessageMediaKindDocument, Document: &Document{
ID: 7, FileReference: []byte{1, 2, 3}, MimeType: "audio/ogg",
Attributes: []DocumentAttribute{{Kind: DocAttrAudio, Voice: true, Waveform: []byte{4, 5, 6}}},
}},
}
x, err := NewMessageReplyExternal(source)
if err != nil {
t.Fatal(err)
}
source.Media.Document.FileReference[0] = 9
source.Media.Document.Attributes[0].Waveform[0] = 9
copy := CloneMessageReply(&MessageReply{External: x})
copy.External.Media.Document.Attributes[0].Waveform[1] = 9
if !reflect.DeepEqual(x.Media.Document.FileReference, []byte{1, 2, 3}) || !reflect.DeepEqual(x.Media.Document.Attributes[0].Waveform, []byte{4, 5, 6}) {
t.Fatal("source or another owner mutated the document snapshot")
}
raw, err := EncodeMessageReplyExternal(x)
if err != nil {
t.Fatal(err)
}
got, err := DecodeMessageReplyExternal(raw)
if err != nil || !reflect.DeepEqual(got, x) {
t.Fatalf("document snapshot roundtrip: %+v %v", got, err)
}
}

View file

@ -0,0 +1,59 @@
package domain
import "testing"
func TestNormalizePhoneUsesCountryAwareE164Identity(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{name: "iran redundant national trunk", input: "+98 0998 167 9461", want: "989981679461"},
{name: "iran canonical", input: "+98 998 167 9461", want: "989981679461"},
{name: "iran wire digits redundant trunk", input: "9809981679461", want: "989981679461"},
{name: "italy significant leading zero", input: "+39 02 1234 5678", want: "390212345678"},
{name: "china presentation", input: "+86 (188) 0000-0000", want: "8618800000000"},
{name: "possible reserved NANP range", input: "+1 555 000 0001", want: "15550000001"},
{name: "local number without country", input: "09981679461", want: ""},
{name: "letters are not separators", input: "+98abc9981679461", want: ""},
{name: "international prefix is not country code", input: "00989981679461", want: ""},
{name: "reserved system identity", input: OfficialSystemPhone, want: OfficialSystemPhone},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := NormalizePhone(test.input); got != test.want {
t.Fatalf("NormalizePhone(%q) = %q, want %q", test.input, got, test.want)
}
})
}
}
func TestNormalizePhoneAcceptsVirtual888LoginIdentityRange(t *testing.T) {
for input, want := range map[string]string{
"+888 12-34": "8881234",
"8880000": "8880000",
"888123456789012": "888123456789012",
} {
if got := NormalizePhone(input); got != want {
t.Fatalf("NormalizePhone(%q) = %q, want %q", input, got, want)
}
}
for _, phone := range []string{"888123", "8881234567890123", "+888abc1234"} {
if got := NormalizePhone(phone); got != "" {
t.Fatalf("NormalizePhone(%q) = %q, want empty", phone, got)
}
}
}
func TestValidPhoneRequiresCanonicalStorageShape(t *testing.T) {
for _, phone := range []string{"989981679461", "390212345678", "8618800000000", "15550000001", "8880000", "888123456789012", OfficialSystemPhone} {
if !ValidPhone(phone) {
t.Fatalf("ValidPhone(%q) = false", phone)
}
}
for _, phone := range []string{"+989981679461", "+8881234", "888123", "8881234567890123", "9809981679461", "09981679461", "", "+98abc9981679461"} {
if ValidPhone(phone) {
t.Fatalf("ValidPhone(%q) = true", phone)
}
}
}

View file

@ -0,0 +1,89 @@
package domain
import "slices"
func EvaluatePrivacy(rules PrivacyRules, ctx PrivacyContext) bool {
if ctx.OwnerUserID != 0 && ctx.OwnerUserID == ctx.ViewerUserID {
return true
}
if len(rules.Rules) == 0 {
rules.Rules = DefaultPrivacyRules(rules.Key)
}
for _, rule := range rules.Rules {
if explicitDisallowMatches(rule, ctx) {
return false
}
}
for _, rule := range rules.Rules {
if explicitAllowMatches(rule, ctx) {
return true
}
}
for _, rule := range rules.Rules {
switch rule.Kind {
case PrivacyRuleDisallowContacts:
if ctx.ViewerIsContact {
return false
}
case PrivacyRuleAllowContacts:
if ctx.ViewerIsContact {
return true
}
}
}
for _, rule := range rules.Rules {
switch rule.Kind {
case PrivacyRuleDisallowAll:
return false
case PrivacyRuleAllowAll:
return true
}
}
return false
}
func explicitDisallowMatches(rule PrivacyRule, ctx PrivacyContext) bool {
switch rule.Kind {
case PrivacyRuleDisallowUsers:
return slices.Contains(rule.UserIDs, ctx.ViewerUserID)
case PrivacyRuleDisallowChatParticipants:
return intersects(rule.ChatIDs, ctx.SharedChatIDs)
case PrivacyRuleDisallowBots:
return ctx.ViewerIsBot
default:
return false
}
}
func explicitAllowMatches(rule PrivacyRule, ctx PrivacyContext) bool {
switch rule.Kind {
case PrivacyRuleAllowUsers:
return slices.Contains(rule.UserIDs, ctx.ViewerUserID)
case PrivacyRuleAllowChatParticipants:
return intersects(rule.ChatIDs, ctx.SharedChatIDs)
case PrivacyRuleAllowCloseFriends:
return ctx.ViewerCloseFriend
case PrivacyRuleAllowPremium:
return ctx.ViewerIsPremium
case PrivacyRuleAllowBots:
return ctx.ViewerIsBot
default:
return false
}
}
func intersects(a, b []int64) bool {
if len(a) == 0 || len(b) == 0 {
return false
}
set := make(map[int64]struct{}, len(a))
for _, id := range a {
set[id] = struct{}{}
}
for _, id := range b {
if _, ok := set[id]; ok {
return true
}
}
return false
}

View file

@ -9,6 +9,9 @@ import (
const (
// OfficialSystemUserID 是 Telegram 兼容客户端识别的官方系统账号。
OfficialSystemUserID int64 = 777000
// OfficialSystemPhone is a reserved service identity, not an ordinary E.164
// login number. Auth must recognize and reject it before account lookup.
OfficialSystemPhone = "42777"
// OfficialSystemUserPhotoID/AccessHash 是该账号头像 photo 的固定 id
// 与 files.Service.SeedOfficialSystemAvatar 种子写入的行保持一致,
// 确保跨重启后 OfficialSystemUser() 引用的 photo id 稳定不变。
@ -108,7 +111,7 @@ func SetOfficialSystemUserAvatar(dcID int, stripped []byte) {
}
// officialSystemUserDisplayName overrides OfficialSystemUser's FirstName --
// empty means "use branding.ProductName" (the compile-time default), set
// empty means "use branding.ProductName()" (the compile-time default), set
// once at startup from the operator's Server Settings -> Server identity
// name, if any. Deliberately only the display name, not Username: the
// account's @username is a stable, addressable identifier other things may
@ -118,7 +121,7 @@ var officialSystemUserDisplayName string
// SetOfficialSystemUserDisplayName records the operator's custom server
// name for the official system account (777000), read once at startup from
// Server Settings -> Server identity. Pass "" to fall back to
// branding.ProductName -- the same "unset -> default" contract the avatar
// branding.ProductName() -- the same "unset -> default" contract the avatar
// override above uses.
func SetOfficialSystemUserDisplayName(name string) {
officialSystemUserDisplayName = strings.TrimSpace(name)
@ -126,7 +129,7 @@ func SetOfficialSystemUserDisplayName(name string) {
// officialSystemDisplayName returns the official system account's current
// effective display name: the operator's custom override if set via
// SetOfficialSystemUserDisplayName, else branding.ProductName. Shared by
// SetOfficialSystemUserDisplayName, else branding.ProductName(). Shared by
// OfficialSystemUser (777000's FirstName) and the login-welcome-message
// {{server_name}} placeholder (see login_welcome_template.go) so both stay
// consistent with each other.
@ -134,7 +137,7 @@ func officialSystemDisplayName() string {
if officialSystemUserDisplayName != "" {
return officialSystemUserDisplayName
}
return branding.ProductName
return branding.ProductName()
}
// botFatherPhotoDCID/Stripped 由 files.Service.SeedBotFatherAvatar 在启动时
@ -223,11 +226,11 @@ func OfficialSystemUser() User {
// only the default snapshot; startup reconciliation and the memory backend use
// these helpers so custom deployments do not expose stale "telesrv" text.
func ChatBotDescription() string {
return "Chat with the configured " + branding.ProductName + " AI provider."
return "Chat with the configured " + branding.ProductName() + " AI provider."
}
func StickersBotDescription() string {
return "Create custom sticker and emoji packs for " + branding.ProductName + "."
return "Create custom sticker and emoji packs for " + branding.ProductName() + "."
}
// BotFatherUser 返回内置 BotFather 账号。username 不以 bot 结尾属种子例外(与官方一致)。

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,129 @@
package loadharness
import (
"crypto/sha256"
"testing"
"time"
"github.com/iamxvbaba/td/tg"
)
func TestGroupMediaChannelNudgeUsesOnlyMatchingChannelAndMaximumPTS(t *testing.T) {
matchingLow := &tg.UpdateChannelTooLong{ChannelID: 71}
matchingLow.SetPts(11)
matchingHigh := &tg.UpdateChannelTooLong{ChannelID: 71}
matchingHigh.SetPts(19)
other := &tg.UpdateChannelTooLong{ChannelID: 72}
other.SetPts(100)
pts, ok := groupMediaChannelNudge(&tg.Updates{Updates: []tg.UpdateClass{other, matchingLow, matchingHigh}}, 71)
if !ok || pts != 19 {
t.Fatalf("matching channel nudge = pts:%d ok:%v, want 19/true", pts, ok)
}
withoutPTS := &tg.UpdateChannelTooLong{ChannelID: 71}
if pts, ok = groupMediaChannelNudge(&tg.UpdateShort{Update: withoutPTS}, 71); !ok || pts != 0 {
t.Fatalf("optional-pts channel nudge = pts:%d ok:%v, want 0/true", pts, ok)
}
if pts, ok = groupMediaChannelNudge(&tg.Updates{Updates: []tg.UpdateClass{other}}, 71); ok || pts != 0 {
t.Fatalf("unrelated channel nudge = pts:%d ok:%v, want 0/false", pts, ok)
}
}
func TestGroupMediaChannelLiveUpdateUsesOnlyMatchingChannelAndMaximumPTS(t *testing.T) {
matchingLow := &tg.UpdateNewChannelMessage{
Message: &tg.Message{PeerID: &tg.PeerChannel{ChannelID: 71}}, Pts: 11, PtsCount: 1,
}
matchingHigh := &tg.UpdateNewChannelMessage{
Message: &tg.Message{PeerID: &tg.PeerChannel{ChannelID: 71}}, Pts: 19, PtsCount: 1,
}
other := &tg.UpdateNewChannelMessage{
Message: &tg.Message{PeerID: &tg.PeerChannel{ChannelID: 72}}, Pts: 100, PtsCount: 1,
}
pts, count := groupMediaChannelLiveUpdate(
&tg.Updates{Updates: []tg.UpdateClass{other, matchingLow, matchingHigh}}, 71,
)
if pts != 19 || count != 2 {
t.Fatalf("matching channel live updates = pts:%d count:%d, want 19/2", pts, count)
}
pts, count = groupMediaChannelLiveUpdate(&tg.UpdateShort{Update: other}, 71)
if pts != 0 || count != 0 {
t.Fatalf("unrelated channel live update = pts:%d count:%d, want 0/0", pts, count)
}
}
func TestGroupMediaDifferencePageRequiresMonotonicPTS(t *testing.T) {
message := &tg.Message{Message: "marker"}
pts, final, messages, updates, err := groupMediaDifferencePage(10, &tg.UpdatesChannelDifference{
Pts: 11, Final: false, NewMessages: []tg.MessageClass{message},
OtherUpdates: []tg.UpdateClass{&tg.UpdateChannel{ChannelID: 71}},
})
if err != nil || pts != 11 || final || len(messages) != 1 || len(updates) != 1 {
t.Fatalf("full difference page = pts:%d final:%v messages:%d updates:%d err:%v", pts, final, len(messages), len(updates), err)
}
pts, final, messages, updates, err = groupMediaDifferencePage(11, &tg.UpdatesChannelDifferenceEmpty{Pts: 11, Final: true})
if err != nil || pts != 11 || !final || len(messages) != 0 || len(updates) != 0 {
t.Fatalf("empty difference page = pts:%d final:%v messages:%d updates:%d err:%v", pts, final, len(messages), len(updates), err)
}
dialog := &tg.Dialog{}
dialog.SetPts(15)
pts, final, messages, updates, err = groupMediaDifferencePage(11, &tg.UpdatesChannelDifferenceTooLong{
Final: true, Dialog: dialog, Messages: []tg.MessageClass{message},
})
if err != nil || pts != 15 || !final || len(messages) != 1 || len(updates) != 0 {
t.Fatalf("too-long difference page = pts:%d final:%v messages:%d updates:%d err:%v", pts, final, len(messages), len(updates), err)
}
if _, _, _, _, err := groupMediaDifferencePage(15, &tg.UpdatesChannelDifference{Pts: 15, Final: false}); err == nil {
t.Fatal("non-final difference page without PTS progress was accepted")
}
if _, _, _, _, err := groupMediaDifferencePage(15, &tg.UpdatesChannelDifferenceEmpty{Pts: 14, Final: true}); err == nil {
t.Fatal("difference PTS regression was accepted")
}
if _, _, _, _, err := groupMediaDifferencePage(15, &tg.UpdatesChannelDifferenceTooLong{Final: false, Dialog: dialog}); err == nil {
t.Fatal("non-final channelDifferenceTooLong was accepted")
}
}
func TestGroupMediaDifferenceRequestCoalescesMaximumPTS(t *testing.T) {
client := &groupMediaClient{differenceWake: make(chan struct{}, 1)}
client.requestChannelDifference(12)
client.requestChannelDifference(9)
client.requestChannelDifference(17)
client.requestChannelDifference(0)
if !client.differenceRequested.Load() || !client.differenceForce.Load() || client.differenceTargetPts.Load() != 17 || len(client.differenceWake) != 1 {
t.Fatalf("coalesced request = requested:%v force:%v pts:%d wakes:%d", client.differenceRequested.Load(), client.differenceForce.Load(), client.differenceTargetPts.Load(), len(client.differenceWake))
}
}
func TestGroupMediaFanoutCountsOnlyDifferenceRecoveredMessages(t *testing.T) {
const marker = "telesrv-group-media/run/photo/1"
message := func(id int64) *tg.Message {
value := &tg.Message{Message: marker}
media := &tg.MessageMediaDocument{}
media.SetDocument(&tg.Document{ID: id, AccessHash: id + 100, FileReference: []byte{byte(id)}, Size: 4096})
value.SetMedia(media)
return value
}
tracker := &groupMediaFanoutTracker{
prefix: "telesrv-group-media/run/", members: map[int64]int{101: 0, 102: 1},
expected: make(map[string]time.Time), committed: make(map[string]bool),
observations: make(map[string]map[int64]groupMediaObservation),
}
tracker.begin(marker)
tracker.observeDifference(101, []tg.MessageClass{message(1)}, nil)
tracker.observeDifference(102, nil, []tg.UpdateClass{&tg.UpdateNewChannelMessage{Message: message(2)}})
tracker.finish(marker, true)
report := tracker.report()
if report.Messages != 1 || report.Expected != 2 || report.Observed != 2 || report.Missing != 0 || report.Duplicate != 0 {
t.Fatalf("difference fanout report = %+v", report)
}
canonical := []groupMediaTarget{{Kind: "photo", Marker: marker, Size: 4096, SHA256: sha256.Sum256([]byte("canonical"))}}
for index, userID := range []int64{101, 102} {
targets, err := tracker.targetsForUser(userID, canonical)
if err != nil || len(targets) != 1 || targets[0].SHA256 != canonical[0].SHA256 || targets[0].Location == nil {
t.Fatalf("difference targets for user %d = %+v, %v", userID, targets, err)
}
location, ok := targets[0].Location.(*tg.InputDocumentFileLocation)
if !ok || location.ID != int64(index+1) {
t.Fatalf("difference target for user %d has location %#v, want document %d", userID, targets[0].Location, index+1)
}
}
}

View file

@ -0,0 +1,190 @@
package mtprotoedge
import "sync"
// bulkRPCScheduler limits runnable file handlers before they enter the shared
// inbound worker pool. Waiters remain behind inboundRPCGate, so bulk backend
// latency cannot occupy every worker needed by bootstrap and control RPCs.
type bulkRPCScheduler struct {
mu sync.Mutex
max int
inUse int
waiters []*bulkRPCLease
closed bool
}
type bulkRPCLease struct {
scheduler *bulkRPCScheduler
granted bool
released bool
notified bool
notify func(bool)
}
// bulkRPCAdmission serializes the two bulk prerequisites: a request may enter
// the process-wide handler scheduler only after its logical session owns an ACK
// window credit. This prevents credit-blocked requests from parking all global
// handler slots.
type bulkRPCAdmission struct {
mu sync.Mutex
scheduler *bulkRPCScheduler
lease *bulkRPCLease
released bool
}
func newBulkRPCScheduler(max int) *bulkRPCScheduler {
if max <= 0 {
max = 1
}
return &bulkRPCScheduler{max: max}
}
func (s *bulkRPCScheduler) reserve() *bulkRPCLease {
if s == nil {
return nil
}
lease := &bulkRPCLease{scheduler: s}
s.mu.Lock()
if s.closed {
lease.released = true
} else if s.inUse < s.max {
s.inUse++
lease.granted = true
} else {
s.waiters = append(s.waiters, lease)
}
s.mu.Unlock()
return lease
}
func (l *bulkRPCLease) subscribe(notify func(bool)) {
if l == nil || l.scheduler == nil || notify == nil {
return
}
s := l.scheduler
s.mu.Lock()
l.notify = notify
granted, released := l.granted, l.released
shouldNotify := (granted || released) && !l.notified
if shouldNotify {
l.notified = true
}
s.mu.Unlock()
if !shouldNotify {
return
}
if granted {
notify(true)
} else {
notify(false)
}
}
func (l *bulkRPCLease) release() {
if l == nil || l.scheduler == nil {
return
}
s := l.scheduler
var notifications []func(bool)
s.mu.Lock()
if l.released {
s.mu.Unlock()
return
}
l.released = true
if l.granted {
l.granted = false
s.inUse--
}
for !s.closed && s.inUse < s.max && len(s.waiters) > 0 {
next := s.waiters[0]
s.waiters[0] = nil
s.waiters = s.waiters[1:]
if next == nil || next.released {
continue
}
next.granted = true
s.inUse++
if next.notify != nil && !next.notified {
next.notified = true
notifications = append(notifications, next.notify)
}
}
s.mu.Unlock()
for _, notify := range notifications {
notify(true)
}
}
func newBulkRPCAdmission(scheduler *bulkRPCScheduler) *bulkRPCAdmission {
if scheduler == nil {
return nil
}
return &bulkRPCAdmission{scheduler: scheduler}
}
func (a *bulkRPCAdmission) subscribeAfter(credit *outboundBulkCredit, notify func(bool)) {
if a == nil || a.scheduler == nil || credit == nil || notify == nil {
if notify != nil {
notify(false)
}
return
}
credit.subscribe(func(success bool) {
if !success {
notify(false)
return
}
lease := a.scheduler.reserve()
a.mu.Lock()
if a.released {
a.mu.Unlock()
lease.release()
notify(false)
return
}
a.lease = lease
a.mu.Unlock()
lease.subscribe(notify)
})
}
func (a *bulkRPCAdmission) release() {
if a == nil {
return
}
a.mu.Lock()
if a.released {
a.mu.Unlock()
return
}
a.released = true
lease := a.lease
a.lease = nil
a.mu.Unlock()
lease.release()
}
func (s *bulkRPCScheduler) close() {
if s == nil {
return
}
var notifications []func(bool)
s.mu.Lock()
s.closed = true
for _, lease := range s.waiters {
if lease == nil || lease.released {
continue
}
lease.released = true
if lease.notify != nil && !lease.notified {
lease.notified = true
notifications = append(notifications, lease.notify)
}
}
s.waiters = nil
s.mu.Unlock()
for _, notify := range notifications {
notify(false)
}
}

View file

@ -0,0 +1,92 @@
package mtprotoedge
import (
"testing"
"time"
)
func TestBulkRPCSchedulerKeepsOverflowBehindGate(t *testing.T) {
scheduler := newBulkRPCScheduler(2)
first := scheduler.reserve()
second := scheduler.reserve()
third := scheduler.reserve()
woken := make(chan bool, 1)
third.subscribe(func(success bool) { woken <- success })
select {
case <-woken:
t.Fatal("overflow bulk handler became runnable before a slot was released")
default:
}
first.release()
select {
case success := <-woken:
if !success {
t.Fatal("overflow bulk handler was canceled instead of admitted")
}
case <-time.After(time.Second):
t.Fatal("overflow bulk handler did not become runnable")
}
second.release()
third.release()
}
func TestBulkRPCSchedulerCloseCancelsWaiters(t *testing.T) {
scheduler := newBulkRPCScheduler(1)
active := scheduler.reserve()
waiting := scheduler.reserve()
woken := make(chan bool, 1)
waiting.subscribe(func(success bool) { woken <- success })
scheduler.close()
select {
case success := <-woken:
if success {
t.Fatal("closed bulk scheduler granted a waiting handler")
}
case <-time.After(time.Second):
t.Fatal("closed bulk scheduler did not cancel waiter")
}
active.release()
}
func TestBulkRPCAdmissionDoesNotReserveGlobalSlotBeforeSessionCredit(t *testing.T) {
scheduler := newBulkRPCScheduler(1)
state := newOutboundStateWithLimits(newOutboundTrackedBudget(1<<20), 128, 1<<20)
state.bulkMax = 1
activeCredit := state.reserveBulkCredit()
waitingCredit := state.reserveBulkCredit()
admission := newBulkRPCAdmission(scheduler)
woken := make(chan bool, 1)
admission.subscribeAfter(waitingCredit.credit, func(success bool) { woken <- success })
scheduler.mu.Lock()
inUseBeforeCredit := scheduler.inUse
scheduler.mu.Unlock()
if inUseBeforeCredit != 0 {
t.Fatalf("credit-blocked request reserved %d global slots, want 0", inUseBeforeCredit)
}
select {
case <-woken:
t.Fatal("credit-blocked request became runnable")
default:
}
activeCredit.releaseIfOwned()
select {
case success := <-woken:
if !success {
t.Fatal("request was canceled after session credit became available")
}
case <-time.After(time.Second):
t.Fatal("request did not enter global scheduler after session credit")
}
scheduler.mu.Lock()
inUseAfterCredit := scheduler.inUse
scheduler.mu.Unlock()
if inUseAfterCredit != 1 {
t.Fatalf("admitted request reserved %d global slots, want 1", inUseAfterCredit)
}
admission.release()
waitingCredit.releaseIfOwned()
state.closeBulkWindow()
}

View file

@ -0,0 +1,29 @@
package mtprotoedge
import "testing"
func TestInboundSeenHistoryUsesStableCircularBacking(t *testing.T) {
state := newConnState()
for id := int64(1); id <= maxTrackedClientMsgIDs; id++ {
state.trackInbound(id, int32(id*2+1), true, false, msgStateReceived)
}
if len(state.order) != maxTrackedClientMsgIDs || len(state.seen) != maxTrackedClientMsgIDs {
t.Fatalf("initial seen history = order:%d map:%d", len(state.order), len(state.seen))
}
backing := &state.order[0]
for id := int64(maxTrackedClientMsgIDs + 1); id <= 4*maxTrackedClientMsgIDs; id++ {
state.trackInbound(id, int32(id*2+1), true, false, msgStateReceived)
}
if &state.order[0] != backing {
t.Fatal("full seen history replaced its circular backing")
}
if len(state.order) != maxTrackedClientMsgIDs || len(state.seen) != maxTrackedClientMsgIDs {
t.Fatalf("steady seen history = order:%d map:%d", len(state.order), len(state.seen))
}
if _, ok := state.seenRecord(1); ok {
t.Fatal("seen history retained its oldest ID")
}
if _, ok := state.seenRecord(4 * maxTrackedClientMsgIDs); !ok {
t.Fatal("seen history lost its newest ID")
}
}

View file

@ -0,0 +1,169 @@
package mtprotoedge
import (
"context"
"crypto/rand"
"crypto/rsa"
"fmt"
"net"
"testing"
"time"
"go.uber.org/zap/zaptest"
"github.com/gotd/log/logzap"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/exchange"
"github.com/iamxvbaba/td/session"
"github.com/iamxvbaba/td/telegram"
"github.com/iamxvbaba/td/telegram/dcs"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/transport"
"telesrv/internal/app/account"
"telesrv/internal/app/auth"
"telesrv/internal/app/contacts"
"telesrv/internal/app/dialogs"
"telesrv/internal/app/help"
"telesrv/internal/app/langpack"
"telesrv/internal/app/updates"
"telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/rpc"
"telesrv/internal/store/memory"
)
// TestClientIPPersistsToAuthorization verifies the real connection-to-persistence
// path: the accepted connection's remote IP is carried as neutral transport
// metadata by mtprotoedge, flows through RPC routing, and is persisted into the
// device authorization (authorizations.ip). It runs a full login over a real
// MTProto connection and asserts the stored authorization carries the client's
// loopback IP.
func TestClientIPPersistsToAuthorization(t *testing.T) {
const (
dc = 2
phone = "+8613800138100"
code = "12345"
clientIP = "127.0.0.1"
)
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("gen rsa: %v", err)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
tcpAddr := ln.Addr().(*net.TCPAddr)
if tcpAddr.IP.String() != clientIP {
t.Fatalf("test listener bound to %s, want loopback %s", tcpAddr.IP, clientIP)
}
userStore := memory.NewUserStore()
authzStore := memory.NewAuthorizationStore()
authKeyStore := memory.NewAuthKeyStore()
helpStore := memory.NewHelpStore()
if err := helpStore.UpsertAppConfig(context.Background(), domain.AppConfig{
Client: "tdesktop", Hash: 1_000_000,
JSON: []byte(`{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373"}`),
}); err != nil {
t.Fatalf("seed app config: %v", err)
}
if err := helpStore.UpsertCountries(context.Background(), []domain.Country{
{ISO2: "US", DefaultName: "United States", CountryCodes: []domain.CountryCode{{CountryCode: "1", Prefixes: []string{"1"}}}},
}); err != nil {
t.Fatalf("seed countries: %v", err)
}
langPackStore := memory.NewLangPackStore()
if err := langPackStore.UpsertPack(context.Background(), domain.LangPack{
LangPack: "tdesktop", LangCode: "en", Version: 1,
Strings: []domain.LangPackString{{Key: "lng_language_name", Value: "English"}},
}); err != nil {
t.Fatalf("seed langpack: %v", err)
}
deps := rpc.Deps{
Auth: auth.NewService(userStore, authzStore, memory.NewCodeStore(), authKeyStore, memory.NewTempAuthKeyBindingStore(authKeyStore), code),
Account: account.NewService(memory.NewPasswordStore()),
Help: help.NewService(helpStore, helpStore),
Users: users.NewService(userStore),
Updates: updates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()),
Contacts: contacts.NewService(memory.NewContactStore()),
Dialogs: dialogs.NewService(memory.NewDialogStore()),
LangPack: langpack.NewService(langPackStore),
}
router := rpc.New(rpc.Config{DC: dc, IP: tcpAddr.IP.String(), Port: tcpAddr.Port}, deps, zaptest.NewLogger(t), clock.System)
srv := New(Options{Logger: zaptest.NewLogger(t), DC: dc, RSAKey: rsaKey, AuthKeys: authKeyStore, LayerRPC: router})
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
serveErr := make(chan error, 1)
go func() { serveErr <- srv.Serve(ctx, ln) }()
opts := telegram.Options{
PublicKeys: []exchange.PublicKey{{RSA: &rsaKey.PublicKey}},
Resolver: dcs.Plain(dcs.PlainOptions{Protocol: transport.Intermediate}),
DCList: dcs.List{Options: []tg.DCOption{{ID: dc, IPAddress: tcpAddr.IP.String(), Port: tcpAddr.Port, Static: true}}},
Logger: logzap.New(zaptest.NewLogger(t).Named("client")),
SessionStorage: &session.StorageMemory{},
UpdateHandler: telegram.UpdateHandlerFunc(func(context.Context, tg.UpdatesClass) error { return nil }),
}
client := telegram.NewClient(1, "hash", opts)
var newUserID int64
if err := client.Run(ctx, func(ctx context.Context) error {
raw := tg.NewClient(client)
sent, err := raw.AuthSendCode(ctx, &tg.AuthSendCodeRequest{PhoneNumber: phone, APIID: 1, APIHash: "hash", Settings: tg.CodeSettings{}})
if err != nil {
return err
}
sentCode, ok := sent.(*tg.AuthSentCode)
if !ok {
return fmt.Errorf("sendCode result = %T, want *tg.AuthSentCode", sent)
}
if _, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{PhoneNumber: phone, PhoneCodeHash: sentCode.PhoneCodeHash, PhoneCode: code}); err != nil {
return err
}
signUpRes, err := raw.AuthSignUp(ctx, &tg.AuthSignUpRequest{PhoneNumber: phone, PhoneCodeHash: sentCode.PhoneCodeHash, FirstName: "IP", LastName: "Test"})
if err != nil {
return err
}
authz, ok := signUpRes.(*tg.AuthAuthorization)
if !ok {
return fmt.Errorf("signUp result = %T, want *tg.AuthAuthorization", signUpRes)
}
newUser, ok := authz.User.(*tg.User)
if !ok {
return fmt.Errorf("signUp user = %T, want *tg.User", authz.User)
}
newUserID = newUser.ID
return nil
}); err != nil {
t.Fatalf("client login flow: %v", err)
}
auths, err := authzStore.ListByUser(ctx, newUserID)
if err != nil || len(auths) == 0 {
t.Fatalf("authorizations for user %d = %d (err=%v), want >=1", newUserID, len(auths), err)
}
var gotIP string
for _, a := range auths {
if a.IP != "" {
gotIP = a.IP
break
}
}
if gotIP != clientIP {
t.Fatalf("persisted authorization IP = %q, want %q", gotIP, clientIP)
}
select {
case err := <-serveErr:
t.Fatalf("server stopped unexpectedly: %v", err)
default:
}
}

View file

@ -5,6 +5,7 @@ import (
"context"
"encoding/hex"
"errors"
"net"
"sync"
"sync/atomic"
"time"
@ -81,12 +82,12 @@ type Conn struct {
salt int64
key crypto.AuthKey
outbound chan outboundOp
outboundControl chan outboundOp
outbound chan *outboundOp
outboundControl chan *outboundOp
// Critical RPC results (session/difference convergence) and large bulk
// responses have independent bounded lanes. The actor remains the sole writer.
outboundCritical chan outboundOp
outboundBulk chan outboundOp
outboundCritical chan *outboundOp
outboundBulk chan *outboundOp
outboundStop chan struct{}
outboundDone chan struct{}
outboundClose sync.Once
@ -106,8 +107,12 @@ type Conn struct {
// full. Content-related control frames keep this budget while pending for resend.
outboundControlTrackedBudget *outboundTrackedBudget
outboundControlBudgetOnce sync.Once
outboundCriticalTrackedBudget *outboundTrackedBudget
outboundCriticalBudgetOnce sync.Once
outboundScratchPool *outboundScratchPool
outboundScratchOnce sync.Once
outboundOpPool *outboundOpPool
outboundReplayBodyPool *outboundReplayBodyPool
// outboundState outlives this physical Conn generation. A replacement
// physical connection for the same auth key/session reuses it.
outboundState *outboundState
@ -160,6 +165,9 @@ type Conn struct {
// 单连接只保留并发配额;实际 worker 来自 Server 共享池,避免每连接预留 goroutine。
rpcRootCtx context.Context
rpcMaxInflight int
// remoteAddr 是 MTProto 连接的对端地址(来自 net.Conn.RemoteAddr仅保留 host
// 部分;绑定设备授权时写入 authorizations.ip便于在 admin 面板看到登录 IP。
remoteAddr string
// sentContentMessages is retained only for standalone construction tests.
// Server connections allocate seq_no from logical-session outboundState.
@ -419,3 +427,21 @@ func (c *Conn) ReceivesUpdates() bool { return c.receivesUpdates.Load() }
// SetReceivesUpdates 设置该连接是否接收主动推送的 updates。
// 登录后的主连接在 updates.getState/getDifference 建立同步基线后置为 true。
func (c *Conn) SetReceivesUpdates(v bool) { c.receivesUpdates.Store(v) }
// setRemoteAddrStr 记录 MTProto 连接的对端地址remote 形如 host:port
// 只保留 host 部分(去掉端口),用于绑定设备授权时写入 authorizations.ip。
func (c *Conn) setRemoteAddrStr(remote string) {
if remote == "" {
return
}
host, _, err := net.SplitHostPort(remote)
if err != nil {
// 已经是纯 host例如 unix socket 或 IPv6 无端口形式)。
c.remoteAddr = remote
return
}
c.remoteAddr = host
}
// clientIP 返回连接的对端 IPhost 部分),未设置时为空字符串。
func (c *Conn) clientIP() string { return c.remoteAddr }

View file

@ -22,8 +22,8 @@ func TestPushSkipsConnReboundToOtherUser(t *testing.T) {
c := &Conn{
sessionID: sid,
authKeyID: [8]byte{authKey},
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(userA)

View file

@ -27,6 +27,7 @@ import (
"github.com/iamxvbaba/td/tlprofile"
"telesrv/internal/observability/dbtrace"
"telesrv/internal/postresponse"
"telesrv/internal/rpcresult"
"telesrv/internal/store"
)
@ -40,6 +41,7 @@ type connState struct {
createdFloor int64
seen map[int64]clientMsgRecord // 已处理的 client msg_id用于幂等和 msgs_state_req
order []int64
orderHead int
minSeen int64
maxSeen int64
// maxContentMsgID/maxContentSeqNo 是已接受 content 消息的 msg_id / seq_no 高水位,
@ -116,7 +118,7 @@ var errActivationAuthKeyRejected = errors.New("activation auth key no longer exi
// 直接复用 current.key/current.salt 解密。任何 provisional 在 claim 建立后、发 required
// control 前都会最终回查 AuthKeyStore使外部撤销与 activation 线性化。
// plain 是 serveConn 持有的复用明文缓冲frame 的 slice 仅在下一帧解密前有效。
func (s *Server) handleEncrypted(ctx context.Context, tc transport.Conn, cs *connState, current *Conn, fetchedKey *store.AuthKeyData, b, plain *bin.Buffer) (*Conn, error) {
func (s *Server) handleEncrypted(ctx context.Context, tc transport.Conn, cs *connState, current *Conn, remote string, fetchedKey *store.AuthKeyData, b, plain *bin.Buffer) (*Conn, error) {
var key crypto.AuthKey
var serverSalt int64
var authKeyExpiresAt int
@ -161,6 +163,8 @@ func (s *Server) handleEncrypted(ctx context.Context, tc transport.Conn, cs *con
} else {
current = s.newConn(tc, key, frame.sessionID, serverSalt)
}
// 记录对端 IP供绑定设备授权时写入 authorizations.ipadmin 面板可见)。
current.setRemoteAddrStr(remote)
current.authKeyExpiresAt = authKeyExpiresAt
// Same-session evidence is restored as explicit; auth-key metadata is only
// an inherited default and can be corrected by the next invokeWithLayer.
@ -904,7 +908,7 @@ func (s *Server) publishRPCResult(
priority := rpcResultPriority(method, encoded)
encoded.priority = priority
if metrics, ok := s.metrics.(RPCResultMetrics); ok {
metrics.RPCResultPrepared(method, priority.String(), encoded.uncompressedBytes, len(encoded.body), encoded.compressed)
metrics.RPCResultPrepared(method, priority.String(), encoded.uncompressedBytes, encoded.wireSize(), encoded.compressed)
}
visible := encoded.compressed || priority == outboundPriorityCritical || priority == outboundPriorityBulk
return priority, visible
@ -916,21 +920,26 @@ func (s *Server) publishRPCResult(
// re-execution hidden behind a local capacity error.
retainForReplay := func(encoded *encodedOutboundMessage, admissionErr error) error {
if s == nil || s.rpcResults == nil || c == nil || encoded == nil || reqMsgID == 0 {
if encoded != nil {
encoded.releaseBulkCredit()
}
return errors.New("rpc result receipt ledger is unavailable")
}
priority, visible := prepareEncoded(encoded)
if owner != nil && !owner.HandOff() {
encoded.releaseBulkCredit()
return ErrRPCResultFlightInvalid
}
started := time.Now()
encoded.markReplayable()
encoded.releaseBulkCredit()
// Complete may expose terminal execution only after the old connection
// is irreversibly unable to accept another same-generation request.
c.fenceUndeliveredRPCResult()
s.completeRPCResult(c, reqMsgID, encoded, false)
latency := time.Since(started)
if metrics, ok := s.metrics.(RPCResultMetrics); ok {
metrics.RPCResultDelivered(method, latency, len(encoded.body), admissionErr)
metrics.RPCResultDelivered(method, latency, encoded.wireSize(), admissionErr)
}
resultLogLevel := zap.DebugLevel
if visible {
@ -941,14 +950,15 @@ func (s *Server) publishRPCResult(
zap.String("method", method), zap.Int64("req_msg_id", reqMsgID),
zap.Int64("delivered_req_msg_id", encoded.writtenRequestID()),
zap.String("auth_key_id", c.authKeyHex), zap.Int64("session_id", c.sessionID),
zap.Int("wire_bytes", len(encoded.body)), zap.Bool("gzip", encoded.compressed),
zap.Int("wire_bytes", encoded.wireSize()), zap.Bool("gzip", encoded.compressed),
zap.String("priority", priority.String()), zap.Error(admissionErr))
}
return nil
}
encoded, reserved, retained, err := s.encodeRPCResultReservedWithHandoffContext(
prepareCtx, c, reqMsgID, result, retainForReplay,
methodPriority := rpcMethodPriority(method)
encoded, reserved, retained, err := s.encodeRPCResultReservedWithPriorityAndHandoffContext(
prepareCtx, c, reqMsgID, result, methodPriority, retainForReplay,
)
if retained {
return err
@ -958,11 +968,14 @@ func (s *Server) publishRPCResult(
return err
}
if err != nil {
if provider, ok := result.(interface{ exactRPCBulkCredit() *outboundBulkCredit }); ok {
provider.exactRPCBulkCredit().release()
}
s.log.Warn("Encode RPC result failed; publishing INTERNAL",
zap.String("method", method), zap.Int64("req_msg_id", reqMsgID), zap.Error(err))
afterDelivered = nil
encoded, reserved, retained, err = s.encodeRPCResultReservedWithHandoffContext(
prepareCtx, c, reqMsgID, &mt.RPCError{ErrorCode: 500, ErrorMessage: "INTERNAL"}, retainForReplay,
encoded, reserved, retained, err = s.encodeRPCResultReservedWithPriorityAndHandoffContext(
prepareCtx, c, reqMsgID, &mt.RPCError{ErrorCode: 500, ErrorMessage: "INTERNAL"}, methodPriority, retainForReplay,
)
if retained {
return err
@ -973,6 +986,9 @@ func (s *Server) publishRPCResult(
}
}
if encoded == nil || reserved == nil {
if provider, ok := result.(interface{ exactRPCBulkCredit() *outboundBulkCredit }); ok {
provider.exactRPCBulkCredit().release()
}
c.fenceUndeliveredRPCResult()
return errors.New("rpc result encode completed without tracked retention")
}
@ -981,6 +997,7 @@ func (s *Server) publishRPCResult(
defer reserved.release()
priority, _ := prepareEncoded(encoded)
if owner != nil && !owner.HandOff() {
encoded.releaseBulkCredit()
return ErrRPCResultFlightInvalid
}
@ -989,7 +1006,7 @@ func (s *Server) publishRPCResult(
latency := time.Since(egressStarted)
deliveredReqMsgID := encoded.writtenRequestID()
if metrics, ok := s.metrics.(RPCResultMetrics); ok {
metrics.RPCResultDelivered(method, latency, len(encoded.body), deliveryErr)
metrics.RPCResultDelivered(method, latency, encoded.wireSize(), deliveryErr)
}
if deliveryErr != nil {
encoded.markReplayable()
@ -1000,7 +1017,7 @@ func (s *Server) publishRPCResult(
zap.String("method", method), zap.Int64("req_msg_id", reqMsgID),
zap.Int64("delivered_req_msg_id", deliveredReqMsgID),
zap.String("auth_key_id", c.authKeyHex), zap.Int64("session_id", c.sessionID),
zap.Int("wire_bytes", len(encoded.body)), zap.Bool("gzip", encoded.compressed),
zap.Int("wire_bytes", encoded.wireSize()), zap.Bool("gzip", encoded.compressed),
zap.Error(deliveryErr))
}
return
@ -1012,7 +1029,7 @@ func (s *Server) publishRPCResult(
zap.String("method", method), zap.Int64("req_msg_id", reqMsgID),
zap.Int64("delivered_req_msg_id", deliveredReqMsgID),
zap.String("auth_key_id", c.authKeyHex), zap.Int64("session_id", c.sessionID),
zap.Int("wire_bytes", len(encoded.body)), zap.Bool("gzip", encoded.compressed),
zap.Int("wire_bytes", encoded.wireSize()), zap.Bool("gzip", encoded.compressed),
zap.Duration("egress_latency", latency))
}
}
@ -1026,7 +1043,7 @@ func (s *Server) publishRPCResult(
if checked := s.log.Check(zap.DebugLevel, "RPC result admitted"); checked != nil {
checked.Write(
zap.String("method", method), zap.Int64("req_msg_id", reqMsgID),
zap.Int("wire_bytes", len(encoded.body)), zap.Int("inner_bytes", encoded.uncompressedBytes),
zap.Int("wire_bytes", encoded.wireSize()), zap.Int("inner_bytes", encoded.uncompressedBytes),
zap.Bool("gzip", encoded.compressed), zap.String("priority", priority.String()))
}
return nil
@ -1086,7 +1103,7 @@ func (s *Server) sendReplayedRPCResultWithHook(
c.fenceUndeliveredRPCResult()
return errors.New("nil replayed rpc_result")
}
attempt, reserved, err := c.cloneRPCResultForRequestReserved(encoded, encoded.reqMsgID, false)
attempt, reserved, err := c.cloneRPCResultForRequestReservedContext(ctx, encoded, encoded.reqMsgID, false)
if err != nil {
c.failOutboundBudget(err)
c.fenceUndeliveredRPCResult()
@ -1222,6 +1239,19 @@ func (s *Server) encodeRPCResultReservedWithHandoffContext(
reqMsgID int64,
result bin.Encoder,
handoff rpcResultRetentionHandoff,
) (*encodedOutboundMessage, *outboundBodyReservation, bool, error) {
return s.encodeRPCResultReservedWithPriorityAndHandoffContext(
ctx, c, reqMsgID, result, outboundPriorityNormal, handoff,
)
}
func (s *Server) encodeRPCResultReservedWithPriorityAndHandoffContext(
ctx context.Context,
c *Conn,
reqMsgID int64,
result bin.Encoder,
priority outboundPriority,
handoff rpcResultRetentionHandoff,
) (*encodedOutboundMessage, *outboundBodyReservation, bool, error) {
if ctx == nil {
ctx = context.Background()
@ -1237,7 +1267,10 @@ func (s *Server) encodeRPCResultReservedWithHandoffContext(
if err != nil {
return err
}
budget := c.outboundMessageBudget(encoded.typeID, false)
if priority != outboundPriorityNormal {
encoded.priority = priority
}
budget := c.outboundMessageBudgetForPriority(encoded.typeID, encoded.priority, false)
bytes := len(encoded.body)
if budget.reserve(bytes) {
reserved = &outboundBodyReservation{budget: budget, bytes: bytes}
@ -1286,6 +1319,14 @@ func (s *Server) encodeRPCResultWithoutSlot(ctx context.Context, c *Conn, reqMsg
return nil, fmt.Errorf("bind exact layer rpc result: %w", err)
}
}
var replaySource rpcresult.ReplaySource
if provider, ok := result.(interface{ exactRPCReplaySource() rpcresult.ReplaySource }); ok {
replaySource = provider.exactRPCReplaySource()
}
var bulkCredit *outboundBulkCredit
if provider, ok := result.(interface{ exactRPCBulkCredit() *outboundBulkCredit }); ok {
bulkCredit = provider.exactRPCBulkCredit()
}
// Encode the ordinary exact/no-gzip path directly behind the rpc_result
// prefix. This avoids both the old generated Prepare snapshot and another
// full-body copy merely to prepend the 12-byte envelope.
@ -1303,10 +1344,15 @@ func (s *Server) encodeRPCResultWithoutSlot(ctx context.Context, c *Conn, reqMsg
return nil, fmt.Errorf("%w: body=%d limit=%d", ErrOutboundMessageTooLarge, len(innerBody)+12, maxOutboundBodyBytes)
}
wireInner, compressed, err := encodeAdaptiveRPCResultInner(ctx, nil, innerBody)
wireInner := innerBody
compressed := false
if replaySource == nil {
var err error
wireInner, compressed, err = encodeAdaptiveRPCResultInner(ctx, nil, innerBody)
if err != nil {
return nil, fmt.Errorf("compress rpc result: %w", err)
}
}
if len(wireInner) > maxOutboundBodyBytes-12 {
return nil, fmt.Errorf("%w: body=%d limit=%d", ErrOutboundMessageTooLarge, len(wireInner)+12, maxOutboundBodyBytes)
}
@ -1322,6 +1368,7 @@ func (s *Server) encodeRPCResultWithoutSlot(ctx context.Context, c *Conn, reqMsg
typeID: proto.ResultTypeID, body: body, reqMsgID: reqMsgID,
compressed: compressed, uncompressedBytes: len(innerBody), delivery: newRPCResultDelivery(0),
layer: layerBinding, layerInvariant: layerInvariantResult,
replaySource: replaySource, innerDigest: sha256.Sum256(innerBody), logicalBytes: len(body), bulkCredit: bulkCredit,
}, nil
}
@ -1620,18 +1667,23 @@ func (cs *connState) trackInbound(msgID int64, seqNo int32, content, service boo
cs.maxContentSeqNo = seqNo
}
}
var evicted int64
if len(cs.order) < maxTrackedClientMsgIDs {
cs.order = append(cs.order, msgID)
} else {
evicted = cs.order[cs.orderHead]
cs.order[cs.orderHead] = msgID
cs.orderHead = (cs.orderHead + 1) % len(cs.order)
}
if msgID < cs.minSeen {
cs.minSeen = msgID
}
if msgID > cs.maxSeen {
cs.maxSeen = msgID
}
if len(cs.order) > maxTrackedClientMsgIDs {
oldest := cs.order[0]
cs.order = cs.order[1:]
delete(cs.seen, oldest)
if oldest == cs.minSeen || oldest == cs.maxSeen {
if evicted != 0 {
delete(cs.seen, evicted)
if evicted == cs.minSeen || evicted == cs.maxSeen {
cs.recomputeRange()
}
}

View file

@ -19,8 +19,8 @@ func TestRunFlushDiscardsBatchOnIdentitySwitch(t *testing.T) {
c := &Conn{
sessionID: sessionID,
authKeyID: raw,
outbound: make(chan outboundOp, 4),
outboundControl: make(chan outboundOp, 4),
outbound: make(chan *outboundOp, 4),
outboundControl: make(chan *outboundOp, 4),
outboundStop: make(chan struct{}),
}
sm.Register(c)

View file

@ -2098,7 +2098,7 @@ func TestLayerRPCDependencyGateUsesBusinessOutcome(t *testing.T) {
if dependencies.failed || len(dependencies.waiters) != 1 {
t.Fatalf("dependencies before completion = %+v", dependencies)
}
gate := newLayerRPCExecutionGate(c, dependencies)
gate := newLayerRPCExecutionGate(c, dependencies, nil, nil)
if gate == nil || gate.runnable() {
t.Fatal("dependency gate was runnable before business completion")
}
@ -2123,7 +2123,7 @@ func TestLayerRPCDependencyGateUsesBusinessOutcome(t *testing.T) {
t.Fatal(err)
}
dependencies := s.layerRPCDependencies(c, 304, missing)
gate := newLayerRPCExecutionGate(c, dependencies)
gate := newLayerRPCExecutionGate(c, dependencies, nil, nil)
if !dependencies.failed || gate == nil || !gate.runnable() || gate.success() {
t.Fatalf("missing dependency gate = deps:%+v runnable:%v success:%v", dependencies, gate.runnable(), gate.success())
}

View file

@ -8,6 +8,8 @@ import (
"sync"
"sync/atomic"
"time"
"telesrv/internal/transport"
)
// ErrInboundRPCQueueFull 表示 inbound RPC 已触达单连接或进程级预算。
@ -940,6 +942,11 @@ func (c *Conn) runInboundRPC(task inboundRPC) {
c.metrics.InboundRPCStarted(task.method, now.Sub(task.enqueuedAt))
ctx := task.ctx
// 把连接对端 IP 作为中立传输元数据注入,绑定设备授权时写 authorizations.ip。
// Edge 只产生传输事实;由 RPC 层的 transport.ClientIPFrom 消费,避免反向依赖。
if ip := c.clientIP(); ip != "" {
ctx = transport.WithClientIP(ctx, ip)
}
if task.run != nil {
_ = task.run(ctx)
}

View file

@ -13,6 +13,7 @@ import (
"github.com/iamxvbaba/td/tlprofile"
"telesrv/internal/observability/dbtrace"
"telesrv/internal/postresponse"
"telesrv/internal/rpcresult"
)
// layerRPCResultEncoder keeps the generated result bound to the immutable
@ -22,6 +23,8 @@ import (
type layerRPCResultEncoder struct {
call tlprofile.Call
result tlprofile.Result
source rpcresult.ReplaySource
bulk *outboundBulkCredit
}
func (e *layerRPCResultEncoder) Encode(b *bin.Buffer) error {
@ -45,6 +48,20 @@ func (e *layerRPCResultEncoder) exactLayerRPCResultBinding() outboundLayerBindin
}
}
func (e *layerRPCResultEncoder) exactRPCReplaySource() rpcresult.ReplaySource {
if e == nil {
return nil
}
return e.source
}
func (e *layerRPCResultEncoder) exactRPCBulkCredit() *outboundBulkCredit {
if e == nil {
return nil
}
return e.bulk
}
type exactLayerRPCResultEncoder interface {
bin.Encoder
exactLayerRPCResultBinding() outboundLayerBinding
@ -90,7 +107,11 @@ func bindAdmittedLayerRPCResult(request tlprofile.Admission, result tlprofile.Re
if result.Prepared().Identity() != request.Prepared().Identity() {
return nil, errLayerRPCResultIdentityMismatch
}
return &layerRPCResultEncoder{call: request.Call(), result: result}, nil
var source rpcresult.ReplaySource
if carrier, ok := result.(rpcresult.Carrier); ok {
source = carrier.ExactReplaySource()
}
return &layerRPCResultEncoder{call: request.Call(), result: result, source: source}, nil
}
func (s *Server) newInboundLayerRPCTask(
@ -104,7 +125,15 @@ func (s *Server) newInboundLayerRPCTask(
owner *rpcResultOwnerLease,
) inboundRPC {
wireSize := request.Prepared().WireSize()
gate := newLayerRPCExecutionGate(c, dependencies)
var bulkLease *outboundBulkCreditLease
var bulkExecution *bulkRPCAdmission
if method == "upload.getFile" && c != nil && c.outboundState != nil {
bulkLease = c.outboundState.reserveBulkCredit()
if s.bulkRPCScheduler != nil {
bulkExecution = newBulkRPCAdmission(s.bulkRPCScheduler)
}
}
gate := newLayerRPCExecutionGate(c, dependencies, bulkLease, bulkExecution)
timeoutResponse := func() {
writeTimeout := c.writeTimeout
if writeTimeout <= 0 || writeTimeout > 5*time.Second {
@ -126,6 +155,8 @@ func (s *Server) newInboundLayerRPCTask(
size: wireSize,
onTimeout: timeoutResponse,
release: func() {
bulkExecution.release()
bulkLease.releaseIfOwned()
if owner != nil && owner.Abort() {
c.fenceUndeliveredRPCResult()
}
@ -137,7 +168,7 @@ func (s *Server) newInboundLayerRPCTask(
ErrorCode: 500, ErrorMessage: "MSG_WAIT_FAILED",
}, nil)
}
if err := s.handleAdmittedLayerRPC(s.withLayerRPCProfileEvidenceFresh(taskCtx, profileEvidenceFresh), c, msgID, admissionSeq, method, request, owner); err != nil {
if err := s.handleAdmittedLayerRPC(s.withLayerRPCProfileEvidenceFresh(taskCtx, profileEvidenceFresh), c, msgID, admissionSeq, method, request, owner, bulkLease); err != nil {
fields := []zap.Field{
zap.Int64("msg_id", msgID), zap.String("auth_key_id", c.authKeyHex),
zap.Int64("session_id", c.sessionID), zap.Error(err),
@ -161,11 +192,15 @@ func layerRPCTimeoutMessage(gate *inboundRPCGate) string {
return "RPC_TIMEOUT"
}
func newLayerRPCExecutionGate(c *Conn, dependencies layerRPCDependencySet) *inboundRPCGate {
if len(dependencies.waiters) == 0 && !dependencies.failed {
func newLayerRPCExecutionGate(c *Conn, dependencies layerRPCDependencySet, bulk *outboundBulkCreditLease, execution *bulkRPCAdmission) *inboundRPCGate {
if len(dependencies.waiters) == 0 && !dependencies.failed && bulk == nil && execution == nil {
return nil
}
gate := newInboundRPCGate(len(dependencies.waiters), c.wakeInboundRPC)
prerequisites := len(dependencies.waiters)
if bulk != nil {
prerequisites++
}
gate := newInboundRPCGate(prerequisites, c.wakeInboundRPC)
if dependencies.failed {
gate.failed.Store(true)
}
@ -174,6 +209,11 @@ func newLayerRPCExecutionGate(c *Conn, dependencies layerRPCDependencySet) *inbo
gate.resolve(false)
}
}
if bulk != nil && bulk.credit != nil && execution != nil {
execution.subscribeAfter(bulk.credit, gate.resolve)
} else if bulk != nil && bulk.credit != nil {
bulk.credit.subscribe(gate.resolve)
}
// Release the subscriber-installation sentinel.
gate.resolve(true)
return gate
@ -202,6 +242,7 @@ func (s *Server) handleAdmittedLayerRPC(
method string,
request tlprofile.Admission,
owner *rpcResultOwnerLease,
bulkLease *outboundBulkCreditLease,
) error {
if s.layerRPC == nil {
return s.publishAdmittedLayerRPCResult(c, msgID, method, owner, false, &mt.RPCError{
@ -219,6 +260,9 @@ func (s *Server) handleAdmittedLayerRPC(
var exact *layerRPCResultEncoder
if err == nil && result != nil {
exact, err = bindAdmittedLayerRPCResult(request, result)
if err == nil && exact != nil && bulkLease != nil {
exact.bulk = bulkLease.transfer()
}
}
dur := s.clock.Now().Sub(start)
s.metrics.RPCHandled(effectiveMethod, dur, err)

View file

@ -101,7 +101,7 @@ func TestProjectionFailureCachesInternalWithoutRepeatingBusiness(t *testing.T) {
}
if err := s.handleAdmittedLayerRPC(
context.Background(), c, reqMsgID, claim.admissionSeq,
"help.getConfig", request, claim.owner,
"help.getConfig", request, claim.owner, nil,
); err != nil {
t.Fatalf("publish projection failure: %v", err)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,102 @@
package mtprotoedge
// outboundReplayBodyPool owns the short-lived plaintext buffers used to
// materialize descriptor-backed rpc_result frames for an exact resend. It is
// deliberately bounded and Server-owned: a burst may warm the size classes,
// but it cannot make the process retain an unbounded sync.Pool tail.
type outboundReplayBodyPool struct {
classes []outboundReplayBodyClass
}
type outboundReplayBodyClass struct {
size int
idle chan []byte
}
type outboundReplayBodyClassSpec struct {
size int
maxIdle int
}
var defaultOutboundReplayBodyClasses = []outboundReplayBodyClassSpec{
{size: 4<<10 + 64, maxIdle: 32},
{size: 16<<10 + 64, maxIdle: 32},
{size: 64<<10 + 64, maxIdle: 32},
{size: 256<<10 + 64, maxIdle: 32},
{size: 512<<10 + 64, maxIdle: 32},
{size: 1<<20 + 64, maxIdle: 32},
{size: 2<<20 + 64, maxIdle: 8},
}
func newOutboundReplayBodyPool(specs []outboundReplayBodyClassSpec) *outboundReplayBodyPool {
classes := make([]outboundReplayBodyClass, 0, len(specs))
for _, spec := range specs {
if spec.size <= 0 || spec.maxIdle <= 0 {
continue
}
classes = append(classes, outboundReplayBodyClass{
size: spec.size,
idle: make(chan []byte, spec.maxIdle),
})
}
return &outboundReplayBodyPool{classes: classes}
}
// acquire returns an empty buffer and its owning class. Bodies larger than the
// largest reusable class use ordinary GC ownership and return class -1.
func (p *outboundReplayBodyPool) acquire(size int) ([]byte, int) {
if p == nil || size <= 0 {
return nil, -1
}
for class := range p.classes {
bucket := &p.classes[class]
if size > bucket.size {
continue
}
select {
case buf := <-bucket.idle:
return buf[:0], class
default:
return make([]byte, 0, bucket.size), class
}
}
return nil, -1
}
func (p *outboundReplayBodyPool) release(class int, buf []byte) {
if p == nil || class < 0 || class >= len(p.classes) {
return
}
bucket := &p.classes[class]
if cap(buf) != bucket.size {
return
}
buf = buf[:0]
select {
case bucket.idle <- buf:
default:
}
}
type outboundReplayBodyLease struct {
pool *outboundReplayBodyPool
class int
buf []byte
}
func (l *outboundReplayBodyLease) release() {
if l == nil || l.pool == nil {
return
}
l.pool.release(l.class, l.buf)
*l = outboundReplayBodyLease{}
}
var fallbackOutboundReplayBodyPool = newOutboundReplayBodyPool(defaultOutboundReplayBodyClasses)
func (c *Conn) replayBodyPool() *outboundReplayBodyPool {
if c != nil && c.outboundReplayBodyPool != nil {
return c.outboundReplayBodyPool
}
return fallbackOutboundReplayBodyPool
}

View file

@ -215,13 +215,13 @@ func TestSendRequiredControlQueueDeadlineTerminatesAndReturnsBudget(t *testing.T
writeTimeout: time.Second,
outboundTrackedBudget: newOutboundTrackedBudget(1 << 20),
outboundControlTrackedBudget: controlBudget,
outbound: make(chan outboundOp, 1),
outboundControl: make(chan outboundOp, 1),
outbound: make(chan *outboundOp, 1),
outboundControl: make(chan *outboundOp, 1),
outboundStop: make(chan struct{}),
}
// No actor is running and the bounded control queue is full, so the parent
// deadline must cover queue admission and make the failure terminal.
c.outboundControl <- outboundOp{kind: outboundAck}
c.outboundControl <- &outboundOp{kind: outboundAck}
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
defer cancel()

View file

@ -4,12 +4,15 @@ import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"errors"
"io"
"sync"
"sync/atomic"
"testing"
"time"
"unsafe"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/crypto"
@ -20,6 +23,17 @@ import (
"github.com/iamxvbaba/td/transport"
)
type staticRPCReplaySource struct {
inner []byte
}
func (s *staticRPCReplaySource) EncodeInner(_ context.Context, out *bin.Buffer) error {
out.Put(s.inner)
return nil
}
func (*staticRPCReplaySource) RetainedBytes() int { return 128 }
type failAfterTransport struct {
failAt atomic.Int32
sends atomic.Int32
@ -448,6 +462,9 @@ func newOutboundTestConn(t *testing.T, tr transport.Conn, budget *outboundTracke
}
func TestOutboundQueueBackingUsesSmallConfigurableBounds(t *testing.T) {
if slot, wide := unsafe.Sizeof((*outboundOp)(nil)), unsafe.Sizeof(outboundOp{}); slot >= wide {
t.Fatalf("indirect queue slot = %d bytes, wide outbound op = %d bytes", slot, wide)
}
t.Run("defaults", func(t *testing.T) {
c := &Conn{metrics: NopMetrics{}}
c.startOutbound()
@ -477,6 +494,64 @@ func TestOutboundQueueBackingUsesSmallConfigurableBounds(t *testing.T) {
})
}
func TestOutboundOpPoolClearsReferencesAndBoundsIdle(t *testing.T) {
pool := newOutboundOpPool(1)
op := pool.acquire()
op.ctx = context.Background()
op.msg = &mt.PingRequest{PingID: 1}
op.encoded = &encodedOutboundMessage{body: []byte("payload")}
op.ids = []int64{1, 2, 3}
op.done = make(chan outboundResult, 1)
op.terminal = func(error) {}
pool.release(op)
reused := pool.acquire()
if reused != op {
t.Fatal("idle outbound op was not reused")
}
if reused.ctx != nil || reused.msg != nil || reused.encoded != nil || reused.ids != nil || reused.done != nil || reused.terminal != nil {
t.Fatalf("reused outbound op retained references: %+v", reused)
}
pool.release(reused)
pool.release(&outboundOp{})
if got := len(pool.idle); got != 1 {
t.Fatalf("idle outbound op count = %d, want bounded 1", got)
}
}
func BenchmarkOutboundOpPool(b *testing.B) {
pool := newOutboundOpPool(1)
b.ReportAllocs()
for b.Loop() {
op := pool.acquire()
op.kind = outboundSend
pool.release(op)
}
}
func TestOutboundAckHistoryUsesStableCircularBacking(t *testing.T) {
state := newOutboundState(newOutboundTrackedBudget(1 << 20))
for id := int64(1); id <= maxTrackedAckedMsgIDs; id++ {
state.markAcked(id)
}
if len(state.ackOrder) != maxTrackedAckedMsgIDs || len(state.acked) != maxTrackedAckedMsgIDs {
t.Fatalf("initial ack history = order:%d map:%d", len(state.ackOrder), len(state.acked))
}
backing := &state.ackOrder[0]
for id := int64(maxTrackedAckedMsgIDs + 1); id <= 4*maxTrackedAckedMsgIDs; id++ {
state.markAcked(id)
}
if &state.ackOrder[0] != backing {
t.Fatal("full ack history replaced its circular backing")
}
if len(state.ackOrder) != maxTrackedAckedMsgIDs || len(state.acked) != maxTrackedAckedMsgIDs {
t.Fatalf("steady ack history = order:%d map:%d", len(state.ackOrder), len(state.acked))
}
if state.isKnown(1) || !state.isKnown(4*maxTrackedAckedMsgIDs) {
t.Fatal("ack history did not evict oldest and retain newest IDs")
}
}
func TestOutboundOptionsDefaults(t *testing.T) {
opts := Options{}
opts.setDefaults()
@ -486,6 +561,9 @@ func TestOutboundOptionsDefaults(t *testing.T) {
if opts.OutboundTrackedGlobalMaxBytes != 512<<20 {
t.Fatalf("outbound tracked default = %d, want %d", opts.OutboundTrackedGlobalMaxBytes, 512<<20)
}
if opts.OutboundCriticalGlobalMaxBytes != 64<<20 {
t.Fatalf("outbound critical default = %d, want %d", opts.OutboundCriticalGlobalMaxBytes, 64<<20)
}
}
func TestServerNewConnectionsShareOutboundBudgetAndQueueLimits(t *testing.T) {
@ -493,6 +571,7 @@ func TestServerNewConnectionsShareOutboundBudgetAndQueueLimits(t *testing.T) {
OutboundQueueSize: 7,
OutboundControlQueueSize: 3,
OutboundTrackedGlobalMaxBytes: 20,
OutboundCriticalGlobalMaxBytes: 30,
})
var rawKey crypto.Key
key := rawKey.WithID()
@ -513,6 +592,12 @@ func TestServerNewConnectionsShareOutboundBudgetAndQueueLimits(t *testing.T) {
if got := srv.outboundTrackedBudget.maxBytes; got != 20 {
t.Fatalf("server outbound tracked max = %d, want 20", got)
}
if c1.outboundCriticalTrackedBudget != srv.outboundCriticalBudget || c2.outboundCriticalTrackedBudget != srv.outboundCriticalBudget {
t.Fatal("server connections did not receive the shared critical tracking budget")
}
if got := srv.outboundCriticalBudget.maxBytes; got != 30 {
t.Fatalf("server outbound critical max = %d, want 30", got)
}
}
func TestEncryptOutboundFrameDecryptsWithGotdCipher(t *testing.T) {
@ -909,6 +994,162 @@ func TestOutboundTrackedBudgetWriteFailureReturnsReservation(t *testing.T) {
}
}
func TestOutboundStateCompactsImmutableRPCResultAndReplaysExactBody(t *testing.T) {
budget := newOutboundTrackedBudget(1 << 20)
state := newOutboundStateWithLimits(budget, 64, 1<<20)
inner := bytes.Repeat([]byte{0x5a}, 4096)
var body bin.Buffer
body.PutID(proto.ResultTypeID)
body.PutLong(7001)
body.Put(inner)
wire := body.Raw()
if !budget.reserve(len(wire)) {
t.Fatal("reserve first-write body")
}
frame := &outboundFrame{
msgID: 9001,
seqNo: 1,
typeID: proto.ResultTypeID,
body: wire,
reservedBytes: len(wire),
reservationBudget: budget,
reqMsgID: 7001,
replaySource: &staticRPCReplaySource{inner: append([]byte(nil), inner...)},
innerDigest: sha256.Sum256(inner),
uncompressedBytes: len(inner),
logicalBytes: len(wire),
}
if err := state.admitReserved(frame); err != nil {
t.Fatalf("admit frame: %v", err)
}
if !state.compactImmutableFrame(frame) {
t.Fatal("immutable frame was not compacted")
}
if frame.body != nil {
t.Fatal("compacted frame retained full body")
}
if got := budget.snapshot(); got != outboundReplayDescriptorCharge {
t.Fatalf("retained bytes = %d, want descriptor charge %d", got, outboundReplayDescriptorCharge)
}
replay, ok := state.rpcResult(7001)
if !ok || replay.replaySource == nil || len(replay.body) != 0 {
t.Fatalf("replay descriptor = %+v ok=%v", replay, ok)
}
materialized, err := replay.materializeRPCResultBody(context.Background(), 7001)
if err != nil {
t.Fatalf("materialize replay: %v", err)
}
if !bytes.Equal(materialized, wire) {
t.Fatal("materialized replay differs from first-write body")
}
state.ack([]int64{9001})
if got := budget.snapshot(); got != 0 {
t.Fatalf("retained bytes after ACK = %d, want 0", got)
}
}
func TestImmutableRPCResultMaterializesDirectlyIntoScratch(t *testing.T) {
inner := bytes.Repeat([]byte{0x6b}, 1<<20)
logicalBytes := 12 + len(inner)
replay := &encodedOutboundMessage{
typeID: proto.ResultTypeID,
reqMsgID: 7101,
replaySource: &staticRPCReplaySource{inner: inner},
innerDigest: sha256.Sum256(inner),
uncompressedBytes: len(inner),
logicalBytes: logicalBytes,
}
pool := newOutboundReplayBodyPool([]outboundReplayBodyClassSpec{{size: logicalBytes, maxIdle: 1}})
scratch, class := pool.acquire(logicalBytes)
body, usedScratch, err := replay.materializeRPCResultBodyInto(context.Background(), replay.reqMsgID, scratch)
if err != nil {
t.Fatalf("materialize replay: %v", err)
}
if !usedScratch {
t.Fatal("descriptor replay did not use the supplied scratch buffer")
}
if len(body) != logicalBytes || &body[0] != &scratch[:cap(scratch)][0] {
t.Fatal("materialized body does not alias the supplied scratch buffer")
}
if got := int64(binary.LittleEndian.Uint64(body[4:12])); got != replay.reqMsgID {
t.Fatalf("materialized req_msg_id = %d, want %d", got, replay.reqMsgID)
}
pool.release(class, body)
if got := len(pool.classes[class].idle); got != 1 {
t.Fatalf("idle pooled bodies = %d, want 1", got)
}
}
func TestOutboundReplayBodyPoolBoundsIdleBuffers(t *testing.T) {
pool := newOutboundReplayBodyPool([]outboundReplayBodyClassSpec{{size: 4096, maxIdle: 1}})
first, firstClass := pool.acquire(4000)
second, secondClass := pool.acquire(4000)
if firstClass != 0 || secondClass != 0 || cap(first) != 4096 || cap(second) != 4096 {
t.Fatalf("acquired classes/capacities = (%d,%d) (%d,%d)", firstClass, cap(first), secondClass, cap(second))
}
pool.release(firstClass, first)
pool.release(secondClass, second)
if got := len(pool.classes[0].idle); got != 1 {
t.Fatalf("idle pooled bodies = %d, want bounded at 1", got)
}
oversized, class := pool.acquire(4097)
if oversized != nil || class != -1 {
t.Fatalf("oversized acquisition = len:%d class:%d, want GC-owned nil/-1", len(oversized), class)
}
}
func BenchmarkImmutableRPCResultMaterializePooled(b *testing.B) {
inner := bytes.Repeat([]byte{0x6b}, 1<<20)
logicalBytes := 12 + len(inner)
replay := &encodedOutboundMessage{
typeID: proto.ResultTypeID,
reqMsgID: 7101,
replaySource: &staticRPCReplaySource{inner: inner},
innerDigest: sha256.Sum256(inner),
uncompressedBytes: len(inner),
logicalBytes: logicalBytes,
}
pool := newOutboundReplayBodyPool([]outboundReplayBodyClassSpec{{size: logicalBytes, maxIdle: 1}})
b.ReportAllocs()
b.SetBytes(int64(logicalBytes))
b.ResetTimer()
for range b.N {
scratch, class := pool.acquire(logicalBytes)
body, usedScratch, err := replay.materializeRPCResultBodyInto(context.Background(), replay.reqMsgID, scratch)
if err != nil || !usedScratch {
b.Fatalf("materialize replay: used=%v err=%v", usedScratch, err)
}
pool.release(class, body)
}
}
func TestOutboundBulkACKWindowWakesNextWaiter(t *testing.T) {
state := newOutboundStateWithLimits(newOutboundTrackedBudget(1<<20), 128, 1<<20)
leasing := make([]*outboundBulkCreditLease, 0, defaultBulkACKWindow+1)
for range defaultBulkACKWindow + 1 {
leasing = append(leasing, state.reserveBulkCredit())
}
woken := make(chan bool, 1)
leasing[len(leasing)-1].credit.subscribe(func(success bool) { woken <- success })
select {
case <-woken:
t.Fatal("window overflow waiter woke before ACK credit release")
default:
}
leasing[0].releaseIfOwned()
select {
case success := <-woken:
if !success {
t.Fatal("window waiter was canceled instead of granted")
}
case <-time.After(time.Second):
t.Fatal("window waiter did not wake after credit release")
}
for _, lease := range leasing[1:] {
lease.releaseIfOwned()
}
}
func TestOutboundStateEvictionReturnsTrackedBudget(t *testing.T) {
budget := newOutboundTrackedBudget(64)
state := newOutboundStateWithLimits(budget, 2, 8)
@ -991,11 +1232,11 @@ func TestOutboundStateReleasesMixedBodyAndControlBudgets(t *testing.T) {
func TestSendBestEffortQueueFullBehavior(t *testing.T) {
c := &Conn{metrics: NopMetrics{}, outboundTrackedBudget: newOutboundTrackedBudget(1 << 20)}
c.outbound = make(chan outboundOp, 1)
c.outboundControl = make(chan outboundOp, 1)
c.outbound = make(chan *outboundOp, 1)
c.outboundControl = make(chan *outboundOp, 1)
c.outboundStop = make(chan struct{})
// 占满普通队列,模拟出站拥塞。
c.outbound <- outboundOp{}
c.outbound <- &outboundOp{}
if err := c.SendBestEffort(context.Background(), proto.MessageFromServer, &mt.MsgsAck{}, 0); err != ErrOutboundQueueFull {
t.Fatalf("timeout=0 on full queue: err = %v, want ErrOutboundQueueFull", err)
@ -1027,10 +1268,10 @@ func TestSendBestEffortQueueFullBehavior(t *testing.T) {
func TestSendAsyncControlQueueBoundary(t *testing.T) {
c := &Conn{metrics: NopMetrics{}, outboundTrackedBudget: newOutboundTrackedBudget(1 << 20)}
c.outbound = make(chan outboundOp, 1)
c.outboundControl = make(chan outboundOp, 1)
c.outbound = make(chan *outboundOp, 1)
c.outboundControl = make(chan *outboundOp, 1)
c.outboundStop = make(chan struct{})
c.outboundControl <- outboundOp{kind: outboundAck}
c.outboundControl <- &outboundOp{kind: outboundAck}
if err := c.SendAsync(context.Background(), proto.MessageFromServer, &mt.MsgsAck{}); err != nil {
t.Fatalf("SendAsync on full control queue: %v", err)

View file

@ -325,16 +325,24 @@ func encodeAdaptiveRPCResultInner(ctx context.Context, stop <-chan struct{}, inn
// Android. These bootstrap barriers must pass background prefetch regardless of
// platform or their own encoded size.
func rpcResultPriority(method string, encoded *encodedOutboundMessage) outboundPriority {
if priority := rpcMethodPriority(method); priority != outboundPriorityNormal {
return priority
}
return classifyOutboundPriority(encoded, false)
}
func rpcMethodPriority(method string) outboundPriority {
base := method
if i := strings.IndexByte(base, '#'); i >= 0 {
base = base[:i]
}
switch base {
case "updates.getDifference", "updates.getChannelDifference", "updates.getState",
"messages.getDialogs", "messages.getPinnedDialogs":
"messages.getDialogs", "messages.getPinnedDialogs",
"auth.bindTempAuthKey", "help.getConfig", "users.getUsers":
return outboundPriorityCritical
}
return classifyOutboundPriority(encoded, false)
return outboundPriorityNormal
}
func (p outboundPriority) String() string {

View file

@ -104,6 +104,30 @@ func TestEncodeRPCResultReservedChargesBodyBeforeReturning(t *testing.T) {
}
}
func TestCriticalRPCResultUsesIndependentRetainedBudget(t *testing.T) {
ordinary := newOutboundTrackedBudget(1)
critical := newOutboundTrackedBudget(1 << 20)
c := legacyCanonicalTestConn(t, &Conn{
metrics: NopMetrics{},
outboundTrackedBudget: ordinary,
outboundCriticalTrackedBudget: critical,
})
s := New(Options{})
encoded, reserved, retained, err := s.encodeRPCResultReservedWithPriorityAndHandoffContext(
context.Background(), c, 791, exactTestRPCResult(&tg.DataJSON{Data: "bootstrap"}), outboundPriorityCritical, nil,
)
if err != nil || retained || encoded == nil || reserved == nil {
t.Fatalf("critical encode encoded=%p reserved=%p retained=%v err=%v", encoded, reserved, retained, err)
}
if got := ordinary.snapshot(); got != 0 {
t.Fatalf("ordinary budget used by critical result = %d", got)
}
if got, want := critical.snapshot(), int64(len(encoded.body)); got != want {
t.Fatalf("critical budget = %d, want %d", got, want)
}
reserved.release()
}
func TestEncodeRPCResultReservedDropsBodyOnBudgetTimeout(t *testing.T) {
const maxBytes = 1 << 20
budget := newOutboundTrackedBudget(maxBytes)

View file

@ -23,10 +23,10 @@ func TestRPCResultCloneReservationIsOneShotUnderReleaseRace(t *testing.T) {
}
reserved := &outboundBodyReservation{budget: budget, bytes: len(encoded.body)}
start := make(chan struct{})
taken := make(chan outboundOp, 1)
taken := make(chan *outboundOp, 1)
go func() {
<-start
op, _ := reserved.take(encoded)
op, _ := reserved.take(encoded, fallbackOutboundOpPool)
taken <- op
}()
released := make(chan struct{})
@ -53,7 +53,7 @@ func TestRPCResultReservationReleaseWinsAdmissionRollback(t *testing.T) {
t.Fatal("reserve body")
}
reserved := &outboundBodyReservation{budget: budget, bytes: len(encoded.body)}
op, err := reserved.take(encoded)
op, err := reserved.take(encoded, fallbackOutboundOpPool)
if err != nil {
t.Fatalf("take reservation: %v", err)
}
@ -61,7 +61,7 @@ func TestRPCResultReservationReleaseWinsAdmissionRollback(t *testing.T) {
// rolls the op back. The rollback must observe the release request and return
// the raw op charge instead of resurrecting an owner nobody will release.
reserved.release()
if !reserved.reclaim(&op) {
if !reserved.reclaim(op) {
t.Fatal("reclaim actor reservation")
}
if got := budget.snapshot(); got != 0 {
@ -175,7 +175,7 @@ func TestOutboundActorRetargetRequiresSecondBodyReservation(t *testing.T) {
terminalBytes = budget.snapshot()
},
}
err := c.handleOutboundSend(state, op)
err := c.handleOutboundSend(state, &op)
op.finish(outboundResult{err: err})
if !errors.Is(terminalErr, ErrOutboundTrackedBudget) {
t.Fatalf("retarget terminal error = %v, want %v", terminalErr, ErrOutboundTrackedBudget)
@ -228,7 +228,7 @@ func TestOutboundActorRetargetTransfersOnlyReplacementToPending(t *testing.T) {
terminalBytes = budget.snapshot()
},
}
err := c.handleOutboundSend(state, op)
err := c.handleOutboundSend(state, &op)
op.finish(outboundResult{err: err})
if terminalErr != nil {
t.Fatalf("retarget terminal error: %v", terminalErr)

View file

@ -1135,5 +1135,5 @@ func (s *Server) publishRewrappedRPCResult(
s.log.Info("RPC init rewrap result replay delivered",
zap.String("method", method), zap.Int64("req_msg_id", reqMsgID),
zap.String("auth_key_id", c.authKeyHex), zap.Int64("session_id", c.sessionID),
zap.Int("wire_bytes", len(encoded.body)))
zap.Int("wire_bytes", encoded.wireSize()))
}

View file

@ -38,6 +38,8 @@ type RuntimeSnapshot struct {
OutboundTrackedMaxBytes int64
OutboundControlBytes int64
OutboundControlMaxBytes int64
OutboundCriticalBytes int64
OutboundCriticalMaxBytes int64
OutboundWriteBytes int64
OutboundWriteMaxBytes int64
RPCExecutionOwners int64
@ -190,6 +192,10 @@ func (s *Server) RuntimeSnapshot() RuntimeSnapshot {
result.OutboundControlBytes = s.outboundControlBudget.snapshot()
result.OutboundControlMaxBytes = s.outboundControlBudget.maxBytes
}
if s.outboundCriticalBudget != nil {
result.OutboundCriticalBytes = s.outboundCriticalBudget.snapshot()
result.OutboundCriticalMaxBytes = s.outboundCriticalBudget.maxBytes
}
if s.outboundScratchPool != nil && s.outboundScratchPool.budget != nil {
result.OutboundWriteBytes = s.outboundScratchPool.snapshot()
result.OutboundWriteMaxBytes = s.outboundScratchPool.budget.maxBytes

View file

@ -330,27 +330,21 @@ type Options struct {
// 阻断连接维持消息。可靠响应无法 tracking 时终止该连接durable best-effort update
// 则只丢在线加速并由 difference 恢复。
OutboundTrackedGlobalMaxBytes int64
// OutboundCriticalGlobalMaxBytes is an independent retained-body reserve for
// bootstrap/convergence RPC results. Bulk traffic cannot consume it.
OutboundCriticalGlobalMaxBytes int64
// OutboundWriteGlobalMaxBytes bounds concurrent encrypted wire/codec/obfuscation scratch.
// Scratch is shared and pooled across connections; default 512 MiB.
OutboundWriteGlobalMaxBytes int64
// DC 是本 server 的 DC ID。默认 2。
DC int
// StrictDC turns on exact DC-ID validation for the permanent-key exchange
// (default off = lenient). telesrv is always a single physical backend —
// there is no real multi-DC federation behind it — but the OwpenGram
// client forks intentionally run in "single-server backend" mode, where
// dc_id 1..5 all alias to this one server (see owpengram_servers.cpp /
// ApplyServerToDcOptions in the desktop client) so that any old data
// referencing a specific dc_id still resolves correctly. When tdesktop
// adds a new local account it picks its own starting dc_id (its usual
// multi-DC load-spreading behavior, unrelated to which physical server
// it's actually talking to) — that choice is not guaranteed to equal our
// configured DC. Strict validation would reject those accounts with
// "-444 wrong dc_id" even though they are connecting to the right (and
// only) server; dc_id is a client-side routing label here, not part of
// key derivation, so accepting the mismatch does not weaken the exchange.
// The switch exists for a hypothetical future real multi-DC deployment.
// StrictDC enables DC-label validation during key exchange. It is false by
// default: this single physical backend accepts every wire int32 label for
// permanent and temporary keys, and the label never changes auth-key
// persistence, session identity, or business state. When enabled,
// permanent labels must equal DC and temporary labels may equal +/-DC.
// This diagnostic switch does not itself provide multi-DC isolation.
StrictDC bool
// RSAKey 是 server RSA 私钥用于密钥交换。nil 时无法完成握手。
RSAKey *rsa.PrivateKey
@ -458,6 +452,9 @@ func (o *Options) setDefaults() {
if o.OutboundTrackedGlobalMaxBytes <= 0 {
o.OutboundTrackedGlobalMaxBytes = defaultOutboundTrackedMaxBytes
}
if o.OutboundCriticalGlobalMaxBytes <= 0 {
o.OutboundCriticalGlobalMaxBytes = defaultOutboundCriticalMaxBytes
}
if o.OutboundWriteGlobalMaxBytes <= 0 {
o.OutboundWriteGlobalMaxBytes = defaultOutboundWriteMaxBytes
}
@ -518,13 +515,17 @@ type Server struct {
rpcQueueSize int
rpcTimeout time.Duration
rpcScheduler *inboundRPCScheduler
bulkRPCScheduler *bulkRPCScheduler
rpcDeliveryHooks *rpcDeliveryHookExecutor
frameBudget *inboundFrameBudget
outboundQueueSize int
outboundControlQueueSize int
outboundTrackedBudget *outboundTrackedBudget
outboundControlBudget *outboundTrackedBudget
outboundCriticalBudget *outboundTrackedBudget
outboundScratchPool *outboundScratchPool
outboundOpPool *outboundOpPool
outboundReplayBodyPool *outboundReplayBodyPool
dc int
strictDC bool
@ -581,17 +582,20 @@ func New(opts Options) *Server {
rpcQueueSize: opts.RPCQueueSize,
rpcTimeout: opts.RPCTimeout,
rpcScheduler: newInboundRPCScheduler(opts.RPCGlobalWorkers, opts.RPCGlobalMaxTasks, opts.RPCGlobalMaxBytes),
bulkRPCScheduler: newBulkRPCScheduler(max(1, opts.RPCGlobalWorkers/2)),
rpcDeliveryHooks: newRPCDeliveryHookExecutor(opts.RPCDeliveryHookWorkers, opts.RPCDeliveryHookMaxPending),
frameBudget: newInboundFrameBudget(opts.InboundFrameGlobalMaxBytes),
outboundQueueSize: opts.OutboundQueueSize,
outboundControlQueueSize: opts.OutboundControlQueueSize,
outboundTrackedBudget: newOutboundTrackedBudget(opts.OutboundTrackedGlobalMaxBytes),
outboundControlBudget: newOutboundTrackedBudget(defaultOutboundControlMaxBytes),
outboundCriticalBudget: newOutboundTrackedBudget(opts.OutboundCriticalGlobalMaxBytes),
outboundScratchPool: newOutboundScratchPool(opts.OutboundWriteGlobalMaxBytes),
outboundOpPool: newOutboundOpPool(defaultOutboundOpPoolSize),
outboundReplayBodyPool: newOutboundReplayBodyPool(defaultOutboundReplayBodyClasses),
dc: opts.DC,
strictDC: opts.StrictDC,
key: exchange.PrivateKey{RSA: opts.RSAKey},
pubKeyPEM: rsaPublicKeyPEM(opts.RSAKey),
authKeys: opts.AuthKeys,
conns: conns,
rpc: opts.legacyRPC,
@ -612,6 +616,7 @@ func New(opts Options) *Server {
}),
rpcRewrap: newRPCRewrapRegistry(opts.RPCGlobalMaxTasks),
admission: newAdmissionController(opts.MaxConnections, opts.MaxConnectionsPerIP, opts.MaxConcurrentHandshakes),
pubKeyPEM: rsaPublicKeyPEM(opts.RSAKey),
}
if opts.IdentityDir != "" {
server.identityStore = identity.NewStore(opts.IdentityDir)
@ -680,7 +685,10 @@ func (s *Server) buildConn(tc transport.Conn, lease *physicalTransportLease, key
outboundControlQueueSize: s.outboundControlQueueSize,
outboundTrackedBudget: s.outboundTrackedBudget,
outboundControlTrackedBudget: s.outboundControlBudget,
outboundCriticalTrackedBudget: s.outboundCriticalBudget,
outboundScratchPool: s.outboundScratchPool,
outboundOpPool: s.outboundOpPool,
outboundReplayBodyPool: s.outboundReplayBodyPool,
rpcDeliveryHooks: s.rpcDeliveryHooks,
rpcResultAcked: func(conn *Conn, reqMsgID int64) {
// The sole outbound actor invokes this only after resolving a client
@ -708,6 +716,7 @@ func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
defer s.rpcDeliveryHooks.stop(rpcCloseWaitTimeout)
defer s.conns.releaseAllLogicalSessions()
defer s.rpcScheduler.stop(rpcCloseWaitTimeout)
defer s.bulkRPCScheduler.close()
// 只在最外层 listener 包一次,确保 same-port mux 的 sniff/HTTP upgrade 也计入
// raw admission而不是等连接已经分流后才计数。
ln = s.observeRawAccepts(s.admission.wrapListener(ln))
@ -1147,7 +1156,7 @@ func (s *Server) serveConn(ctx context.Context, raw transport.Conn, remote, loca
fetchedKey = &d
}
current, err = s.handleEncrypted(ctx, conn, cs, current, fetchedKey, &b, &plain)
current, err = s.handleEncrypted(ctx, conn, cs, current, remote, fetchedKey, &b, &plain)
if errors.Is(err, errActivationAuthKeyRejected) {
// handleEncrypted writes -404 while its activation claim still owns the
// physical writer, then its deferred abort removes/closes the claim.

View file

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

View file

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

View file

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

View file

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

View file

@ -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)
@ -63,7 +63,7 @@ func TestPushTransientCompatibleSkipsUnavailableAndUnknownProfiles(t *testing.T)
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)

View file

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

View file

@ -43,7 +43,7 @@ func (r *Router) resolveAIComposeStyleWebPage(ctx context.Context, rawURL string
Hash: aiComposeToneWebPageHash(tone),
Date: int(now.Unix()),
Type: aiComposeToneWebPageType,
SiteName: branding.ProductName,
SiteName: branding.ProductName(),
Title: tone.Title,
Description: tone.Prompt,
ComposeToneEmojiID: tone.EmojiID,

View file

@ -41,8 +41,18 @@ func (r *Router) onUpdatesGetChannelDifference(ctx context.Context, req *tg.Upda
})
if err != nil {
if errors.Is(err, domain.ErrPersistentTimestamp) {
r.log.Debug("channel difference cursor rejected",
zap.Int64("viewer_user_id", userID),
zap.Int64("channel_id", channelID),
zap.Int("request_pts", req.Pts))
return nil, persistentTimestampInvalidErr()
}
r.log.Warn("load channel difference failed",
zap.Int64("viewer_user_id", userID),
zap.Int64("channel_id", channelID),
zap.Int("request_pts", req.Pts),
zap.Int("limit", req.Limit),
zap.Error(err))
return nil, channelInvalidErr(err)
}
diff, err = r.enrichChannelDifferenceStrict(ctx, userID, diff)

View file

@ -9,6 +9,7 @@ import (
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
"telesrv/internal/transport"
)
type ctxKey int
@ -303,3 +304,15 @@ func invokeWithoutUpdatesFrom(ctx context.Context) bool {
v, _ := ctx.Value(invokeWithoutUpdatesKey).(bool)
return v
}
// WithClientIP 在 ctx 注入客户端连接的对端 IP来自 MTProto 连接的 RemoteAddr
// 仅在需要时(绑定设备授权)由 edge 写入,其余 RPC 不依赖它。edge 通过中立
// internal/transport 载体写入,这里仅做别名以便 rpc 业务层读取,避免反向依赖。
func WithClientIP(ctx context.Context, ip string) context.Context {
return transport.WithClientIP(ctx, ip)
}
// ClientIPFrom 返回 ctx 中的客户端对端 IP未设置时 ok=false。
func ClientIPFrom(ctx context.Context) (string, bool) {
return transport.ClientIPFrom(ctx)
}

View file

@ -16,6 +16,9 @@ func (r *Router) authzFromCtx(ctx context.Context) domain.Authorization {
a.AppVersion = ci.AppVersion
a.APIID = ci.APIID
}
if ip, ok := ClientIPFrom(ctx); ok {
a.IP = ip
}
return a
}

View file

@ -346,7 +346,7 @@ func tgMessageReplyHeader(m domain.Message) tg.MessageReplyHeaderClass {
}
return &tg.MessageReplyStoryHeader{Peer: peer, StoryID: m.ReplyTo.StoryID}
}
if m.ReplyTo.MessageID <= 0 && m.ReplyTo.TopMessageID <= 0 {
if m.ReplyTo.MessageID <= 0 && m.ReplyTo.TopMessageID <= 0 && m.ReplyTo.External == nil {
return nil
}
header := &tg.MessageReplyHeader{}
@ -364,6 +364,18 @@ func tgMessageReplyHeader(m domain.Message) tg.MessageReplyHeaderClass {
header.SetReplyToPeerID(peer)
}
}
if external := m.ReplyTo.External; external != nil {
if from := tgMessageFwdHeader(&external.From); from != nil {
header.SetReplyFrom(*from)
}
if !external.Media.IsZero() {
header.SetReplyMedia(tgMessageMedia(external.Media))
}
if m.ReplyTo.QuoteText == "" && external.Text != "" {
header.SetQuoteText(external.Text)
header.SetQuoteEntities(tgMessageEntities(external.Entities))
}
}
if m.ReplyTo.QuoteText != "" {
header.SetQuote(true)
header.SetQuoteText(m.ReplyTo.QuoteText)

View file

@ -62,6 +62,8 @@ func peersListEmptyErr() error { return tgerr.New(400, "PEERS_LIST_EMPTY") }
// peerIDInvalidErr 表示目标 peer 不存在或当前阶段不支持。
func peerIDInvalidErr() error { return tgerr.New(400, "PEER_ID_INVALID") }
func fromPeerInvalidErr() error { return tgerr.New(400, "FROM_PEER_INVALID") }
func parentPeerInvalidErr() error { return tgerr.New(400, "PARENT_PEER_INVALID") }
func sendAsPeerInvalidErr() error { return tgerr.New(400, "SEND_AS_PEER_INVALID") }
@ -442,6 +444,8 @@ func dhGAInvalidErr() error { return tgerr.New(400, "DH_G_A_INVALI
func maxDateInvalidErr() error { return tgerr.New(400, "MAX_DATE_INVALID") }
func fileEmptyErr() error { return tgerr.New(400, "FILE_EMPTY") }
func quoteTextInvalidErr() error { return tgerr.New(400, "QUOTE_TEXT_INVALID") }
// signInErr 把登录业务错误映射为客户端可识别的 rpc_error。
func signInErr(err error) error {
switch {

View file

@ -25,7 +25,7 @@ func (r *Router) registerHelp(d *tlprofile.Dispatcher) {
}, nil
})
registerRPC[*tg.HelpGetInviteTextRequest](d, tlprofile.SemanticMethodHelpGetInviteText, func(ctx context.Context, layerRequest *tg.HelpGetInviteTextRequest) (any, error) {
return &tg.HelpInviteText{Message: "Join me on " + branding.ProductName + "."}, nil
return &tg.HelpInviteText{Message: "Join me on " + branding.ProductName() + "."}, nil
})
registerRPC[*tg.HelpSaveAppLogRequest](d, tlprofile.SemanticMethodHelpSaveAppLog, func(ctx context.Context, _ *tg.HelpSaveAppLogRequest) (any, error) {
return r.onHelpSaveAppLog(ctx)
@ -178,7 +178,7 @@ func (r *Router) onHelpDismissSuggestion(ctx context.Context, req *tg.HelpDismis
// dead payment URLs. All six TL fields are mandatory.
func (r *Router) onHelpGetPremiumPromo(ctx context.Context) (*tg.HelpPremiumPromo, error) {
promo := &tg.HelpPremiumPromo{
StatusText: branding.PremiumName + " is not active on this account.",
StatusText: branding.PremiumName() + " is not active on this account.",
StatusEntities: []tg.MessageEntityClass{},
VideoSections: []string{},
Videos: []tg.DocumentClass{},
@ -207,7 +207,7 @@ func (r *Router) onHelpGetPremiumPromo(ctx context.Context) (*tg.HelpPremiumProm
}
if u.PremiumActiveAt(r.clock.Now().Unix()) {
until := time.Unix(int64(u.PremiumUntil), 0)
promo.StatusText = branding.PremiumName + " is active until " + until.Format("2006-01-02") + "."
promo.StatusText = branding.PremiumName() + " is active until " + until.Format("2006-01-02") + "."
}
if r.deps.PremiumPromo != nil {
catalog, found, err := r.deps.PremiumPromo.PremiumPromo(ctx)

View file

@ -12,12 +12,48 @@ import (
"github.com/iamxvbaba/td/tlprofile"
compatandroid "telesrv/internal/compat/android"
"telesrv/internal/observability/dbtrace"
"telesrv/internal/rpcresult"
)
type layerWrappersAppliedKey struct{}
type layerAdmissionSequenceKey struct{}
type layerRPCProfileEvidenceFreshKey struct{}
type immutableLayerRPCReplaySource struct {
call tlprofile.Call
source rpcresult.ValueSource
}
func (s *immutableLayerRPCReplaySource) EncodeInner(ctx context.Context, out *bin.Buffer) error {
if s == nil || s.source == nil {
return fmt.Errorf("immutable layer RPC replay source is unavailable")
}
value, err := s.source.Value(ctx)
if err != nil {
return err
}
return s.call.EncodeResult(value, out)
}
func (s *immutableLayerRPCReplaySource) RetainedBytes() int {
if s == nil || s.source == nil {
return 0
}
return s.source.RetainedBytes() + 128
}
type immutableLayerRPCResult struct {
tlprofile.Result
source rpcresult.ReplaySource
}
func (r *immutableLayerRPCResult) ExactReplaySource() rpcresult.ReplaySource {
if r == nil {
return nil
}
return r.source
}
// WithLayerRPCProfileEvidenceFresh records whether an admitted request's
// explicit selector is inside MTProto's mutable msg_id freshness window. A
// stale selector still owns its immutable request/result codec, but wrapper
@ -278,7 +314,16 @@ func (r *Router) DispatchAdmitted(
}
dbBefore := dbtrace.SnapshotFromContext(ctx)
start := time.Now()
result, err := r.dispatchGeneratedSafely(ctx, method, request)
dispatchCtx, replayCapture := rpcresult.WithCapture(ctx)
result, err := r.dispatchGeneratedSafely(dispatchCtx, method, request)
if err == nil && result != nil {
if valueSource := replayCapture.Take(); valueSource != nil {
result = &immutableLayerRPCResult{
Result: result,
source: &immutableLayerRPCReplaySource{call: call, source: valueSource},
}
}
}
dur := time.Since(start)
dbDelta := dbtrace.SnapshotFromContext(ctx).Sub(dbBefore)
fields := append([]zap.Field{

View file

@ -0,0 +1,87 @@
package rpc
import (
"context"
"reflect"
"testing"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"telesrv/internal/domain"
)
func TestExternalPrivateReplySnapshotWireAndProtection(t *testing.T) {
r, store, events, a, b := savedForwardFixture(t)
ctx := WithUserID(context.Background(), a.ID)
seed, err := store.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: a.ID, RecipientUserID: a.ID, RandomID: 1, Message: "a🌕 quote", Date: 1700000000, Media: &domain.MessageMedia{Kind: domain.MessageMediaKindContact, Contact: &domain.MessageContact{FirstName: "snapshot", PhoneNumber: "123"}}})
if err != nil {
t.Fatal(err)
}
for _, manual := range []bool{false, true} {
reply := &tg.InputReplyToMessage{ReplyToMsgID: seed.SenderMessage.ID}
reply.SetReplyToPeerID(&tg.InputPeerSelf{})
random := int64(2)
if manual {
random = 3
reply.SetQuoteText("quote")
reply.SetQuoteOffset(4)
}
req := &tg.MessagesSendMessageRequest{Peer: &tg.InputPeerUser{UserID: b.ID, AccessHash: b.AccessHash}, Message: "external", RandomID: random}
req.SetReplyTo(reply)
out, err := r.onMessagesSendMessage(ctx, req)
if err != nil {
t.Fatal(err)
}
m := out.(*tg.Updates).Updates[1].(*tg.UpdateNewMessage).Message.(*tg.Message)
h := m.ReplyTo.(*tg.MessageReplyHeader)
if h.ReplyFrom.FromID.(*tg.PeerUser).UserID != a.ID || h.ReplyFrom.Date != seed.SenderMessage.Date || h.ReplyToMsgID != seed.SenderMessage.ID || h.Quote != manual {
t.Fatalf("sender reply=%+v", h)
}
if manual {
if h.QuoteText != "quote" || h.QuoteOffset != 4 {
t.Fatal("manual quote")
}
} else if h.QuoteText != seed.SenderMessage.Body {
t.Fatal("external preview text missing")
}
if h.ReplyMedia.(*tg.MessageMediaContact).FirstName != "snapshot" {
t.Fatal("media snapshot")
}
ownerEvents := savedForwardEvents(t, events, b.ID)[0]
recipient := ownerEvents[len(ownerEvents)-1].Message
rh := tgMessageReplyHeader(recipient).(*tg.MessageReplyHeader)
if _, set := rh.GetReplyToMsgID(); set {
t.Fatal("recipient received sender-owned source ID")
}
if !reflect.DeepEqual(rh.ReplyFrom, h.ReplyFrom) {
t.Fatal("external author differs by owner")
}
if manual {
if _, err := store.DeleteMessages(ctx, domain.DeleteMessagesRequest{OwnerUserID: a.ID, IDs: []int{seed.SenderMessage.ID}}); err != nil {
t.Fatal(err)
}
}
if manual {
before := savedForwardEvents(t, events, a.ID, b.ID)
replayed, err := r.onMessagesSendMessage(ctx, req)
if err != nil || !reflect.DeepEqual(out.(*tg.Updates).Updates, replayed.(*tg.Updates).Updates) || !reflect.DeepEqual(before, savedForwardEvents(t, events, a.ID, b.ID)) {
t.Fatalf("exact replay=%v", err)
}
}
}
protected, err := store.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: a.ID, RecipientUserID: a.ID, RandomID: 10, Message: "protected", NoForwards: true, Date: 1700000001})
if err != nil {
t.Fatal(err)
}
reply := &tg.InputReplyToMessage{ReplyToMsgID: protected.SenderMessage.ID}
reply.SetReplyToPeerID(&tg.InputPeerSelf{})
req := &tg.MessagesSendMessageRequest{Peer: &tg.InputPeerUser{UserID: b.ID, AccessHash: b.AccessHash}, Message: "forbidden", RandomID: 11}
req.SetReplyTo(reply)
before := savedForwardEvents(t, events, a.ID, b.ID)
if _, err := r.onMessagesSendMessage(ctx, req); !tgerr.Is(err, "CHAT_FORWARDS_RESTRICTED") {
t.Fatalf("protected source=%v", err)
}
if !reflect.DeepEqual(before, savedForwardEvents(t, events, a.ID, b.ID)) {
t.Fatal("rejection wrote event")
}
}

View file

@ -514,7 +514,7 @@ func (r *Router) forwardFromPeerAndSources(ctx context.Context, userID int64, in
}
return fromPeer, sources, nil
}
fromPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input)
fromPeer, err := r.checkedMessageReadPeer(ctx, userID, input, false)
return fromPeer, nil, err
}
@ -682,7 +682,9 @@ func (r *Router) forwardSourcesFromPrivateMessages(ctx context.Context, userID i
if fromPeer.Type != domain.PeerTypeUser || fromPeer.ID == 0 {
return nil, domain.ErrMessageIDInvalid
}
if svc, ok := r.deps.Messages.(PrivateNoForwardsService); ok {
// Saved Messages has no two-user protection state. Its individual source
// messages still pass the noforwards check below, including inferred peers.
if svc, ok := r.deps.Messages.(PrivateNoForwardsService); ok && fromPeer.ID != userID {
state, err := svc.GetPrivateNoForwards(ctx, userID, fromPeer.ID)
if err != nil {
return nil, err
@ -771,6 +773,8 @@ func messageForwardErr(err error) error {
return messageIDInvalidErr()
case errors.Is(err, domain.ErrChatForwardsRestricted):
return chatForwardsRestrictedErr()
case errors.Is(err, domain.ErrQuoteTextInvalid):
return quoteTextInvalidErr()
case errors.Is(err, domain.ErrReplyMessageIDInvalid):
return replyMessageIDInvalidErr()
case errors.Is(err, domain.ErrMessageRandomIDDuplicate):

View file

@ -806,10 +806,13 @@ func tgGlobalSearchMessages(viewerUserID int64, limit int, private domain.Messag
return &tg.MessagesMessages{Messages: messages, Chats: chats, Users: users}
}
func (r *Router) messageFilterFromHistoryRequest(userID int64, req *tg.MessagesGetHistoryRequest) (domain.MessageFilter, bool) {
peer, ok := r.domainPeerFromInputPeer(userID, req.Peer)
if !ok {
return domain.MessageFilter{}, false
func (r *Router) messageFilterFromHistoryRequest(ctx context.Context, userID int64, req *tg.MessagesGetHistoryRequest) (domain.MessageFilter, error) {
if err := validateMessageReadBounds(req.Limit, req.OffsetID, req.MaxID, req.MinID); err != nil {
return domain.MessageFilter{}, err
}
peer, err := r.checkedMessageReadPeer(ctx, userID, req.Peer, false)
if err != nil {
return domain.MessageFilter{}, err
}
limit := req.Limit
if limit > 50 {
@ -825,10 +828,13 @@ func (r *Router) messageFilterFromHistoryRequest(userID int64, req *tg.MessagesG
MaxID: req.MaxID,
MinID: req.MinID,
Hash: req.Hash,
}, true
}, nil
}
func (r *Router) messageFilterFromSearchRequest(ctx context.Context, userID int64, req *tg.MessagesSearchRequest) (domain.MessageFilter, error) {
if err := validateMessageReadBounds(req.Limit, req.OffsetID, req.MaxID, req.MinID); err != nil {
return domain.MessageFilter{}, err
}
limit := req.Limit
if limit > 500 {
limit = 500
@ -840,6 +846,7 @@ func (r *Router) messageFilterFromSearchRequest(ctx context.Context, userID int6
MaxDate: req.MaxDate,
AddOffset: domain.ClampMessageHistoryAddOffset(req.AddOffset),
Limit: limit,
CountOnly: req.Limit == 0,
MaxID: req.MaxID,
MinID: req.MinID,
Hash: req.Hash,
@ -850,10 +857,21 @@ func (r *Router) messageFilterFromSearchRequest(ctx context.Context, userID int6
filter.PhoneCallsOnly = true
filter.MissedPhoneCallsOnly = phoneCalls.Missed
}
if peer, ok := r.domainPeerFromInputPeer(userID, req.Peer); ok {
if empty, ok := req.Peer.(*tg.InputPeerEmpty); !ok || empty == nil {
peer, err := r.checkedMessageReadPeer(ctx, userID, req.Peer, false)
if err != nil {
return domain.MessageFilter{}, err
}
filter.HasPeer = true
filter.Peer = peer
}
if req.FromID != nil {
from, err := r.checkedMessageReadPeer(ctx, userID, req.FromID, true)
if err != nil {
return domain.MessageFilter{}, err
}
filter.SenderUserID = from.ID
}
savedReactions, hasSavedReactions := req.GetSavedReaction()
// An empty optional vector carries no reaction-filtering semantics. Some TL
// clients emit flags.3 with a zero-length vector on ordinary peer searches.

View file

@ -580,7 +580,7 @@ func TestMessagesGetHistoryReturnsStoredMessages(t *testing.T) {
Hash: 99,
},
}
r := New(Config{}, Deps{Messages: messages}, zaptest.NewLogger(t), clock.System)
r := New(Config{}, Deps{Messages: messages, Users: mapUsersService{users: map[int64]domain.User{domain.OfficialSystemUserID: domain.OfficialSystemUser()}}}, zaptest.NewLogger(t), clock.System)
req := &tg.MessagesGetHistoryRequest{
Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash},
Limit: 20,

View file

@ -206,7 +206,7 @@ func TestSavedReactionTagHashMatchesClientShape(t *testing.T) {
func TestMessageFilterFromSearchRequestParsesSavedTagsAndPeer(t *testing.T) {
const userID = int64(1000000001)
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
r := New(Config{}, Deps{Users: mapUsersService{users: map[int64]domain.User{userID + 1: {ID: userID + 1, AccessHash: 1}}}}, zaptest.NewLogger(t), clock.System)
req := &tg.MessagesSearchRequest{
Peer: &tg.InputPeerSelf{},
Q: "needle",

View file

@ -0,0 +1,83 @@
package rpc
import (
"context"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
type privateSendBaseUserResolver interface {
BaseUsersByIDs(ctx context.Context, userIDs []int64) ([]domain.User, error)
}
// Read endpoints retain their existing positive page caps and signed
// add_offset semantics. Invalid IDs must not become an unbounded query.
func validateMessageReadBounds(limit, offsetID, maxID, minID int) error {
if limit < 0 {
return limitInvalidErr()
}
for _, id := range [...]int{offsetID, maxID, minID} {
if id < 0 || id > domain.MaxMessageBoxID {
return msgIDInvalidErr()
}
}
return nil
}
// checkedMessageReadPeer never treats an invalid reference as global scope.
// Only the search parser admits InputPeerEmpty, explicitly. Base identity
// lookup avoids loading viewer-specific user projections for count requests.
func (r *Router) checkedMessageReadPeer(ctx context.Context, userID int64, input tg.InputPeerClass, sender bool) (domain.Peer, error) {
invalid := peerIDInvalidErr
if sender {
invalid = fromPeerInvalidErr
}
if inputPeerClassNil(input) {
return domain.Peer{}, invalid()
}
switch peer := input.(type) {
case *tg.InputPeerSelf:
if userID <= 0 {
return domain.Peer{}, invalid()
}
return domain.Peer{Type: domain.PeerTypeUser, ID: userID}, nil
case *tg.InputPeerUser:
if peer.UserID <= 0 {
return domain.Peer{}, invalid()
}
if r.deps.Users == nil {
return domain.Peer{}, internalErr()
}
if resolver, ok := r.deps.Users.(privateSendBaseUserResolver); ok {
users, err := resolver.BaseUsersByIDs(ctx, []int64{peer.UserID})
if err != nil {
return domain.Peer{}, internalErr()
}
for _, user := range users {
if user.ID == peer.UserID && user.AccessHash == peer.AccessHash {
return domain.Peer{Type: domain.PeerTypeUser, ID: peer.UserID}, nil
}
}
return domain.Peer{}, invalid()
}
user, found, err := r.deps.Users.ByID(ctx, userID, peer.UserID)
if err != nil {
return domain.Peer{}, internalErr()
}
if found && user.AccessHash == peer.AccessHash {
return domain.Peer{Type: domain.PeerTypeUser, ID: peer.UserID}, nil
}
return domain.Peer{}, invalid()
default:
if sender {
return domain.Peer{}, invalid()
}
resolved, ok := r.domainPeerFromInputPeer(userID, input)
if !ok || resolved.ID <= 0 {
return domain.Peer{}, invalid()
}
return r.checkedDomainPeerFromInputPeer(ctx, userID, input)
}
}

View file

@ -0,0 +1,83 @@
package rpc
import (
"context"
"errors"
"testing"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap/zaptest"
"telesrv/internal/domain"
)
type failingReadUsers struct{ mapUsersService }
func (failingReadUsers) BaseUsersByIDs(context.Context, []int64) ([]domain.User, error) {
return nil, errors.New("identity unavailable")
}
func TestMessageReadInputScope(t *testing.T) {
users := &countingMapUsersService{mapUsersService: mapUsersService{users: map[int64]domain.User{22: {ID: 22, AccessHash: 77}}}}
r := New(Config{}, Deps{Users: users}, zaptest.NewLogger(t), clock.System)
ctx := context.Background()
invalid := []tg.InputPeerClass{nil, (*tg.InputPeerUser)(nil), &tg.InputPeerEmpty{}, &tg.InputPeerUser{UserID: 0}, &tg.InputPeerUser{UserID: -22}, &tg.InputPeerUser{UserID: 22, AccessHash: 0}, &tg.InputPeerUser{UserID: 22, AccessHash: 78}, &tg.InputPeerUser{UserID: 23, AccessHash: 77}, &tg.InputPeerUserFromMessage{Peer: &tg.InputPeerSelf{}, MsgID: 1, UserID: 22}}
for i, p := range invalid {
for _, sender := range []bool{false, true} {
_, err := r.checkedMessageReadPeer(ctx, 11, p, sender)
want := "PEER_ID_INVALID"
if sender {
want = "FROM_PEER_INVALID"
}
if !tgerr.Is(err, want) {
t.Fatalf("invalid[%d], sender=%t err=%v", i, sender, err)
}
}
}
for _, p := range []tg.InputPeerClass{&tg.InputPeerEmpty{}, &tg.InputPeerSelf{}, &tg.InputPeerUser{UserID: 22, AccessHash: 77}} {
req := &tg.MessagesSearchRequest{Peer: p, FromID: &tg.InputPeerUser{UserID: 22, AccessHash: 77}, Filter: &tg.InputMessagesFilterEmpty{}}
f, err := r.messageFilterFromSearchRequest(ctx, 11, req)
_, global := p.(*tg.InputPeerEmpty)
if err != nil || f.HasPeer == global || f.SenderUserID != 22 || !f.CountOnly {
t.Fatalf("scope=%T filter=%+v err=%v", p, f, err)
}
}
if users.byIDCalls != 0 || users.byIDsCalls != 0 || users.selfCalls != 0 || users.baseByIDsCalls == 0 {
t.Fatalf("read validation projected users: %+v", users)
}
for _, deps := range []Deps{{}, {Users: failingReadUsers{}}} {
broken := New(Config{}, deps, zaptest.NewLogger(t), clock.System)
_, err := broken.checkedMessageReadPeer(ctx, 11, &tg.InputPeerUser{UserID: 22, AccessHash: 77}, false)
if !tgerr.Is(err, "INTERNAL_SERVER_ERROR") {
t.Fatalf("missing identity boundary err=%v", err)
}
}
}
func TestMessageReadBoundsAndCaps(t *testing.T) {
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
ctx := context.Background()
for _, tt := range []struct {
limit, offset, max, min int
want string
}{{-1, 0, 0, 0, "LIMIT_INVALID"}, {0, -1, 0, 0, "MSG_ID_INVALID"}, {0, 0, -1, 0, "MSG_ID_INVALID"}, {0, 0, 0, -1, "MSG_ID_INVALID"}, {0, domain.MaxMessageBoxID + 1, 0, 0, "MSG_ID_INVALID"}, {0, 0, domain.MaxMessageBoxID + 1, 0, "MSG_ID_INVALID"}} {
_, se := r.messageFilterFromSearchRequest(ctx, 11, &tg.MessagesSearchRequest{Peer: &tg.InputPeerSelf{}, Limit: tt.limit, OffsetID: tt.offset, MaxID: tt.max, MinID: tt.min})
_, he := r.messageFilterFromHistoryRequest(ctx, 11, &tg.MessagesGetHistoryRequest{Peer: &tg.InputPeerSelf{}, Limit: tt.limit, OffsetID: tt.offset, MaxID: tt.max, MinID: tt.min})
if !tgerr.Is(se, tt.want) || !tgerr.Is(he, tt.want) {
t.Fatalf("bounds %+v search=%v history=%v", tt, se, he)
}
}
s, err := r.messageFilterFromSearchRequest(ctx, 11, &tg.MessagesSearchRequest{Peer: &tg.InputPeerSelf{}, Limit: 501, AddOffset: -2})
if err != nil || s.Limit != 500 || s.AddOffset != -2 || s.CountOnly {
t.Fatalf("search %+v %v", s, err)
}
h, err := r.messageFilterFromHistoryRequest(ctx, 11, &tg.MessagesGetHistoryRequest{Peer: &tg.InputPeerSelf{}, Limit: 501, AddOffset: -2})
if err != nil || h.Limit != 50 || h.AddOffset != -2 || h.CountOnly {
t.Fatalf("history %+v %v", h, err)
}
h, err = r.messageFilterFromHistoryRequest(ctx, 11, &tg.MessagesGetHistoryRequest{Peer: &tg.InputPeerSelf{}})
if err != nil || h.CountOnly {
t.Fatalf("default history %+v %v", h, err)
}
}

View file

@ -611,9 +611,9 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
if err != nil {
return nil, internalErr()
}
filter, ok := r.messageFilterFromHistoryRequest(userID, req)
if !ok {
return messagesNotModifiedOrEmpty(req.Hash), nil
filter, err := r.messageFilterFromHistoryRequest(ctx, userID, req)
if err != nil {
return nil, err
}
if filter.Peer.Type == domain.PeerTypeChannel {
if r.deps.Channels == nil {

View file

@ -0,0 +1,259 @@
package rpc
import (
"context"
"errors"
"fmt"
"reflect"
"testing"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap/zaptest"
appdialogs "telesrv/internal/app/dialogs"
appmessages "telesrv/internal/app/messages"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func savedForwardFixture(t *testing.T) (*Router, *memory.MessageStore, *memory.UpdateEventStore, domain.User, domain.User) {
t.Helper()
ctx := context.Background()
users := memory.NewUserStore()
a, err := users.Create(ctx, domain.User{AccessHash: 51, Phone: "15550009501", FirstName: "A"})
if err != nil {
t.Fatal(err)
}
b, err := users.Create(ctx, domain.User{AccessHash: 52, Phone: "15550009502", FirstName: "B"})
if err != nil {
t.Fatal(err)
}
dialogs := memory.NewDialogStore()
messages := memory.NewMessageStore(dialogs)
events := memory.NewUpdateEventStore()
messages.AttachUpdateEventStore(events)
r := New(Config{}, Deps{Users: appusers.NewService(users), Dialogs: appdialogs.NewService(dialogs), Messages: appmessages.NewService(messages, dialogs)}, zaptest.NewLogger(t), clock.System)
return r, messages, events, a, b
}
func savedForwardEvents(t *testing.T, events *memory.UpdateEventStore, owners ...int64) [][]domain.UpdateEvent {
t.Helper()
result := make([][]domain.UpdateEvent, len(owners))
for i, owner := range owners {
var err error
result[i], err = events.ListAfter(context.Background(), owner, 0, 100)
if err != nil {
t.Fatal(err)
}
}
return result
}
func TestSavedForwardSourceScopeProtectionAndDeletedReplay(t *testing.T) {
for _, scope := range []string{"self", "explicit-user", "inferred"} {
for _, saved := range []bool{false, true} {
for _, protected := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/saved=%v/protected=%v", scope, saved, protected), func(t *testing.T) {
r, store, events, a, b := savedForwardFixture(t)
ctx := WithUserID(context.Background(), a.ID)
seed, err := store.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: a.ID, RecipientUserID: a.ID, RandomID: 1, Message: "saved 🌕 quote", NoForwards: protected, Date: 1700000000})
if err != nil {
t.Fatal(err)
}
to := tg.InputPeerClass(&tg.InputPeerSelf{})
targetSender := a.ID
if !saved {
to = &tg.InputPeerUser{UserID: b.ID, AccessHash: b.AccessHash}
targetSender = b.ID
}
target, err := store.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: targetSender, RecipientUserID: a.ID, RandomID: 2, Message: "target", Date: 1700000001})
if err != nil {
t.Fatal(err)
}
from := tg.InputPeerClass(&tg.InputPeerSelf{})
if scope == "explicit-user" {
from = &tg.InputPeerUser{UserID: a.ID, AccessHash: a.AccessHash}
}
if scope == "inferred" {
from = &tg.InputPeerEmpty{}
}
req := &tg.MessagesForwardMessagesRequest{FromPeer: from, ToPeer: to, ID: []int{seed.SenderMessage.ID}, RandomID: []int64{3}}
reply := &tg.InputReplyToMessage{ReplyToMsgID: target.RecipientMessage.ID}
reply.SetQuoteText("target")
req.SetReplyTo(reply)
before := savedForwardEvents(t, events, a.ID, b.ID)
out, err := r.onMessagesForwardMessages(ctx, req)
if protected {
if !tgerr.Is(err, "CHAT_FORWARDS_RESTRICTED") || !reflect.DeepEqual(before, savedForwardEvents(t, events, a.ID, b.ID)) {
t.Fatalf("protected Saved source err=%v or wrote events", err)
}
return
}
if err != nil {
t.Fatal(err)
}
full := out.(*tg.Updates)
msg := full.Updates[1].(*tg.UpdateNewMessage).Message.(*tg.Message)
if msg.Message != seed.SenderMessage.Body || msg.FwdFrom.FromID.(*tg.PeerUser).UserID != a.ID || msg.FwdFrom.Date != seed.SenderMessage.Date || msg.ReplyTo.(*tg.MessageReplyHeader).ReplyToMsgID != target.RecipientMessage.ID {
t.Fatalf("forward metadata: %+v", msg)
}
after := savedForwardEvents(t, events, a.ID, b.ID)
for i := range after {
want := 1
if saved && i == 1 {
want = 0
}
if len(after[i])-len(before[i]) != want {
t.Fatal("new forward event cardinality")
}
if want == 1 && after[i][len(after[i])-1].PtsCount != 1 {
t.Fatal("new forward PTS count")
}
}
if !saved {
received := after[1][len(after[1])-1].Message
if received.ReplyTo == nil || received.ReplyTo.MessageID != target.SenderMessage.ID {
t.Fatal("recipient reply not mapped")
}
}
if _, err := store.DeleteMessages(ctx, domain.DeleteMessagesRequest{OwnerUserID: a.ID, IDs: []int{seed.SenderMessage.ID}, Date: 1700000002}); err != nil {
t.Fatal(err)
}
beforeReplay := savedForwardEvents(t, events, a.ID, b.ID)
replay, err := r.onMessagesForwardMessages(ctx, req)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(full.Updates, replay.(*tg.Updates).Updates) || !reflect.DeepEqual(beforeReplay, savedForwardEvents(t, events, a.ID, b.ID)) {
t.Fatal("replay changed message or appended event")
}
fresh := *req
fresh.RandomID = []int64{4}
if _, err := r.onMessagesForwardMessages(ctx, &fresh); !tgerr.Is(err, "MESSAGE_ID_INVALID") {
t.Fatalf("fresh deleted source err=%v", err)
}
if !reflect.DeepEqual(beforeReplay, savedForwardEvents(t, events, a.ID, b.ID)) {
t.Fatal("rejected fresh request appended event")
}
})
}
}
}
}
// The injected boundary fails before the second send enters the real store.
// All successful sends and replay lookups retain the normal service/store path.
type failSecondForward struct {
*appmessages.Service
failRandom int64
calls []int64
}
func (s *failSecondForward) SendPrivateText(ctx context.Context, user int64, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) {
s.calls = append(s.calls, req.RandomID)
if req.RandomID == s.failRandom {
return domain.SendPrivateTextResult{}, errors.New("injected second-send failure")
}
return s.Service.SendPrivateText(ctx, user, req)
}
func TestSavedForwardPartialCommitRetryAfterCommittedSourceDeleted(t *testing.T) {
r, store, events, a, b := savedForwardFixture(t)
ctx := WithUserID(context.Background(), a.ID)
ids := []int{}
for i := int64(1); i <= 2; i++ {
source, err := store.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: a.ID, RecipientUserID: a.ID, RandomID: i, Message: fmt.Sprintf("source-%d", i), Date: 1700000000})
if err != nil {
t.Fatal(err)
}
ids = append(ids, source.SenderMessage.ID)
}
fault := &failSecondForward{Service: r.deps.Messages.(*appmessages.Service), failRandom: 12}
r.deps.Messages = fault
req := &tg.MessagesForwardMessagesRequest{FromPeer: &tg.InputPeerSelf{}, ToPeer: &tg.InputPeerUser{UserID: b.ID, AccessHash: b.AccessHash}, ID: ids, RandomID: []int64{11, 12}}
before := savedForwardEvents(t, events, a.ID, b.ID)
if _, err := r.onMessagesForwardMessages(ctx, req); !tgerr.Is(err, "INTERNAL_SERVER_ERROR") {
t.Fatalf("partial send err=%v", err)
}
after := savedForwardEvents(t, events, a.ID, b.ID)
for i := range after {
if len(after[i])-len(before[i]) != 1 {
t.Fatal("first item must commit before second fails")
}
}
first := after[0][len(after[0])-1].Message
if _, err := store.DeleteMessages(ctx, domain.DeleteMessagesRequest{OwnerUserID: a.ID, IDs: ids[:1], Date: 1700000001}); err != nil {
t.Fatal(err)
}
before = savedForwardEvents(t, events, a.ID, b.ID)
fault.failRandom = 0
out, err := r.onMessagesForwardMessages(ctx, req)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(fault.calls, []int64{11, 12, 12}) {
t.Fatalf("send calls=%v; committed item must not be re-sent", fault.calls)
}
full := out.(*tg.Updates)
if full.Updates[0].(*tg.UpdateMessageID).ID != first.ID || len(full.Updates) != 4 {
t.Fatal("partial retry lost first committed ID")
}
after = savedForwardEvents(t, events, a.ID, b.ID)
for i := range after {
if len(after[i])-len(before[i]) != 1 {
t.Fatal("retry must only append second item")
}
}
if _, err := r.onMessagesForwardMessages(ctx, req); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(after, savedForwardEvents(t, events, a.ID, b.ID)) {
t.Fatal("full replay appended events")
}
}
func TestReplyAndForwardExplicitSourceCredentials(t *testing.T) {
for _, method := range []string{"reply", "forward"} {
for _, wrong := range []tg.InputPeerClass{&tg.InputPeerUser{UserID: 22, AccessHash: 78}, &tg.InputPeerUser{UserID: 23, AccessHash: 77}, &tg.InputPeerEmpty{}} {
t.Run(fmt.Sprintf("%s/%#v", method, wrong), func(t *testing.T) {
messages := &captureMessages{}
r := New(Config{}, Deps{Messages: messages, Users: mapUsersService{users: map[int64]domain.User{22: {ID: 22, AccessHash: 77}}}}, zaptest.NewLogger(t), clock.System)
ctx := WithUserID(context.Background(), 11)
var err error
if method == "reply" {
req := &tg.MessagesSendMessageRequest{Peer: &tg.InputPeerSelf{}, Message: "reply", RandomID: 91}
reply := &tg.InputReplyToMessage{ReplyToMsgID: 7}
reply.SetReplyToPeerID(wrong)
req.SetReplyTo(reply)
_, err = r.onMessagesSendMessage(ctx, req)
if !tgerr.Is(err, "REPLY_MESSAGE_ID_INVALID") {
t.Fatalf("reply err=%v", err)
}
} else {
_, err = r.onMessagesForwardMessages(ctx, &tg.MessagesForwardMessagesRequest{FromPeer: wrong, ToPeer: &tg.InputPeerSelf{}, ID: []int{7}, RandomID: []int64{92}})
// Empty from_peer is permitted only when an owned source can be inferred.
want := "PEER_ID_INVALID"
if _, ok := wrong.(*tg.InputPeerEmpty); ok {
want = "MESSAGE_ID_INVALID"
}
if !tgerr.Is(err, want) {
t.Fatalf("forward err=%v want=%s", err, want)
}
}
if messages.sendReq.RandomID != 0 {
t.Fatal("invalid credentials reached write service")
}
})
}
}
for _, deps := range []Deps{{}, {Users: failingReadUsers{}}} {
r := New(Config{}, deps, zaptest.NewLogger(t), clock.System)
reply := &tg.InputReplyToMessage{ReplyToMsgID: 1}
reply.SetReplyToPeerID(&tg.InputPeerUser{UserID: 22, AccessHash: 77})
if _, err := r.messageReplyFromInput(context.Background(), 11, domain.Peer{Type: domain.PeerTypeUser, ID: 11}, reply); !tgerr.Is(err, "INTERNAL_SERVER_ERROR") {
t.Fatalf("identity unavailable err=%v", err)
}
}
}

View file

@ -8,6 +8,7 @@ import (
"unicode/utf8"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"telesrv/internal/domain"
)
@ -273,6 +274,10 @@ func messageSendErr(err error) error {
return randomIDDuplicateErr()
case errors.Is(err, domain.ErrMessageEmpty):
return messageEmptyErr()
case errors.Is(err, domain.ErrChatForwardsRestricted):
return chatForwardsRestrictedErr()
case errors.Is(err, domain.ErrQuoteTextInvalid):
return quoteTextInvalidErr()
default:
return internalErr()
}
@ -330,8 +335,11 @@ func (r *Router) messageReplyFromInput(ctx context.Context, userID int64, peer d
}
replyPeer := peer
if inputPeer, ok := reply.GetReplyToPeerID(); ok {
parsed, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inputPeer)
parsed, err := r.checkedMessageReadPeer(ctx, userID, inputPeer, false)
if err != nil {
if tgerr.Is(err, "INTERNAL_SERVER_ERROR") {
return nil, err
}
return nil, replyMessageIDInvalidErr()
}
replyPeer = parsed

View file

@ -52,6 +52,8 @@ type presenceTracker struct {
// offlineTimers 跟踪每个 user 挂起的 offline 广播去抖定时器,使重新上线能取消它,
// 避免断连风暴下 O(N) 个裸 time.AfterFunc 在 runtime timer heap 长期堆积。
offlineTimers map[int64]*time.Timer
offlineRunning int64
lastSeenDirectRunning int64
}
func newPresenceTracker() *presenceTracker {
@ -62,8 +64,8 @@ func newPresenceTracker() *presenceTracker {
}
}
// armOfflineTimer 安排(或重置)某 user 的 offline 广播去抖定时器。定时器触发时先把自己
// 从 map 移除再执行 firefire 内部会查在线态做最终去抖),全程不持 p.mu 调用 fire。
// armOfflineTimer replaces the user's pending grace timer. Stop cannot join
// an already-started AfterFunc; the callback must still prove timer ownership.
func (p *presenceTracker) armOfflineTimer(userID int64, d time.Duration, fire func()) {
if p == nil || userID == 0 {
return
@ -75,12 +77,24 @@ func (p *presenceTracker) armOfflineTimer(userID int64, d time.Duration, fire fu
if old := p.offlineTimers[userID]; old != nil {
old.Stop()
}
p.offlineTimers[userID] = time.AfterFunc(d, func() {
var timer *time.Timer
timer = time.AfterFunc(d, func() {
p.mu.Lock()
delete(p.offlineTimers, userID)
if p.offlineTimers[userID] != timer {
p.mu.Unlock()
return
}
delete(p.offlineTimers, userID)
p.offlineRunning++
p.mu.Unlock()
defer func() {
p.mu.Lock()
p.offlineRunning--
p.mu.Unlock()
}()
fire()
})
p.offlineTimers[userID] = timer
p.mu.Unlock()
}
@ -419,8 +433,13 @@ func (r *Router) persistReservedLastSeenAsync(
lastSeenAt int,
) {
bgCtx, cancel := r.presenceBackgroundContext(ctx, 10*time.Second)
// Keep the direct overflow write visible after its parent callback returns.
// The production batch normally handles this work; admission errors retain
// their existing explicit log and authoritative write behavior.
r.presence.changeDirectLastSeenRunning(1)
go func() {
defer cancel()
defer r.presence.changeDirectLastSeenRunning(-1)
defer func() {
if rec := recover(); rec != nil {
r.log.Error("Update user last seen panicked", zap.Int64("user_id", userID), zap.Any("panic", rec))
@ -488,6 +507,12 @@ func (r *Router) presenceBackgroundContext(ctx context.Context, timeout time.Dur
// 去抖宽限后在后台带超时执行——连接关闭发生在 serveConn 退出路径上,同步做
// DB 写与逐 peer 查询会在断连风暴时放大 DB 压力并拖住 goroutine 退出。
func (r *Router) SessionOffline(rawAuthKeyID [8]byte, sessionID, userID int64, lastForUser bool) {
r.SessionOfflineAt(rawAuthKeyID, sessionID, userID, lastForUser, int(r.clock.Now().Unix()))
}
// SessionOfflineAt preserves the observed disconnect time and is idempotent
// for repeated physical-session departure reports.
func (r *Router) SessionOfflineAt(rawAuthKeyID [8]byte, sessionID, userID int64, lastForUser bool, disconnectedAt int) {
r.forgetClientSessionInfo(rawAuthKeyID, sessionID)
if userID == 0 {
return
@ -497,7 +522,9 @@ func (r *Router) SessionOffline(rawAuthKeyID [8]byte, sessionID, userID int64, l
if !lastForUser {
return
}
disconnectedAt := int(r.clock.Now().Unix())
if disconnectedAt <= 0 {
disconnectedAt = int(r.clock.Now().Unix())
}
// 用可跟踪的定时器,重新上线时能取消(见 cancelOfflineTimer避免断连风暴堆积。
r.presence.armOfflineTimer(userID, offlineAnnounceGrace, func() {
r.announceUserOfflineIfStillGone(rawAuthKeyID, sessionID, userID, disconnectedAt)

View file

@ -5,6 +5,7 @@ import (
"errors"
"sort"
"sync"
"sync/atomic"
"time"
"go.uber.org/zap"
@ -71,6 +72,7 @@ type presenceLastSeenBatchDispatcher struct {
gate sync.RWMutex
accepting bool
pending atomic.Int64
}
func newPresenceLastSeenBatchDispatcher(
@ -89,6 +91,7 @@ func newPresenceLastSeenBatchDispatcher(
if metrics == nil {
metrics = NopMetrics{}
}
metrics.PresenceLastSeenPending(0)
return &presenceLastSeenBatchDispatcher{
updater: updater,
cfg: cfg,
@ -108,18 +111,23 @@ func (d *presenceLastSeenBatchDispatcher) submit(update store.UserLastSeenUpdate
if !d.accepting {
return errPresenceLastSeenBatchStopped
}
d.metrics.PresenceLastSeenPending(1)
d.changePending(1)
select {
case d.queue <- update:
d.metrics.PresenceLastSeenSubmitted()
return nil
default:
d.metrics.PresenceLastSeenPending(-1)
d.changePending(-1)
d.metrics.PresenceLastSeenOverflow()
return errPresenceLastSeenBatchFull
}
}
func (d *presenceLastSeenBatchDispatcher) changePending(delta int) {
d.pending.Add(int64(delta))
d.metrics.PresenceLastSeenPending(delta)
}
func (d *presenceLastSeenBatchDispatcher) stopAccepting() {
if d == nil {
return
@ -223,7 +231,7 @@ func (d *presenceLastSeenBatchDispatcher) executeWithRetry(
cancel()
d.metrics.PresenceLastSeenBatch(len(updates), time.Since(started), err)
if err == nil {
d.metrics.PresenceLastSeenPending(-rawCount)
d.changePending(-rawCount)
return true
}
if attempt == 1 || attempt&(attempt-1) == 0 {
@ -286,6 +294,6 @@ func (d *presenceLastSeenBatchDispatcher) reportDrainDropped(count int) {
return
}
d.metrics.PresenceLastSeenDrainDropped(count)
d.metrics.PresenceLastSeenPending(-count)
d.changePending(-count)
d.log.Error("presence last-seen shutdown drain expired", zap.Int("updates", count))
}

View file

@ -3,6 +3,8 @@ package rpc
import (
"context"
"errors"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
@ -10,9 +12,27 @@ import (
"go.uber.org/zap/zaptest"
obsmetrics "telesrv/internal/observability/metrics"
"telesrv/internal/store"
)
func TestPresenceLastSeenBatchExportsIdlePending(t *testing.T) {
registry := obsmetrics.New()
updater := &capturePresenceLastSeenUpdater{}
d := newPresenceLastSeenBatchDispatcher(updater, presenceLastSeenBatchConfig{}, zaptest.NewLogger(t), registry)
if d == nil {
t.Fatal("presence owner was not constructed")
}
recorder := httptest.NewRecorder()
registry.ServeHTTP(recorder, httptest.NewRequest("GET", "/metrics", nil))
if !strings.Contains(recorder.Body.String(), "telesrv_presence_last_seen_pending 0\n") || len(updater.snapshot()) != 0 {
t.Fatalf("idle owner must expose zero pending without a last-seen write: %s", recorder.Body.String())
}
if strings.Contains(recorder.Body.String(), "telesrv_presence_last_seen_submitted_total") {
t.Fatal("idle registration fabricated a last-seen submission")
}
}
type capturePresenceLastSeenUpdater struct {
mu sync.Mutex
calls [][]store.UserLastSeenUpdate

Some files were not shown because too many files have changed in this diff Show more