From 2f1818d656d43e85046c5433d745295c5d7b8a72 Mon Sep 17 00:00:00 2001 From: onysd Date: Wed, 9 Sep 2026 02:49:30 +0300 Subject: [PATCH] merged with fixes --- cmd/telesrv-admin/server.go | 47 + .../web/dist/assets/index-Ch2HZ77j.js | 9 + .../web/dist/assets/index-y9w88zN5.css | 1 + cmd/telesrv-admin/web/src/api.ts | 1 + .../web/src/components/AvatarModal.tsx | 40 +- ...181_sticker_set_system_key_unique.down.sql | 4 + .../0181_sticker_set_system_key_unique.up.sql | 12 + ...1_phone_identity_canonicalization.down.sql | 3 + ...001_phone_identity_canonicalization.up.sql | 5 + ...0908000002_private_external_reply.down.sql | 8 + ...260908000002_private_external_reply.up.sql | 7 + go.mod | 3 +- internal/admin/service.go | 65 + internal/adminapi/server.go | 28 +- internal/adminapi/server_test.go | 4 + internal/app/account/lifecycle.go | 2 +- internal/app/bots/botfather.go | 2 +- internal/app/bots/chatbot.go | 4 +- internal/app/bots/stickersbot.go | 10 +- internal/app/bots/verifierbot.go | 8 +- internal/app/bots/verifybot.go | 4 +- internal/app/botverification/seed.go | 2 +- internal/app/files/blobcache.go | 4 +- internal/app/files/diskspace.go | 42 +- internal/app/files/guarded_backend.go | 72 + internal/app/files/photos.go | 49 +- internal/app/files/seed_test.go | 124 +- internal/app/files/upload_parts_test.go | 14 +- internal/app/langpack/service.go | 2 +- internal/app/passkey/service.go | 2 +- internal/app/phone/dh.go | 8 +- internal/app/privacy/service.go | 84 +- internal/app/users/service.go | 6 + internal/branding/branding.go | 164 +- internal/branding/branding_test.go | 65 +- internal/compat/tdesktop/startup_stubs.go | 2 +- internal/config/config.go | 2 +- internal/domain/account.go | 122 +- internal/domain/authorization.go | 3 + internal/domain/emailphone_test.go | 9 +- .../domain/login_welcome_template_test.go | 2 +- internal/domain/media_category.go | 3 +- internal/domain/message.go | 24 +- internal/domain/message_errors.go | 1 + internal/domain/message_reply_external.go | 193 +++ .../domain/message_reply_external_test.go | 92 ++ internal/domain/phone_identity_test.go | 59 + internal/domain/privacy_evaluate.go | 89 ++ internal/domain/system.go | 15 +- internal/loadharness/group_media_load_test.go | 1393 +++++++++++++++++ internal/loadharness/group_media_unit_test.go | 129 ++ internal/mtprotoedge/bulk_rpc_scheduler.go | 190 +++ .../mtprotoedge/bulk_rpc_scheduler_test.go | 92 ++ internal/mtprotoedge/circular_history_test.go | 29 + internal/mtprotoedge/client_ip_e2e_test.go | 169 ++ internal/mtprotoedge/conn.go | 42 +- internal/mtprotoedge/conn_mgmt_fixes_test.go | 4 +- internal/mtprotoedge/encrypted.go | 98 +- internal/mtprotoedge/flush_identity_test.go | 4 +- .../mtprotoedge/inbound_layer_rpc_test.go | 4 +- internal/mtprotoedge/inbound_rpc.go | 7 + internal/mtprotoedge/layer_rpc_execution.go | 56 +- .../mtprotoedge/layer_rpc_execution_test.go | 2 +- internal/mtprotoedge/outbound.go | 783 +++++++-- .../mtprotoedge/outbound_replay_body_pool.go | 102 ++ .../outbound_required_control_test.go | 6 +- internal/mtprotoedge/outbound_test.go | 259 ++- internal/mtprotoedge/rpc_result_egress.go | 12 +- .../mtprotoedge/rpc_result_egress_test.go | 24 + .../rpc_result_retention_budget_test.go | 12 +- internal/mtprotoedge/rpc_rewrap.go | 2 +- internal/mtprotoedge/runtime_metrics.go | 6 + internal/mtprotoedge/server.go | 83 +- .../session_barrier_integration_test.go | 26 +- internal/mtprotoedge/session_manager_test.go | 42 +- internal/mtprotoedge/shutdown_gate_test.go | 4 +- .../mtprotoedge/structural_limits_test.go | 4 +- internal/mtprotoedge/transient_push_test.go | 6 +- internal/observability/metrics/registry.go | 5 +- internal/rpc/aicompose_webpage.go | 2 +- internal/rpc/channels_updates.go | 10 + internal/rpc/context.go | 13 + internal/rpc/convert_auth.go | 3 + internal/rpc/convert_messages.go | 14 +- internal/rpc/errors.go | 4 + internal/rpc/help.go | 6 +- internal/rpc/layer_dispatch.go | 47 +- internal/rpc/messages_external_reply_test.go | 87 + internal/rpc/messages_forward.go | 8 +- internal/rpc/messages_history.go | 30 +- internal/rpc/messages_history_rpc_test.go | 2 +- internal/rpc/messages_reactions_test.go | 2 +- internal/rpc/messages_read_filter.go | 83 + internal/rpc/messages_read_filter_test.go | 83 + internal/rpc/messages_register.go | 6 +- .../messages_saved_forward_boundary_test.go | 259 +++ internal/rpc/messages_send.go | 10 +- internal/rpc/presence.go | 37 +- internal/rpc/presence_last_seen_batch.go | 16 +- internal/rpc/presence_last_seen_batch_test.go | 20 + .../rpc/presence_timer_generation_test.go | 39 + internal/rpc/presence_work.go | 41 + internal/rpc/presence_work_test.go | 202 +++ internal/rpc/router.go | 3 + internal/rpc/router_dispatch_test.go | 25 + internal/rpc/rpc_testkit_auth_test.go | 6 + internal/rpc/rpc_testkit_users_test.go | 16 +- .../rpc/story_peer_projection_rpc_test.go | 1 + internal/rpc/update_peer_refs.go | 8 + internal/rpc/webview_registry.go | 9 +- internal/rpcresult/replay.go | 72 + internal/store/delivery_effect.go | 50 + internal/store/memory/auth.go | 3 + internal/store/memory/bot_branding_test.go | 43 + .../store/memory/media_pagination_test.go | 93 ++ internal/store/memory/media_search.go | 30 +- internal/store/memory/message_helpers.go | 7 +- internal/store/memory/message_history.go | 40 +- internal/store/memory/message_search_test.go | 51 + internal/store/memory/message_send.go | 134 +- internal/store/memory/message_store.go | 6 + internal/store/memory/message_test.go | 10 +- internal/store/memory/updates.go | 45 +- internal/store/message_pin.go | 31 + .../postgres/active_channel_ids_batch.go | 3 + .../postgres/active_channel_ids_batch_test.go | 21 + internal/store/postgres/authorization.go | 3 +- internal/store/postgres/blob_storage_lock.go | 2 +- .../postgres/bootstrap_update_job_batch.go | 3 + .../bootstrap_update_job_batch_test.go | 20 + ...nnel_difference_cursor_integration_test.go | 90 ++ .../channel_difference_integration_test.go | 12 +- internal/store/postgres/channel_listener.go | 3 +- internal/store/postgres/delivery_effect.go | 28 + internal/store/postgres/dialog.go | 2 + .../store/postgres/login_code_delivery.go | 5 +- .../store/postgres/media_pagination_test.go | 106 ++ internal/store/postgres/media_search.go | 110 +- ...message_external_reply_integration_test.go | 335 ++++ internal/store/postgres/message_forward.go | 1 + internal/store/postgres/message_helpers.go | 22 +- internal/store/postgres/message_history.go | 158 +- .../message_history_integration_test.go | 6 +- internal/store/postgres/message_read.go | 6 +- .../postgres/message_search_count_test.go | 56 + .../message_search_integration_test.go | 80 + internal/store/postgres/message_send.go | 120 +- ...ssage_send_idempotency_integration_test.go | 49 +- .../postgres/message_store_testkit_test.go | 148 ++ .../postgres/phone_identity_migration.go | 152 ++ ...one_identity_migration_integration_test.go | 97 ++ internal/store/postgres/postgres.go | 52 + .../postgres/postgres_connection_admission.go | 466 ++++++ .../postgres_connection_admission_test.go | 258 +++ internal/store/postgres/queries/dialog.sql | 4 + internal/store/postgres/queries/message.sql | 40 +- .../store/postgres/queries/saved_dialog.sql | 3 + .../postgres/queries/user_update_event.sql | 2 + .../store/postgres/read_model_listener.go | 3 +- internal/store/postgres/saved_dialog.go | 8 +- internal/store/postgres/scheduled_message.go | 2 +- internal/store/postgres/sqlcgen/dialog.sql.go | 10 +- .../store/postgres/sqlcgen/message.sql.go | 272 ++-- internal/store/postgres/sqlcgen/models.go | 114 ++ .../postgres/sqlcgen/saved_dialog.sql.go | 9 + .../postgres/sqlcgen/user_update_event.sql.go | 6 + internal/store/postgres/update_event.go | 2 + internal/store/private_send_idempotency.go | 10 + .../store/private_send_idempotency_test.go | 32 + internal/store/redisstore/redis.go | 20 +- internal/store/redisstore/redis_test.go | 72 + internal/telegramloginhttp/handler.go | 2 +- internal/transport/metadata.go | 28 + internal/transport/metadata_test.go | 27 + internal/web/server.go | 2 +- internal/webauthn/webauthn.go | 2 +- 176 files changed, 9000 insertions(+), 907 deletions(-) create mode 100644 cmd/telesrv-admin/web/dist/assets/index-Ch2HZ77j.js create mode 100644 cmd/telesrv-admin/web/dist/assets/index-y9w88zN5.css create mode 100644 deploy/migrations/0181_sticker_set_system_key_unique.down.sql create mode 100644 deploy/migrations/0181_sticker_set_system_key_unique.up.sql create mode 100644 deploy/migrations/20260908000001_phone_identity_canonicalization.down.sql create mode 100644 deploy/migrations/20260908000001_phone_identity_canonicalization.up.sql create mode 100644 deploy/migrations/20260908000002_private_external_reply.down.sql create mode 100644 deploy/migrations/20260908000002_private_external_reply.up.sql create mode 100644 internal/app/files/guarded_backend.go create mode 100644 internal/domain/message_reply_external.go create mode 100644 internal/domain/message_reply_external_test.go create mode 100644 internal/domain/phone_identity_test.go create mode 100644 internal/domain/privacy_evaluate.go create mode 100644 internal/loadharness/group_media_load_test.go create mode 100644 internal/loadharness/group_media_unit_test.go create mode 100644 internal/mtprotoedge/bulk_rpc_scheduler.go create mode 100644 internal/mtprotoedge/bulk_rpc_scheduler_test.go create mode 100644 internal/mtprotoedge/circular_history_test.go create mode 100644 internal/mtprotoedge/client_ip_e2e_test.go create mode 100644 internal/mtprotoedge/outbound_replay_body_pool.go create mode 100644 internal/rpc/messages_external_reply_test.go create mode 100644 internal/rpc/messages_read_filter.go create mode 100644 internal/rpc/messages_read_filter_test.go create mode 100644 internal/rpc/messages_saved_forward_boundary_test.go create mode 100644 internal/rpc/presence_timer_generation_test.go create mode 100644 internal/rpc/presence_work.go create mode 100644 internal/rpc/presence_work_test.go create mode 100644 internal/rpcresult/replay.go create mode 100644 internal/store/delivery_effect.go create mode 100644 internal/store/memory/bot_branding_test.go create mode 100644 internal/store/memory/media_pagination_test.go create mode 100644 internal/store/memory/message_search_test.go create mode 100644 internal/store/message_pin.go create mode 100644 internal/store/postgres/channel_difference_cursor_integration_test.go create mode 100644 internal/store/postgres/delivery_effect.go create mode 100644 internal/store/postgres/media_pagination_test.go create mode 100644 internal/store/postgres/message_external_reply_integration_test.go create mode 100644 internal/store/postgres/message_search_count_test.go create mode 100644 internal/store/postgres/message_search_integration_test.go create mode 100644 internal/store/postgres/phone_identity_migration.go create mode 100644 internal/store/postgres/phone_identity_migration_integration_test.go create mode 100644 internal/store/postgres/postgres_connection_admission.go create mode 100644 internal/store/postgres/postgres_connection_admission_test.go create mode 100644 internal/store/redisstore/redis_test.go create mode 100644 internal/transport/metadata.go create mode 100644 internal/transport/metadata_test.go diff --git a/cmd/telesrv-admin/server.go b/cmd/telesrv-admin/server.go index 7bd160a5..0b16c52f 100644 --- a/cmd/telesrv-admin/server.go +++ b/cmd/telesrv-admin/server.go @@ -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"` diff --git a/cmd/telesrv-admin/web/dist/assets/index-Ch2HZ77j.js b/cmd/telesrv-admin/web/dist/assets/index-Ch2HZ77j.js new file mode 100644 index 00000000..710740f1 --- /dev/null +++ b/cmd/telesrv-admin/web/dist/assets/index-Ch2HZ77j.js @@ -0,0 +1,9 @@ +var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},c=(n,r,a)=>(a=n==null?{}:e(i(n)),s(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var l=o((e=>{var t=Symbol.for(`react.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.provider`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.iterator;function p(e){return typeof e!=`object`||!e?null:(e=f&&e[f]||e[`@@iterator`],typeof e==`function`?e:null)}var m={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},h=Object.assign,g={};function _(e,t,n){this.props=e,this.context=t,this.refs=g,this.updater=n||m}_.prototype.isReactComponent={},_.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`setState(...): takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},_.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function v(){}v.prototype=_.prototype;function y(e,t,n){this.props=e,this.context=t,this.refs=g,this.updater=n||m}var b=y.prototype=new v;b.constructor=y,h(b,_.prototype),b.isPureReactComponent=!0;var x=Array.isArray,S=Object.prototype.hasOwnProperty,C={current:null},w={key:!0,ref:!0,__self:!0,__source:!0};function T(e,n,r){var i,a={},o=null,s=null;if(n!=null)for(i in n.ref!==void 0&&(s=n.ref),n.key!==void 0&&(o=``+n.key),n)S.call(n,i)&&!w.hasOwnProperty(i)&&(a[i]=n[i]);var c=arguments.length-2;if(c===1)a.children=r;else if(1{t.exports=l()})),d=o((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=typeof setTimeout==`function`?setTimeout:null,_=typeof clearTimeout==`function`?clearTimeout:null,v=typeof setImmediate<`u`?setImmediate:null;typeof navigator<`u`&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function y(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function b(e){if(h=!1,y(e),!m)if(n(c)!==null)m=!0,M(x);else{var t=n(l);t!==null&&N(b,t.startTime-e)}}function x(t,i){m=!1,h&&(h=!1,_(w),w=-1),p=!0;var a=f;try{for(y(i),d=n(c);d!==null&&(!(d.expirationTime>i)||t&&!D());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=i);i=e.unstable_now(),typeof s==`function`?d.callback=s:d===n(c)&&r(c),y(i)}else r(c);d=n(c)}if(d!==null)var u=!0;else{var g=n(l);g!==null&&N(b,g.startTime-i),u=!1}return u}finally{d=null,f=a,p=!1}}var S=!1,C=null,w=-1,T=5,E=-1;function D(){return!(e.unstable_now()-Ee||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(_(w),w=-1):h=!0,N(b,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,M(x))),r},e.unstable_shouldYield=D,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),f=o(((e,t)=>{t.exports=d()})),p=o((e=>{var t=u(),n=f();function r(e){for(var t=`https://reactjs.org/docs/error-decoder.html?invariant=`+e,n=1;n`u`||window.document===void 0||window.document.createElement===void 0),l=Object.prototype.hasOwnProperty,d=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,p={},m={};function h(e){return l.call(m,e)?!0:l.call(p,e)?!1:d.test(e)?m[e]=!0:(p[e]=!0,!1)}function g(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case`function`:case`symbol`:return!0;case`boolean`:return r?!1:n===null?(e=e.toLowerCase().slice(0,5),e!==`data-`&&e!==`aria-`):!n.acceptsBooleans;default:return!1}}function _(e,t,n,r){if(t==null||g(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return!1===t;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function v(e,t,n,r,i,a,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=i,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=a,this.removeEmptyString=o}var y={};`children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style`.split(` `).forEach(function(e){y[e]=new v(e,0,!1,e,null,!1,!1)}),[[`acceptCharset`,`accept-charset`],[`className`,`class`],[`htmlFor`,`for`],[`httpEquiv`,`http-equiv`]].forEach(function(e){var t=e[0];y[t]=new v(t,1,!1,e[1],null,!1,!1)}),[`contentEditable`,`draggable`,`spellCheck`,`value`].forEach(function(e){y[e]=new v(e,2,!1,e.toLowerCase(),null,!1,!1)}),[`autoReverse`,`externalResourcesRequired`,`focusable`,`preserveAlpha`].forEach(function(e){y[e]=new v(e,2,!1,e,null,!1,!1)}),`allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope`.split(` `).forEach(function(e){y[e]=new v(e,3,!1,e.toLowerCase(),null,!1,!1)}),[`checked`,`multiple`,`muted`,`selected`].forEach(function(e){y[e]=new v(e,3,!0,e,null,!1,!1)}),[`capture`,`download`].forEach(function(e){y[e]=new v(e,4,!1,e,null,!1,!1)}),[`cols`,`rows`,`size`,`span`].forEach(function(e){y[e]=new v(e,6,!1,e,null,!1,!1)}),[`rowSpan`,`start`].forEach(function(e){y[e]=new v(e,5,!1,e.toLowerCase(),null,!1,!1)});var b=/[\-:]([a-z])/g;function x(e){return e[1].toUpperCase()}`accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height`.split(` `).forEach(function(e){var t=e.replace(b,x);y[t]=new v(t,1,!1,e,null,!1,!1)}),`xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type`.split(` `).forEach(function(e){var t=e.replace(b,x);y[t]=new v(t,1,!1,e,`http://www.w3.org/1999/xlink`,!1,!1)}),[`xml:base`,`xml:lang`,`xml:space`].forEach(function(e){var t=e.replace(b,x);y[t]=new v(t,1,!1,e,`http://www.w3.org/XML/1998/namespace`,!1,!1)}),[`tabIndex`,`crossOrigin`].forEach(function(e){y[e]=new v(e,1,!1,e.toLowerCase(),null,!1,!1)}),y.xlinkHref=new v(`xlinkHref`,1,!1,`xlink:href`,`http://www.w3.org/1999/xlink`,!0,!1),[`src`,`href`,`action`,`formAction`].forEach(function(e){y[e]=new v(e,1,!1,e.toLowerCase(),null,!0,!0)});function S(e,t,n,r){var i=y.hasOwnProperty(t)?y[t]:null;(i===null?r||!(2s||i[o]!==a[s]){var c=` +`+i[o].replace(` at new `,` at `);return e.displayName&&c.includes(``)&&(c=c.replace(``,e.displayName)),c}while(1<=o&&0<=s);break}}}finally{re=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:``)?ne(e):``}function ae(e){switch(e.tag){case 5:return ne(e.type);case 16:return ne(`Lazy`);case 13:return ne(`Suspense`);case 19:return ne(`SuspenseList`);case 0:case 2:case 15:return e=ie(e.type,!1),e;case 11:return e=ie(e.type.render,!1),e;case 1:return e=ie(e.type,!0),e;default:return``}}function oe(e){if(e==null)return null;if(typeof e==`function`)return e.displayName||e.name||null;if(typeof e==`string`)return e;switch(e){case E:return`Fragment`;case T:return`Portal`;case O:return`Profiler`;case D:return`StrictMode`;case M:return`Suspense`;case N:return`SuspenseList`}if(typeof e==`object`)switch(e.$$typeof){case A:return(e.displayName||`Context`)+`.Consumer`;case k:return(e._context.displayName||`Context`)+`.Provider`;case j:var t=e.render;return e=e.displayName,e||=(e=t.displayName||t.name||``,e===``?`ForwardRef`:`ForwardRef(`+e+`)`),e;case P:return t=e.displayName||null,t===null?oe(e.type)||`Memo`:t;case F:t=e._payload,e=e._init;try{return oe(e(t))}catch{}}return null}function se(e){var t=e.type;switch(e.tag){case 24:return`Cache`;case 9:return(t.displayName||`Context`)+`.Consumer`;case 10:return(t._context.displayName||`Context`)+`.Provider`;case 18:return`DehydratedFragment`;case 11:return e=t.render,e=e.displayName||e.name||``,t.displayName||(e===``?`ForwardRef`:`ForwardRef(`+e+`)`);case 7:return`Fragment`;case 5:return t;case 4:return`Portal`;case 3:return`Root`;case 6:return`Text`;case 16:return oe(t);case 8:return t===D?`StrictMode`:`Mode`;case 22:return`Offscreen`;case 12:return`Profiler`;case 21:return`Scope`;case 13:return`Suspense`;case 19:return`SuspenseList`;case 25:return`TracingMarker`;case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t==`function`)return t.displayName||t.name||null;if(typeof t==`string`)return t}return null}function ce(e){switch(typeof e){case`boolean`:case`number`:case`string`:case`undefined`:return e;case`object`:return e;default:return``}}function le(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()===`input`&&(t===`checkbox`||t===`radio`)}function ue(e){var t=le(e)?`checked`:`value`,n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=``+e[t];if(!e.hasOwnProperty(t)&&n!==void 0&&typeof n.get==`function`&&typeof n.set==`function`){var i=n.get,a=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return i.call(this)},set:function(e){r=``+e,a.call(this,e)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(e){r=``+e},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function z(e){e._valueTracker||=ue(e)}function de(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r=``;return e&&(r=le(e)?e.checked?`true`:`false`:e.value),e=r,e===n?!1:(t.setValue(e),!0)}function fe(e){if(e||=typeof document<`u`?document:void 0,e===void 0)return null;try{return e.activeElement||e.body}catch{return e.body}}function pe(e,t){var n=t.checked;return R({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function me(e,t){var n=t.defaultValue==null?``:t.defaultValue,r=t.checked==null?t.defaultChecked:t.checked;n=ce(t.value==null?n:t.value),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type===`checkbox`||t.type===`radio`?t.checked!=null:t.value!=null}}function he(e,t){t=t.checked,t!=null&&S(e,`checked`,t,!1)}function ge(e,t){he(e,t);var n=ce(t.value),r=t.type;if(n!=null)r===`number`?(n===0&&e.value===``||e.value!=n)&&(e.value=``+n):e.value!==``+n&&(e.value=``+n);else if(r===`submit`||r===`reset`){e.removeAttribute(`value`);return}t.hasOwnProperty(`value`)?ve(e,t.type,n):t.hasOwnProperty(`defaultValue`)&&ve(e,t.type,ce(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function _e(e,t,n){if(t.hasOwnProperty(`value`)||t.hasOwnProperty(`defaultValue`)){var r=t.type;if(!(r!==`submit`&&r!==`reset`||t.value!==void 0&&t.value!==null))return;t=``+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==``&&(e.name=``),e.defaultChecked=!!e._wrapperState.initialChecked,n!==``&&(e.name=n)}function ve(e,t,n){(t!==`number`||fe(e.ownerDocument)!==e)&&(n==null?e.defaultValue=``+e._wrapperState.initialValue:e.defaultValue!==``+n&&(e.defaultValue=``+n))}var ye=Array.isArray;function be(e,t,n,r){if(e=e.options,t){t={};for(var i=0;i`+t.valueOf().toString()+``,t=Ee.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Oe(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var ke={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Ae=[`Webkit`,`ms`,`Moz`,`O`];Object.keys(ke).forEach(function(e){Ae.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),ke[t]=ke[e]})});function je(e,t,n){return t==null||typeof t==`boolean`||t===``?``:n||typeof t!=`number`||t===0||ke.hasOwnProperty(e)&&ke[e]?(``+t).trim():t+`px`}function Me(e,t){for(var n in e=e.style,t)if(t.hasOwnProperty(n)){var r=n.indexOf(`--`)===0,i=je(n,t[n],r);n===`float`&&(n=`cssFloat`),r?e.setProperty(n,i):e[n]=i}}var Ne=R({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Pe(e,t){if(t){if(Ne[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(r(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(r(60));if(typeof t.dangerouslySetInnerHTML!=`object`||!(`__html`in t.dangerouslySetInnerHTML))throw Error(r(61))}if(t.style!=null&&typeof t.style!=`object`)throw Error(r(62))}}function Fe(e,t){if(e.indexOf(`-`)===-1)return typeof t.is==`string`;switch(e){case`annotation-xml`:case`color-profile`:case`font-face`:case`font-face-src`:case`font-face-uri`:case`font-face-format`:case`font-face-name`:case`missing-glyph`:return!1;default:return!0}}var Ie=null;function Le(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Re=null,ze=null,Be=null;function Ve(e){if(e=Ai(e)){if(typeof Re!=`function`)throw Error(r(280));var t=e.stateNode;t&&(t=Mi(t),Re(e.stateNode,e.type,t))}}function He(e){ze?Be?Be.push(e):Be=[e]:ze=e}function Ue(){if(ze){var e=ze,t=Be;if(Be=ze=null,Ve(e),t)for(e=0;e>>=0,e===0?32:31-(Tt(e)/Et|0)|0}var Ot=64,H=4194304;function kt(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function At(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,i=e.suspendedLanes,a=e.pingedLanes,o=n&268435455;if(o!==0){var s=o&~i;s===0?(a&=o,a!==0&&(r=kt(a))):r=kt(s)}else o=n&~i,o===0?a!==0&&(r=kt(a)):r=kt(o);if(r===0)return 0;if(t!==0&&t!==r&&(t&i)===0&&(i=r&-r,a=t&-t,i>=a||i===16&&a&4194240))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function Ft(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-wt(t),e[t]=n}function It(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Wn),qn=` `,Jn=!1;function Yn(e,t){switch(e){case`keyup`:return Hn.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function Xn(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var Zn=!1;function Qn(e,t){switch(e){case`compositionend`:return Xn(t);case`keypress`:return t.which===32?(Jn=!0,qn):null;case`textInput`:return e=t.data,e===qn&&Jn?null:e;default:return null}}function $n(e,t){if(Zn)return e===`compositionend`||!Un&&Yn(e,t)?(e=mn(),pn=fn=dn=null,Zn=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=br(n)}}function Sr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Sr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Cr(){for(var e=window,t=fe();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=fe(e.document)}return t}function wr(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}function Tr(e){var t=Cr(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Sr(n.ownerDocument.documentElement,n)){if(r!==null&&wr(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),`selectionStart`in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var i=n.textContent.length,a=Math.min(r.start,i);r=r.end===void 0?a:Math.min(r.end,i),!e.extend&&a>r&&(i=r,r=a,a=i),i=xr(n,a);var o=xr(n,r);i&&o&&(e.rangeCount!==1||e.anchorNode!==i.node||e.anchorOffset!==i.offset||e.focusNode!==o.node||e.focusOffset!==o.offset)&&(t=t.createRange(),t.setStart(i.node,i.offset),e.removeAllRanges(),a>r?(e.addRange(t),e.extend(o.node,o.offset)):(t.setEnd(o.node,o.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus==`function`&&n.focus(),n=0;n=document.documentMode,Dr=null,Or=null,kr=null,Ar=!1;function jr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Ar||Dr==null||Dr!==fe(r)||(r=Dr,`selectionStart`in r&&wr(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),kr&&yr(kr,r)||(kr=r,r=ri(Or,`onSelect`),0Pi||(e.current=Ni[Pi],Ni[Pi]=null,Pi--)}function Li(e,t){Pi++,Ni[Pi]=e.current,e.current=t}var Ri={},zi=Fi(Ri),Bi=Fi(!1),Vi=Ri;function Hi(e,t){var n=e.type.contextTypes;if(!n)return Ri;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var i={},a;for(a in n)i[a]=t[a];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=i),i}function Ui(e){return e=e.childContextTypes,e!=null}function Wi(){Ii(Bi),Ii(zi)}function Gi(e,t,n){if(zi.current!==Ri)throw Error(r(168));Li(zi,t),Li(Bi,n)}function Ki(e,t,n){var i=e.stateNode;if(t=t.childContextTypes,typeof i.getChildContext!=`function`)return n;for(var a in i=i.getChildContext(),i)if(!(a in t))throw Error(r(108,se(e)||`Unknown`,a));return R({},n,i)}function qi(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Ri,Vi=zi.current,Li(zi,e),Li(Bi,Bi.current),!0}function Ji(e,t,n){var i=e.stateNode;if(!i)throw Error(r(169));n?(e=Ki(e,t,Vi),i.__reactInternalMemoizedMergedChildContext=e,Ii(Bi),Ii(zi),Li(zi,e)):Ii(Bi),Li(Bi,n)}var Yi=null,Xi=!1,Zi=!1;function Qi(e){Yi===null?Yi=[e]:Yi.push(e)}function $i(e){Xi=!0,Qi(e)}function ea(){if(!Zi&&Yi!==null){Zi=!0;var e=0,t=W;try{var n=Yi;for(W=1;e>=o,i-=o,ca=1<<32-wt(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(r,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(r,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(r,d),ga&&ua(r,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(a,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(a,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(a,h),ga&&ua(a,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(a,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return ga&&ua(a,g),u}for(h=i(a,h);!v.done;g++,v=c.next())v=m(h,a,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(a,e)}),ga&&ua(a,g),u}function _(e,r,i,o){if(typeof i==`object`&&i&&i.type===E&&i.key===null&&(i=i.props.children),typeof i==`object`&&i){switch(i.$$typeof){case w:a:{for(var c=i.key,l=r;l!==null;){if(l.key===c){if(c=i.type,c===E){if(l.tag===7){n(e,l.sibling),r=a(l,i.props.children),r.return=e,e=r;break a}}else if(l.elementType===c||typeof c==`object`&&c&&c.$$typeof===F&&Aa(c)===l.type){n(e,l.sibling),r=a(l,i.props),r.ref=Oa(e,l,i),r.return=e,e=r;break a}n(e,l);break}else t(e,l);l=l.sibling}i.type===E?(r=Zl(i.props.children,e.mode,o,i.key),r.return=e,e=r):(o=Xl(i.type,i.key,i.props,null,e.mode,o),o.ref=Oa(e,r,i),o.return=e,e=o)}return s(e);case T:a:{for(l=i.key;r!==null;){if(r.key===l)if(r.tag===4&&r.stateNode.containerInfo===i.containerInfo&&r.stateNode.implementation===i.implementation){n(e,r.sibling),r=a(r,i.children||[]),r.return=e,e=r;break a}else{n(e,r);break}else t(e,r);r=r.sibling}r=eu(i,e.mode,o),r.return=e,e=r}return s(e);case F:return l=i._init,_(e,r,l(i._payload),o)}if(ye(i))return h(e,r,i,o);if(ee(i))return g(e,r,i,o);ka(e,i)}return typeof i==`string`&&i!==``||typeof i==`number`?(i=``+i,r!==null&&r.tag===6?(n(e,r.sibling),r=a(r,i),r.return=e,e=r):(n(e,r),r=$l(i,e.mode,o),r.return=e,e=r),s(e)):n(e,r)}return _}var Ma=ja(!0),Na=ja(!1),Pa=Fi(null),Fa=null,Ia=null,La=null;function Ra(){La=Ia=Fa=null}function za(e){var t=Pa.current;Ii(Pa),e._currentValue=t}function Ba(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)===t?r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t):(e.childLanes|=t,r!==null&&(r.childLanes|=t)),e===n)break;e=e.return}}function Va(e,t){Fa=e,La=Ia=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&t)!==0&&(js=!0),e.firstContext=null)}function Ha(e){var t=e._currentValue;if(La!==e)if(e={context:e,memoizedValue:t,next:null},Ia===null){if(Fa===null)throw Error(r(308));Ia=e,Fa.dependencies={lanes:0,firstContext:e}}else Ia=Ia.next=e;return t}var Ua=null;function Wa(e){Ua===null?Ua=[e]:Ua.push(e)}function Ga(e,t,n,r){var i=t.interleaved;return i===null?(n.next=n,Wa(t)):(n.next=i.next,i.next=n),t.interleaved=n,Ka(e,r)}function Ka(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var qa=!1;function Ja(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Ya(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Xa(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function Za(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,Bc&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,Ka(e,n)}return i=r.interleaved,i===null?(t.next=t,Wa(r)):(t.next=i.next,i.next=t),r.interleaved=t,Ka(e,n)}function Qa(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194240)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Lt(e,n)}}function $a(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function eo(e,t,n,r){var i=e.updateQueue;qa=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane,p=s.eventTime;if((r&f)===f){u!==null&&(u=u.next={eventTime:p,lane:0,tag:s.tag,payload:s.payload,callback:s.callback,next:null});a:{var m=e,h=s;switch(f=t,p=n,h.tag){case 1:if(m=h.payload,typeof m==`function`){d=m.call(p,d,f);break a}d=m;break a;case 3:m.flags=m.flags&-65537|128;case 0:if(m=h.payload,f=typeof m==`function`?m.call(p,d,f):m,f==null)break a;d=R({},d,f);break a;case 2:qa=!0}}s.callback!==null&&s.lane!==0&&(e.flags|=64,f=i.effects,f===null?i.effects=[s]:f.push(s))}else p={eventTime:p,lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=p,c=d):u=u.next=p,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;f=s,s=f.next,f.next=null,i.lastBaseUpdate=f,i.shared.pending=null}}while(1);if(u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,t=i.shared.interleaved,t!==null){i=t;do o|=i.lane,i=i.next;while(i!==t)}else a===null&&(i.shared.lanes=0);Jc|=o,e.lanes=o,e.memoizedState=d}}function to(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=_o.transition;_o.transition={};try{e(!1),t()}finally{W=n,_o.transition=r}}function is(){return jo().memoizedState}function as(e,t,n){var r=pl(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},ss(e))cs(t,n);else if(n=Ga(e,t,n,r),n!==null){var i=fl();ml(n,e,r,i),ls(n,t,r)}}function os(e,t,n){var r=pl(e),i={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(ss(e))cs(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,Q(s,o)){var c=t.interleaved;c===null?(i.next=i,Wa(t)):(i.next=c.next,c.next=i),t.interleaved=i;return}}catch{}n=Ga(e,t,i,r),n!==null&&(i=fl(),ml(n,e,r,i),ls(n,t,r))}}function ss(e){var t=e.alternate;return e===yo||t!==null&&t===yo}function cs(e,t){Co=So=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function ls(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Lt(e,n)}}var us={readContext:Ha,useCallback:Eo,useContext:Eo,useEffect:Eo,useImperativeHandle:Eo,useInsertionEffect:Eo,useLayoutEffect:Eo,useMemo:Eo,useReducer:Eo,useRef:Eo,useState:Eo,useDebugValue:Eo,useDeferredValue:Eo,useTransition:Eo,useMutableSource:Eo,useSyncExternalStore:Eo,useId:Eo,unstable_isNewReconciler:!1},ds={readContext:Ha,useCallback:function(e,t){return Ao().memoizedState=[e,t===void 0?null:t],e},useContext:Ha,useEffect:qo,useImperativeHandle:function(e,t,n){return n=n==null?null:n.concat([e]),Go(4194308,4,Zo.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Go(4194308,4,e,t)},useInsertionEffect:function(e,t){return Go(4,2,e,t)},useMemo:function(e,t){var n=Ao();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=Ao();return t=n===void 0?t:n(t),r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=as.bind(null,yo,e),[r.memoizedState,e]},useRef:function(e){var t=Ao();return e={current:e},t.memoizedState=e},useState:Ho,useDebugValue:$o,useDeferredValue:function(e){return Ao().memoizedState=e},useTransition:function(){var e=Ho(!1),t=e[0];return e=rs.bind(null,e[1]),Ao().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var i=yo,a=Ao();if(ga){if(n===void 0)throw Error(r(407));n=n()}else{if(n=t(),Vc===null)throw Error(r(349));vo&30||Lo(i,t,n)}a.memoizedState=n;var o={value:n,getSnapshot:t};return a.queue=o,qo(zo.bind(null,i,o,e),[e]),i.flags|=2048,Uo(9,Ro.bind(null,i,o,n,t),void 0,null),n},useId:function(){var e=Ao(),t=Vc.identifierPrefix;if(ga){var n=la,r=ca;n=(r&~(1<<32-wt(r)-1)).toString(32)+n,t=`:`+t+`R`+n,n=wo++,0<\/script>`,e=e.removeChild(e.firstChild)):typeof i.is==`string`?e=c.createElement(n,{is:i.is}):(e=c.createElement(n),n===`select`&&(c=e,i.multiple?c.multiple=!0:i.size&&(c.size=i.size))):e=c.createElementNS(e,n),e[Ci]=t,e[wi]=i,tc(e,t,!1,!1),t.stateNode=e;a:{switch(c=Fe(n,i),n){case`dialog`:Xr(`cancel`,e),Xr(`close`,e),o=i;break;case`iframe`:case`object`:case`embed`:Xr(`load`,e),o=i;break;case`video`:case`audio`:for(o=0;oel&&(t.flags|=128,i=!0,ic(s,!1),t.lanes=4194304)}else{if(!i)if(e=po(c),e!==null){if(t.flags|=128,i=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),ic(s,!0),s.tail===null&&s.tailMode===`hidden`&&!c.alternate&&!ga)return ac(t),null}else 2*mt()-s.renderingStartTime>el&&n!==1073741824&&(t.flags|=128,i=!0,ic(s,!1),t.lanes=4194304);s.isBackwards?(c.sibling=t.child,t.child=c):(n=s.last,n===null?t.child=c:n.sibling=c,s.last=c)}return s.tail===null?(ac(t),null):(t=s.tail,s.rendering=t,s.tail=t.sibling,s.renderingStartTime=mt(),t.sibling=null,n=fo.current,Li(fo,i?n&1|2:n&1),t);case 22:case 23:return wl(),i=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==i&&(t.flags|=8192),i&&t.mode&1?Wc&1073741824&&(ac(t),t.subtreeFlags&6&&(t.flags|=8192)):ac(t),null;case 24:return null;case 25:return null}throw Error(r(156,t.tag))}function sc(e,t){switch(pa(t),t.tag){case 1:return Ui(t.type)&&Wi(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return co(),Ii(Bi),Ii(zi),ho(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return uo(t),null;case 13:if(Ii(fo),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(r(340));Ta()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return Ii(fo),null;case 4:return co(),null;case 10:return za(t.type._context),null;case 22:case 23:return wl(),null;case 24:return null;default:return null}}var cc=!1,lc=!1,uc=typeof WeakSet==`function`?WeakSet:Set,$=null;function dc(e,t){var n=e.ref;if(n!==null)if(typeof n==`function`)try{n(null)}catch(n){Rl(e,t,n)}else n.current=null}function fc(e,t,n){try{n()}catch(n){Rl(e,t,n)}}var pc=!1;function mc(e,t){if(di=Y,e=Cr(),wr(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var i=n.getSelection&&n.getSelection();if(i&&i.rangeCount!==0){n=i.anchorNode;var a=i.anchorOffset,o=i.focusNode;i=i.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||a!==0&&f.nodeType!==3||(c=s+a),f!==o||i!==0&&f.nodeType!==3||(l=s+i),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===a&&(c=s),p===o&&++d===i&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(fi={focusedElem:e,selectionRange:n},Y=!1,$=t;$!==null;)if(t=$,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,$=e;else for(;$!==null;){t=$;try{var h=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(h!==null){var g=h.memoizedProps,_=h.memoizedState,v=t.stateNode;v.__reactInternalSnapshotBeforeUpdate=v.getSnapshotBeforeUpdate(t.elementType===t.type?g:ms(t.type,g),_)}break;case 3:var y=t.stateNode.containerInfo;y.nodeType===1?y.textContent=``:y.nodeType===9&&y.documentElement&&y.removeChild(y.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(r(163))}}catch(e){Rl(t,t.return,e)}if(e=t.sibling,e!==null){e.return=t.return,$=e;break}$=t.return}return h=pc,pc=!1,h}function hc(e,t,n){var r=t.updateQueue;if(r=r===null?null:r.lastEffect,r!==null){var i=r=r.next;do{if((i.tag&e)===e){var a=i.destroy;i.destroy=void 0,a!==void 0&&fc(t,n,a)}i=i.next}while(i!==r)}}function gc(e,t){if(t=t.updateQueue,t=t===null?null:t.lastEffect,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function _c(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t==`function`?t(e):t.current=e}}function vc(e){var t=e.alternate;t!==null&&(e.alternate=null,vc(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Ci],delete t[wi],delete t[Ei],delete t[Di],delete t[Oi])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function yc(e){return e.tag===5||e.tag===3||e.tag===4}function bc(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||yc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function xc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=ui));else if(r!==4&&(e=e.child,e!==null))for(xc(e,t,n),e=e.sibling;e!==null;)xc(e,t,n),e=e.sibling}function Sc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Sc(e,t,n),e=e.sibling;e!==null;)Sc(e,t,n),e=e.sibling}var Cc=null,wc=!1;function Tc(e,t,n){for(n=n.child;n!==null;)Ec(e,t,n),n=n.sibling}function Ec(e,t,n){if(St&&typeof St.onCommitFiberUnmount==`function`)try{St.onCommitFiberUnmount(xt,n)}catch{}switch(n.tag){case 5:lc||dc(n,t);case 6:var r=Cc,i=wc;Cc=null,Tc(e,t,n),Cc=r,wc=i,Cc!==null&&(wc?(e=Cc,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):Cc.removeChild(n.stateNode));break;case 18:Cc!==null&&(wc?(e=Cc,n=n.stateNode,e.nodeType===8?yi(e.parentNode,n):e.nodeType===1&&yi(e,n),q(e)):yi(Cc,n.stateNode));break;case 4:r=Cc,i=wc,Cc=n.stateNode.containerInfo,wc=!0,Tc(e,t,n),Cc=r,wc=i;break;case 0:case 11:case 14:case 15:if(!lc&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){i=r=r.next;do{var a=i,o=a.destroy;a=a.tag,o!==void 0&&(a&2||a&4)&&fc(n,t,o),i=i.next}while(i!==r)}Tc(e,t,n);break;case 1:if(!lc&&(dc(n,t),r=n.stateNode,typeof r.componentWillUnmount==`function`))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(e){Rl(n,t,e)}Tc(e,t,n);break;case 21:Tc(e,t,n);break;case 22:n.mode&1?(lc=(r=lc)||n.memoizedState!==null,Tc(e,t,n),lc=r):Tc(e,t,n);break;default:Tc(e,t,n)}}function Dc(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new uc),t.forEach(function(t){var r=Hl.bind(null,e,t);n.has(t)||(n.add(t),t.then(r,r))})}}function Oc(e,t){var n=t.deletions;if(n!==null)for(var i=0;ia&&(a=s),i&=~o}if(i=a,i=mt()-i,i=(120>i?120:480>i?480:1080>i?1080:1920>i?1920:3e3>i?3e3:4320>i?4320:1960*Ic(i/1960))-i,10e?16:e,ol===null)var i=!1;else{if(e=ol,ol=null,sl=0,Bc&6)throw Error(r(331));var a=Bc;for(Bc|=4,$=e.current;$!==null;){var o=$,s=o.child;if($.flags&16){var c=o.deletions;if(c!==null){for(var l=0;lmt()-$c?Tl(e,0):Xc|=n),hl(e,t)}function Bl(e,t){t===0&&(e.mode&1?(t=H,H<<=1,!(H&130023424)&&(H=4194304)):t=1);var n=fl();e=Ka(e,t),e!==null&&(Ft(e,t,n),hl(e,n))}function Vl(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Bl(e,n)}function Hl(e,t){var n=0;switch(e.tag){case 13:var i=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:i=e.stateNode;break;default:throw Error(r(314))}i!==null&&i.delete(t),Bl(e,n)}var Ul=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||Bi.current)js=!0;else{if((e.lanes&n)===0&&!(t.flags&128))return js=!1,ec(e,t,n);js=!!(e.flags&131072)}else js=!1,ga&&t.flags&1048576&&da(t,ia,t.index);switch(t.lanes=0,t.tag){case 2:var i=t.type;Qs(e,t),e=t.pendingProps;var a=Hi(t,zi.current);Va(t,n),a=Oo(null,t,i,e,a,n);var o=ko();return t.flags|=1,typeof a==`object`&&a&&typeof a.render==`function`&&a.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,Ui(i)?(o=!0,qi(t)):o=!1,t.memoizedState=a.state!==null&&a.state!==void 0?a.state:null,Ja(t),a.updater=gs,t.stateNode=a,a._reactInternals=t,bs(t,i,e,n),t=Bs(null,t,i,!0,o,n)):(t.tag=0,ga&&o&&fa(t),Ms(null,t,a,n),t=t.child),t;case 16:i=t.elementType;a:{switch(Qs(e,t),e=t.pendingProps,a=i._init,i=a(i._payload),t.type=i,a=t.tag=Jl(i),e=ms(i,e),a){case 0:t=Rs(null,t,i,e,n);break a;case 1:t=zs(null,t,i,e,n);break a;case 11:t=Ns(null,t,i,e,n);break a;case 14:t=Ps(null,t,i,ms(i.type,e),n);break a}throw Error(r(306,i,``))}return t;case 0:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:ms(i,a),Rs(e,t,i,a,n);case 1:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:ms(i,a),zs(e,t,i,a,n);case 3:a:{if(Vs(t),e===null)throw Error(r(387));i=t.pendingProps,o=t.memoizedState,a=o.element,Ya(e,t),eo(t,i,null,n);var s=t.memoizedState;if(i=s.element,o.isDehydrated)if(o={element:i,isDehydrated:!1,cache:s.cache,pendingSuspenseBoundaries:s.pendingSuspenseBoundaries,transitions:s.transitions},t.updateQueue.baseState=o,t.memoizedState=o,t.flags&256){a=xs(Error(r(423)),t),t=Hs(e,t,i,n,a);break a}else if(i!==a){a=xs(Error(r(424)),t),t=Hs(e,t,i,n,a);break a}else for(ha=bi(t.stateNode.containerInfo.firstChild),ma=t,ga=!0,_a=null,n=Na(t,null,i,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(Ta(),i===a){t=$s(e,t,n);break a}Ms(e,t,i,n)}t=t.child}return t;case 5:return lo(t),e===null&&xa(t),i=t.type,a=t.pendingProps,o=e===null?null:e.memoizedProps,s=a.children,pi(i,a)?s=null:o!==null&&pi(i,o)&&(t.flags|=32),Ls(e,t),Ms(e,t,s,n),t.child;case 6:return e===null&&xa(t),null;case 13:return Gs(e,t,n);case 4:return so(t,t.stateNode.containerInfo),i=t.pendingProps,e===null?t.child=Ma(t,null,i,n):Ms(e,t,i,n),t.child;case 11:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:ms(i,a),Ns(e,t,i,a,n);case 7:return Ms(e,t,t.pendingProps,n),t.child;case 8:return Ms(e,t,t.pendingProps.children,n),t.child;case 12:return Ms(e,t,t.pendingProps.children,n),t.child;case 10:a:{if(i=t.type._context,a=t.pendingProps,o=t.memoizedProps,s=a.value,Li(Pa,i._currentValue),i._currentValue=s,o!==null)if(Q(o.value,s)){if(o.children===a.children&&!Bi.current){t=$s(e,t,n);break a}}else for(o=t.child,o!==null&&(o.return=t);o!==null;){var c=o.dependencies;if(c!==null){s=o.child;for(var l=c.firstContext;l!==null;){if(l.context===i){if(o.tag===1){l=Xa(-1,n&-n),l.tag=2;var u=o.updateQueue;if(u!==null){u=u.shared;var d=u.pending;d===null?l.next=l:(l.next=d.next,d.next=l),u.pending=l}}o.lanes|=n,l=o.alternate,l!==null&&(l.lanes|=n),Ba(o.return,n,t),c.lanes|=n;break}l=l.next}}else if(o.tag===10)s=o.type===t.type?null:o.child;else if(o.tag===18){if(s=o.return,s===null)throw Error(r(341));s.lanes|=n,c=s.alternate,c!==null&&(c.lanes|=n),Ba(s,n,t),s=o.sibling}else s=o.child;if(s!==null)s.return=o;else for(s=o;s!==null;){if(s===t){s=null;break}if(o=s.sibling,o!==null){o.return=s.return,s=o;break}s=s.return}o=s}Ms(e,t,a.children,n),t=t.child}return t;case 9:return a=t.type,i=t.pendingProps.children,Va(t,n),a=Ha(a),i=i(a),t.flags|=1,Ms(e,t,i,n),t.child;case 14:return i=t.type,a=ms(i,t.pendingProps),a=ms(i.type,a),Ps(e,t,i,a,n);case 15:return Fs(e,t,t.type,t.pendingProps,n);case 17:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:ms(i,a),Qs(e,t),t.tag=1,Ui(i)?(e=!0,qi(t)):e=!1,Va(t,n),vs(t,i,a),bs(t,i,a,n),Bs(null,t,i,!0,e,n);case 19:return Zs(e,t,n);case 22:return Is(e,t,n)}throw Error(r(156,t.tag))};function Wl(e,t){return ut(e,t)}function Gl(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Kl(e,t,n,r){return new Gl(e,t,n,r)}function ql(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Jl(e){if(typeof e==`function`)return+!!ql(e);if(e!=null){if(e=e.$$typeof,e===j)return 11;if(e===P)return 14}return 2}function Yl(e,t){var n=e.alternate;return n===null?(n=Kl(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Xl(e,t,n,i,a,o){var s=2;if(i=e,typeof e==`function`)ql(e)&&(s=1);else if(typeof e==`string`)s=5;else a:switch(e){case E:return Zl(n.children,a,o,t);case D:s=8,a|=8;break;case O:return e=Kl(12,n,t,a|2),e.elementType=O,e.lanes=o,e;case M:return e=Kl(13,n,t,a),e.elementType=M,e.lanes=o,e;case N:return e=Kl(19,n,t,a),e.elementType=N,e.lanes=o,e;case I:return Ql(n,a,o,t);default:if(typeof e==`object`&&e)switch(e.$$typeof){case k:s=10;break a;case A:s=9;break a;case j:s=11;break a;case P:s=14;break a;case F:s=16,i=null;break a}throw Error(r(130,e==null?e:typeof e,``))}return t=Kl(s,n,t,a),t.elementType=e,t.type=i,t.lanes=o,t}function Zl(e,t,n,r){return e=Kl(7,e,r,t),e.lanes=n,e}function Ql(e,t,n,r){return e=Kl(22,e,r,t),e.elementType=I,e.lanes=n,e.stateNode={isHidden:!1},e}function $l(e,t,n){return e=Kl(6,e,null,t),e.lanes=n,e}function eu(e,t,n){return t=Kl(4,e.children===null?[]:e.children,e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function tu(e,t,n,r,i){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Pt(0),this.expirationTimes=Pt(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Pt(0),this.identifierPrefix=r,this.onRecoverableError=i,this.mutableSourceEagerHydrationData=null}function nu(e,t,n,r,i,a,o,s,c){return e=new tu(e,t,n,s,c),t===1?(t=1,!0===a&&(t|=8)):t=0,a=Kl(3,null,null,t),e.current=a,a.stateNode=e,a.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Ja(a),e}function ru(e,t,n){var r=3{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=p()})),h=o((e=>{var t=m();e.createRoot=t.createRoot,e.hydrateRoot=t.hydrateRoot})),g=c(u()),_=c(h(),1),v=class extends Error{status;constructor(e,t){super(t),this.status=e}},y=`telesrv_admin_csrf`,b=`X-CSRF-Token`,x=``;function S(e){x=(e??``).trim()}function C(){if(typeof document>`u`)return``;for(let e of document.cookie.split(`;`)){let t=e.trim(),n=t.indexOf(`=`);if(!(n<=0||t.slice(0,n)!==y))try{return decodeURIComponent(t.slice(n+1))}catch{return t.slice(n+1)}}return``}function w(){return C()||x}function T(e){let t=(e??`GET`).toUpperCase();return t!==`GET`&&t!==`HEAD`&&t!==`OPTIONS`}function E(e){if(!e)return{};if(e instanceof Headers){let t={};return e.forEach((e,n)=>{t[n]=e}),t}return Array.isArray(e)?Object.fromEntries(e):{...e}}async function D(e,t={}){let n=typeof FormData<`u`&&t.body instanceof FormData?{}:{"Content-Type":`application/json`};if(Object.assign(n,E(t.headers)),T(t.method)){let e=w();e&&(n[b]=e)}let r=await fetch(e,{credentials:`same-origin`,...t,headers:n}),i=await r.text(),a=i?JSON.parse(i):null;if(!r.ok){let e=a?.error||a?.Error||a?.message||r.statusText;throw new v(r.status,e)}return a}function O(e){return e instanceof Error?e.message:String(e)}var k={session:()=>D(`/api/session`),login:async e=>{let t=await D(`/api/login`,{method:`POST`,body:JSON.stringify({secret:e})});return S(t.csrf_token),t},logout:()=>D(`/api/logout`,{method:`POST`,body:`{}`}),accounts:e=>D(`/api/accounts?${e.toString()}`),account:e=>D(`/api/accounts/${e}`),channels:e=>D(`/api/channels?${e.toString()}`),channel:e=>D(`/api/channels/${e}`),bots:e=>D(`/api/bots?${e.toString()}`),bot:e=>D(`/api/bots/${e}`),broadcasts:e=>D(`/api/broadcasts?${e.toString()}`),premiumPlans:()=>D(`/api/premium/plans`),collectibleUsernames:e=>D(`/api/collectible-usernames?${e.toString()}`),collectibleUsername:e=>D(`/api/collectible-usernames/${encodeURIComponent(e)}`),collectiblePhones:e=>D(`/api/collectible-phones?${e.toString()}`),collectiblePhone:e=>D(`/api/collectible-phones/${encodeURIComponent(e)}`),accountRatings:e=>D(`/api/account-ratings?${e.toString()}`),accountRating:e=>D(`/api/account-ratings/${encodeURIComponent(e)}`),dashboard:()=>D(`/api/dashboard`),storageStats:()=>D(`/api/storage/stats`),verificationApplications:e=>D(`/api/verification/applications?${e.toString()}`),verificationApplication:e=>D(`/api/verification/applications/${encodeURIComponent(e)}`),verificationCounts:()=>D(`/api/verification/counts`),botVerifiers:e=>D(`/api/botverification/verifiers?${e.toString()}`),verificationIcons:e=>D(`/api/botverification/icons?${e.toString()}`),customVerifications:e=>D(`/api/botverification/marks?${e.toString()}`),customVerificationRequests:e=>D(`/api/botverification/requests?${e.toString()}`),customVerificationRequest:e=>D(`/api/botverification/requests/${encodeURIComponent(e)}`),botVerificationCounts:()=>D(`/api/botverification/counts`),emoji:e=>D(`/api/emoji?${e.toString()}`),emojiAnimation:e=>D(`/api/emoji/${encodeURIComponent(e)}/animation`),messages:e=>D(`/api/messages?${e.toString()}`),message:(e,t)=>D(`/api/messages/detail?${new URLSearchParams({owner_user_id:String(e),msg_id:String(t)}).toString()}`),groupMessages:e=>D(`/api/messages/groups?${e.toString()}`),groupMessage:(e,t)=>D(`/api/messages/groups/detail?${new URLSearchParams({channel_id:String(e),msg_id:String(t)}).toString()}`),moderationCases:e=>D(`/api/moderation/cases?${e.toString()}`),moderationCase:e=>D(`/api/moderation/cases/${e}`),moderationReport:e=>D(`/api/moderation/reports/${e}`),claimModerationCase:(e,t)=>D(`/api/moderation/cases/${e}/claim`,{method:`POST`,body:JSON.stringify({expected_version:t})}),decideModerationCase:(e,t)=>D(`/api/moderation/cases/${e}/decide`,{method:`POST`,body:JSON.stringify(t)}),reviewModerationAppeal:(e,t,n)=>D(`/api/moderation/cases/${e}/appeals/${t}/review`,{method:`POST`,body:JSON.stringify(n)}),gifts:()=>D(`/api/gifts`),auctions:()=>D(`/api/auctions`),officialGifts:()=>D(`/api/official-gifts`),officialGiftAnimation:e=>D(`/api/official-gifts/${encodeURIComponent(e)}/animation`),giftAnimation:e=>D(`/api/gifts/${encodeURIComponent(e)}/animation`),giftCollectibles:e=>D(`/api/gifts/${encodeURIComponent(e)}/collectibles`),giftCollectibleAnimation:(e,t,n)=>D(`/api/gifts/${encodeURIComponent(e)}/collectibles/${t}/${encodeURIComponent(n)}/animation`),importGift:e=>D(`/api/actions/import-gift`,{method:`POST`,body:e}),importOfficialGift:e=>D(`/api/actions/import-official-gift`,{method:`POST`,body:JSON.stringify(e)}),publishGiftCollectibles:(e,t)=>D(`/api/actions/publish-gift-collectibles?gift_id=${encodeURIComponent(e)}`,{method:`POST`,body:t}),gifCatalog:()=>D(`/api/gif-catalog`),stickerSets:e=>D(`/api/stickers?kind=${encodeURIComponent(e)}`),stickerSetDocuments:e=>D(`/api/stickers/${encodeURIComponent(e)}/documents`),stickerDocumentAnimationURL:e=>`/api/stickers/documents/${encodeURIComponent(e)}/animation`,gifCatalogDocumentPreviewURL:e=>`/api/gif-catalog/documents/${encodeURIComponent(e)}/preview`,createStickerSet:e=>D(`/api/actions/create-sticker-set`,{method:`POST`,body:e}),addStickerToSet:e=>D(`/api/actions/add-sticker-to-set`,{method:`POST`,body:e}),createGifCatalogEntry:e=>D(`/api/actions/create-gif-catalog-entry`,{method:`POST`,body:e}),setAccountAvatar:e=>D(`/api/actions/set-account-avatar`,{method:`POST`,body:e}),setChannelAvatar:e=>D(`/api/actions/set-channel-avatar`,{method:`POST`,body:e}),action:(e,t)=>D(e,{method:`POST`,body:JSON.stringify(t)})},A=e=>e.replace(/([a-z0-9])([A-Z])/g,`$1-$2`).toLowerCase(),j=(...e)=>e.filter((e,t,n)=>!!e&&e.trim()!==``&&n.indexOf(e)===t).join(` `).trim(),M={xmlns:`http://www.w3.org/2000/svg`,width:24,height:24,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:2,strokeLinecap:`round`,strokeLinejoin:`round`},N=(0,g.forwardRef)(({color:e=`currentColor`,size:t=24,strokeWidth:n=2,absoluteStrokeWidth:r,className:i=``,children:a,iconNode:o,...s},c)=>(0,g.createElement)(`svg`,{ref:c,...M,width:t,height:t,stroke:e,strokeWidth:r?Number(n)*24/Number(t):n,className:j(`lucide`,i),...s},[...o.map(([e,t])=>(0,g.createElement)(e,t)),...Array.isArray(a)?a:[a]])),P=(e,t)=>{let n=(0,g.forwardRef)(({className:n,...r},i)=>(0,g.createElement)(N,{ref:i,iconNode:t,className:j(`lucide-${A(e)}`,n),...r}));return n.displayName=`${e}`,n},F=P(`BadgeCheck`,[[`path`,{d:`M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z`,key:`3c2336`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),I=P(`CircleAlert`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`line`,{x1:`12`,x2:`12`,y1:`8`,y2:`12`,key:`1pkeuh`}],[`line`,{x1:`12`,x2:`12.01`,y1:`16`,y2:`16`,key:`4dfq90`}]]),L=P(`CircleCheck`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),ee=P(`CircleX`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`m15 9-6 6`,key:`1uzhvr`}],[`path`,{d:`m9 9 6 6`,key:`z0biqf`}]]),R=P(`Layers`,[[`path`,{d:`M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z`,key:`zw3jo`}],[`path`,{d:`M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12`,key:`1wduqc`}],[`path`,{d:`M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17`,key:`kqbvx6`}]]),te=P(`LoaderCircle`,[[`path`,{d:`M21 12a9 9 0 1 1-6.219-8.56`,key:`13zald`}]]),ne=P(`ShieldX`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`m14.5 9.5-5 5`,key:`17q4r4`}],[`path`,{d:`m9.5 9.5 5 5`,key:`18nt4w`}]]),re=P(`Sparkles`,[[`path`,{d:`M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z`,key:`4pj2yx`}],[`path`,{d:`M20 3v4`,key:`1olli1`}],[`path`,{d:`M22 5h-4`,key:`1gvqau`}],[`path`,{d:`M4 17v2`,key:`vumght`}],[`path`,{d:`M5 18H3`,key:`zchphs`}]]),ie=P(`TriangleAlert`,[[`path`,{d:`m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3`,key:`wmoenq`}],[`path`,{d:`M12 9v4`,key:`juzpu7`}],[`path`,{d:`M12 17h.01`,key:`p32p05`}]]),ae=P(`UsersRound`,[[`path`,{d:`M18 21a8 8 0 0 0-16 0`,key:`3ypg7q`}],[`circle`,{cx:`10`,cy:`8`,r:`5`,key:`o932ke`}],[`path`,{d:`M22 20c0-3.37-2-6.5-4-8a5 5 0 0 0-.45-8.3`,key:`10s06x`}]]),oe=P(`Activity`,[[`path`,{d:`M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2`,key:`169zse`}]]),se=P(`ArrowLeftRight`,[[`path`,{d:`M8 3 4 7l4 4`,key:`9rb6wj`}],[`path`,{d:`M4 7h16`,key:`6tx8e3`}],[`path`,{d:`m16 21 4-4-4-4`,key:`siv7j2`}],[`path`,{d:`M20 17H4`,key:`h6l3hr`}]]),ce=P(`ArrowLeft`,[[`path`,{d:`m12 19-7-7 7-7`,key:`1l729n`}],[`path`,{d:`M19 12H5`,key:`x3x0zl`}]]),le=P(`AtSign`,[[`circle`,{cx:`12`,cy:`12`,r:`4`,key:`4exip2`}],[`path`,{d:`M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8`,key:`7n84p3`}]]),ue=P(`BadgeDollarSign`,[[`path`,{d:`M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z`,key:`3c2336`}],[`path`,{d:`M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8`,key:`1h4pet`}],[`path`,{d:`M12 18V6`,key:`zqpxq5`}]]),z=P(`Ban`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`m4.9 4.9 14.2 14.2`,key:`1m5liu`}]]),de=P(`Bot`,[[`path`,{d:`M12 8V4H8`,key:`hb8ula`}],[`rect`,{width:`16`,height:`12`,x:`4`,y:`8`,rx:`2`,key:`enze0r`}],[`path`,{d:`M2 14h2`,key:`vft8re`}],[`path`,{d:`M20 14h2`,key:`4cs60a`}],[`path`,{d:`M15 13v2`,key:`1xurst`}],[`path`,{d:`M9 13v2`,key:`rq6x2g`}]]),fe=P(`Building2`,[[`path`,{d:`M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z`,key:`1b4qmf`}],[`path`,{d:`M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2`,key:`i71pzd`}],[`path`,{d:`M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2`,key:`10jefs`}],[`path`,{d:`M10 6h4`,key:`1itunk`}],[`path`,{d:`M10 10h4`,key:`tcdvrf`}],[`path`,{d:`M10 14h4`,key:`kelpxr`}],[`path`,{d:`M10 18h4`,key:`1ulq68`}]]),pe=P(`Cable`,[[`path`,{d:`M17 21v-2a1 1 0 0 1-1-1v-1a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1`,key:`10bnsj`}],[`path`,{d:`M19 15V6.5a1 1 0 0 0-7 0v11a1 1 0 0 1-7 0V9`,key:`1eqmu1`}],[`path`,{d:`M21 21v-2h-4`,key:`14zm7j`}],[`path`,{d:`M3 5h4V3`,key:`z442eg`}],[`path`,{d:`M7 5a1 1 0 0 1 1 1v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a1 1 0 0 1 1-1V3`,key:`ebdjd7`}]]),me=P(`Calculator`,[[`rect`,{width:`16`,height:`20`,x:`4`,y:`2`,rx:`2`,key:`1nb95v`}],[`line`,{x1:`8`,x2:`16`,y1:`6`,y2:`6`,key:`x4nwl0`}],[`line`,{x1:`16`,x2:`16`,y1:`14`,y2:`18`,key:`wjye3r`}],[`path`,{d:`M16 10h.01`,key:`1m94wz`}],[`path`,{d:`M12 10h.01`,key:`1nrarc`}],[`path`,{d:`M8 10h.01`,key:`19clt8`}],[`path`,{d:`M12 14h.01`,key:`1etili`}],[`path`,{d:`M8 14h.01`,key:`6423bh`}],[`path`,{d:`M12 18h.01`,key:`mhygvu`}],[`path`,{d:`M8 18h.01`,key:`lrp35t`}]]),he=P(`Check`,[[`path`,{d:`M20 6 9 17l-5-5`,key:`1gmf2c`}]]),ge=P(`ChevronDown`,[[`path`,{d:`m6 9 6 6 6-6`,key:`qrunsl`}]]),_e=P(`ChevronLeft`,[[`path`,{d:`m15 18-6-6 6-6`,key:`1wnfg3`}]]),ve=P(`ChevronRight`,[[`path`,{d:`m9 18 6-6-6-6`,key:`mthhwq`}]]),ye=P(`Coins`,[[`circle`,{cx:`8`,cy:`8`,r:`6`,key:`3yglwk`}],[`path`,{d:`M18.09 10.37A6 6 0 1 1 10.34 18`,key:`t5s6rm`}],[`path`,{d:`M7 6h1v4`,key:`1obek4`}],[`path`,{d:`m16.71 13.88.7.71-2.82 2.82`,key:`1rbuyh`}]]),be=P(`Copy`,[[`rect`,{width:`14`,height:`14`,x:`8`,y:`8`,rx:`2`,ry:`2`,key:`17jyea`}],[`path`,{d:`M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2`,key:`zix9uf`}]]),xe=P(`Cpu`,[[`rect`,{width:`16`,height:`16`,x:`4`,y:`4`,rx:`2`,key:`14l7u7`}],[`rect`,{width:`6`,height:`6`,x:`9`,y:`9`,rx:`1`,key:`5aljv4`}],[`path`,{d:`M15 2v2`,key:`13l42r`}],[`path`,{d:`M15 20v2`,key:`15mkzm`}],[`path`,{d:`M2 15h2`,key:`1gxd5l`}],[`path`,{d:`M2 9h2`,key:`1bbxkp`}],[`path`,{d:`M20 15h2`,key:`19e6y8`}],[`path`,{d:`M20 9h2`,key:`19tzq7`}],[`path`,{d:`M9 2v2`,key:`165o2o`}],[`path`,{d:`M9 20v2`,key:`i2bqo8`}]]),B=P(`Crown`,[[`path`,{d:`M11.562 3.266a.5.5 0 0 1 .876 0L15.39 8.87a1 1 0 0 0 1.516.294L21.183 5.5a.5.5 0 0 1 .798.519l-2.834 10.246a1 1 0 0 1-.956.734H5.81a1 1 0 0 1-.957-.734L2.02 6.02a.5.5 0 0 1 .798-.519l4.276 3.664a1 1 0 0 0 1.516-.294z`,key:`1vdc57`}],[`path`,{d:`M5 21h14`,key:`11awu3`}]]),Se=P(`Database`,[[`ellipse`,{cx:`12`,cy:`5`,rx:`9`,ry:`3`,key:`msslwz`}],[`path`,{d:`M3 5V19A9 3 0 0 0 21 19V5`,key:`1wlel7`}],[`path`,{d:`M3 12A9 3 0 0 0 21 12`,key:`mv7ke4`}]]),Ce=P(`ExternalLink`,[[`path`,{d:`M15 3h6v6`,key:`1q9fwt`}],[`path`,{d:`M10 14 21 3`,key:`gplh6r`}],[`path`,{d:`M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6`,key:`a6xqqp`}]]),we=P(`Eye`,[[`path`,{d:`M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0`,key:`1nclc0`}],[`circle`,{cx:`12`,cy:`12`,r:`3`,key:`1v7zrd`}]]),Te=P(`FileJson2`,[[`path`,{d:`M4 22h14a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v4`,key:`1pf5j1`}],[`path`,{d:`M14 2v4a2 2 0 0 0 2 2h4`,key:`tnqrlb`}],[`path`,{d:`M4 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1`,key:`fq0c9t`}],[`path`,{d:`M8 18a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1 1 1 0 0 1-1-1v-1a1 1 0 0 0-1-1`,key:`4gibmv`}]]),Ee=P(`FileJson`,[[`path`,{d:`M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z`,key:`1rqfz7`}],[`path`,{d:`M14 2v4a2 2 0 0 0 2 2h4`,key:`tnqrlb`}],[`path`,{d:`M10 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1`,key:`1oajmo`}],[`path`,{d:`M14 18a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1 1 1 0 0 1-1-1v-1a1 1 0 0 0-1-1`,key:`mpwhp6`}]]),De=P(`Files`,[[`path`,{d:`M20 7h-3a2 2 0 0 1-2-2V2`,key:`x099mo`}],[`path`,{d:`M9 18a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h7l4 4v10a2 2 0 0 1-2 2Z`,key:`18t6ie`}],[`path`,{d:`M3 7.6v12.8A1.6 1.6 0 0 0 4.6 22h9.8`,key:`1nja0z`}]]),Oe=P(`Film`,[[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,key:`afitv7`}],[`path`,{d:`M7 3v18`,key:`bbkbws`}],[`path`,{d:`M3 7.5h4`,key:`zfgn84`}],[`path`,{d:`M3 12h18`,key:`1i2n21`}],[`path`,{d:`M3 16.5h4`,key:`1230mu`}],[`path`,{d:`M17 3v18`,key:`in4fa5`}],[`path`,{d:`M17 7.5h4`,key:`myr1c1`}],[`path`,{d:`M17 16.5h4`,key:`go4c1d`}]]),ke=P(`Flag`,[[`path`,{d:`M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z`,key:`i9b6wo`}],[`line`,{x1:`4`,x2:`4`,y1:`22`,y2:`15`,key:`1cm3nv`}]]),Ae=P(`Flame`,[[`path`,{d:`M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z`,key:`96xj49`}]]),je=P(`Gavel`,[[`path`,{d:`m14.5 12.5-8 8a2.119 2.119 0 1 1-3-3l8-8`,key:`15492f`}],[`path`,{d:`m16 16 6-6`,key:`vzrcl6`}],[`path`,{d:`m8 8 6-6`,key:`18bi4p`}],[`path`,{d:`m9 7 8 8`,key:`5jnvq1`}],[`path`,{d:`m21 11-8-8`,key:`z4y7zo`}]]),Me=P(`Gem`,[[`path`,{d:`M6 3h12l4 6-10 13L2 9Z`,key:`1pcd5k`}],[`path`,{d:`M11 3 8 9l4 13 4-13-3-6`,key:`1fcu3u`}],[`path`,{d:`M2 9h20`,key:`16fsjt`}]]),Ne=P(`Gift`,[[`rect`,{x:`3`,y:`8`,width:`18`,height:`4`,rx:`1`,key:`bkv52`}],[`path`,{d:`M12 8v13`,key:`1c76mn`}],[`path`,{d:`M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7`,key:`6wjy6b`}],[`path`,{d:`M7.5 8a2.5 2.5 0 0 1 0-5A4.8 8 0 0 1 12 8a4.8 8 0 0 1 4.5-5 2.5 2.5 0 0 1 0 5`,key:`1ihvrl`}]]),Pe=P(`Handshake`,[[`path`,{d:`m11 17 2 2a1 1 0 1 0 3-3`,key:`efffak`}],[`path`,{d:`m14 14 2.5 2.5a1 1 0 1 0 3-3l-3.88-3.88a3 3 0 0 0-4.24 0l-.88.88a1 1 0 1 1-3-3l2.81-2.81a5.79 5.79 0 0 1 7.06-.87l.47.28a2 2 0 0 0 1.42.25L21 4`,key:`9pr0kb`}],[`path`,{d:`m21 3 1 11h-2`,key:`1tisrp`}],[`path`,{d:`M3 3 2 14l6.5 6.5a1 1 0 1 0 3-3`,key:`1uvwmv`}],[`path`,{d:`M3 4h8`,key:`1ep09j`}]]),Fe=P(`HardDrive`,[[`line`,{x1:`22`,x2:`2`,y1:`12`,y2:`12`,key:`1y58io`}],[`path`,{d:`M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z`,key:`oot6mr`}],[`line`,{x1:`6`,x2:`6.01`,y1:`16`,y2:`16`,key:`sgf278`}],[`line`,{x1:`10`,x2:`10.01`,y1:`16`,y2:`16`,key:`1l4acy`}]]),Ie=P(`History`,[[`path`,{d:`M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8`,key:`1357e3`}],[`path`,{d:`M3 3v5h5`,key:`1xhq8a`}],[`path`,{d:`M12 7v5l4 2`,key:`1fdv2h`}]]),Le=P(`ImageOff`,[[`line`,{x1:`2`,x2:`22`,y1:`2`,y2:`22`,key:`a6p6uj`}],[`path`,{d:`M10.41 10.41a2 2 0 1 1-2.83-2.83`,key:`1bzlo9`}],[`line`,{x1:`13.5`,x2:`6`,y1:`13.5`,y2:`21`,key:`1q0aeu`}],[`line`,{x1:`18`,x2:`21`,y1:`12`,y2:`15`,key:`5mozeu`}],[`path`,{d:`M3.59 3.59A1.99 1.99 0 0 0 3 5v14a2 2 0 0 0 2 2h14c.55 0 1.052-.22 1.41-.59`,key:`mmje98`}],[`path`,{d:`M21 15V5a2 2 0 0 0-2-2H9`,key:`43el77`}]]),Re=P(`ImagePlus`,[[`path`,{d:`M16 5h6`,key:`1vod17`}],[`path`,{d:`M19 2v6`,key:`4bpg5p`}],[`path`,{d:`M21 11.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7.5`,key:`1ue2ih`}],[`path`,{d:`m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21`,key:`1xmnt7`}],[`circle`,{cx:`9`,cy:`9`,r:`2`,key:`af1f0g`}]]),ze=P(`KeyRound`,[[`path`,{d:`M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z`,key:`1s6t7t`}],[`circle`,{cx:`16.5`,cy:`7.5`,r:`.5`,fill:`currentColor`,key:`w0ekpg`}]]),Be=P(`LayoutDashboard`,[[`rect`,{width:`7`,height:`9`,x:`3`,y:`3`,rx:`1`,key:`10lvy0`}],[`rect`,{width:`7`,height:`5`,x:`14`,y:`3`,rx:`1`,key:`16une8`}],[`rect`,{width:`7`,height:`9`,x:`14`,y:`12`,rx:`1`,key:`1hutg5`}],[`rect`,{width:`7`,height:`5`,x:`3`,y:`16`,rx:`1`,key:`ldoo1y`}]]),Ve=P(`LifeBuoy`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`m4.93 4.93 4.24 4.24`,key:`1ymg45`}],[`path`,{d:`m14.83 9.17 4.24-4.24`,key:`1cb5xl`}],[`path`,{d:`m14.83 14.83 4.24 4.24`,key:`q42g0n`}],[`path`,{d:`m9.17 14.83-4.24 4.24`,key:`bqpfvv`}],[`circle`,{cx:`12`,cy:`12`,r:`4`,key:`4exip2`}]]),He=P(`LogOut`,[[`path`,{d:`M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4`,key:`1uf3rs`}],[`polyline`,{points:`16 17 21 12 16 7`,key:`1gabdz`}],[`line`,{x1:`21`,x2:`9`,y1:`12`,y2:`12`,key:`1uyos4`}]]),Ue=P(`Mail`,[[`rect`,{width:`20`,height:`16`,x:`2`,y:`4`,rx:`2`,key:`18n3k1`}],[`path`,{d:`m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7`,key:`1ocrg3`}]]),V=P(`Megaphone`,[[`path`,{d:`m3 11 18-5v12L3 14v-3z`,key:`n962bs`}],[`path`,{d:`M11.6 16.8a3 3 0 1 1-5.8-1.6`,key:`1yl0tm`}]]),We=P(`MemoryStick`,[[`path`,{d:`M6 19v-3`,key:`1nvgqn`}],[`path`,{d:`M10 19v-3`,key:`iu8nkm`}],[`path`,{d:`M14 19v-3`,key:`kcehxu`}],[`path`,{d:`M18 19v-3`,key:`1vh91z`}],[`path`,{d:`M8 11V9`,key:`63erz4`}],[`path`,{d:`M16 11V9`,key:`fru6f3`}],[`path`,{d:`M12 11V9`,key:`ha00sb`}],[`path`,{d:`M2 15h20`,key:`16ne18`}],[`path`,{d:`M2 7a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v1.1a2 2 0 0 0 0 3.837V17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-5.1a2 2 0 0 0 0-3.837Z`,key:`lhddv3`}]]),Ge=P(`MessageSquareText`,[[`path`,{d:`M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z`,key:`1lielz`}],[`path`,{d:`M13 8H7`,key:`14i4kc`}],[`path`,{d:`M17 12H7`,key:`16if0g`}]]),Ke=P(`Moon`,[[`path`,{d:`M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z`,key:`a7tn18`}]]),qe=P(`Package`,[[`path`,{d:`M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z`,key:`1a0edw`}],[`path`,{d:`M12 22V12`,key:`d0xqtd`}],[`path`,{d:`m3.3 7 7.703 4.734a2 2 0 0 0 1.994 0L20.7 7`,key:`yx3hmr`}],[`path`,{d:`m7.5 4.27 9 5.15`,key:`1c824w`}]]),Je=P(`Palette`,[[`circle`,{cx:`13.5`,cy:`6.5`,r:`.5`,fill:`currentColor`,key:`1okk4w`}],[`circle`,{cx:`17.5`,cy:`10.5`,r:`.5`,fill:`currentColor`,key:`f64h9f`}],[`circle`,{cx:`8.5`,cy:`7.5`,r:`.5`,fill:`currentColor`,key:`fotxhn`}],[`circle`,{cx:`6.5`,cy:`12.5`,r:`.5`,fill:`currentColor`,key:`qy21gx`}],[`path`,{d:`M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z`,key:`12rzf8`}]]),Ye=P(`Pause`,[[`rect`,{x:`14`,y:`4`,width:`4`,height:`16`,rx:`1`,key:`zuxfzm`}],[`rect`,{x:`6`,y:`4`,width:`4`,height:`16`,rx:`1`,key:`1okwgv`}]]),Xe=P(`Phone`,[[`path`,{d:`M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z`,key:`foiqr5`}]]),Ze=P(`Play`,[[`polygon`,{points:`6 3 20 12 6 21 6 3`,key:`1oa8hb`}]]),Qe=P(`Plus`,[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`M12 5v14`,key:`s699le`}]]),$e=P(`PowerOff`,[[`path`,{d:`M18.36 6.64A9 9 0 0 1 20.77 15`,key:`dxknvb`}],[`path`,{d:`M6.16 6.16a9 9 0 1 0 12.68 12.68`,key:`1x7qb5`}],[`path`,{d:`M12 2v4`,key:`3427ic`}],[`path`,{d:`m2 2 20 20`,key:`1ooewy`}]]),et=P(`Power`,[[`path`,{d:`M12 2v10`,key:`mnfbl`}],[`path`,{d:`M18.4 6.6a9 9 0 1 1-12.77.04`,key:`obofu9`}]]),tt=P(`Radio`,[[`path`,{d:`M4.9 19.1C1 15.2 1 8.8 4.9 4.9`,key:`1vaf9d`}],[`path`,{d:`M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5`,key:`u1ii0m`}],[`circle`,{cx:`12`,cy:`12`,r:`2`,key:`1c9p78`}],[`path`,{d:`M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5`,key:`1j5fej`}],[`path`,{d:`M19.1 4.9C23 8.8 23 15.1 19.1 19`,key:`10b0cb`}]]),nt=P(`RefreshCw`,[[`path`,{d:`M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8`,key:`v9h5vc`}],[`path`,{d:`M21 3v5h-5`,key:`1q7to0`}],[`path`,{d:`M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16`,key:`3uifl3`}],[`path`,{d:`M8 16H3v5`,key:`1cv678`}]]),rt=P(`Save`,[[`path`,{d:`M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z`,key:`1c8476`}],[`path`,{d:`M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7`,key:`1ydtos`}],[`path`,{d:`M7 3v4a1 1 0 0 0 1 1h7`,key:`t51u73`}]]),it=P(`Search`,[[`circle`,{cx:`11`,cy:`11`,r:`8`,key:`4ej97u`}],[`path`,{d:`m21 21-4.3-4.3`,key:`1qie3q`}]]),at=P(`Send`,[[`path`,{d:`M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z`,key:`1ffxy3`}],[`path`,{d:`m21.854 2.147-10.94 10.939`,key:`12cjpa`}]]),ot=P(`Server`,[[`rect`,{width:`20`,height:`8`,x:`2`,y:`2`,rx:`2`,ry:`2`,key:`ngkwjq`}],[`rect`,{width:`20`,height:`8`,x:`2`,y:`14`,rx:`2`,ry:`2`,key:`iecqi9`}],[`line`,{x1:`6`,x2:`6.01`,y1:`6`,y2:`6`,key:`16zg32`}],[`line`,{x1:`6`,x2:`6.01`,y1:`18`,y2:`18`,key:`nzw8ys`}]]),st=P(`Settings2`,[[`path`,{d:`M20 7h-9`,key:`3s1dr2`}],[`path`,{d:`M14 17H5`,key:`gfn3mx`}],[`circle`,{cx:`17`,cy:`17`,r:`3`,key:`18b49y`}],[`circle`,{cx:`7`,cy:`7`,r:`3`,key:`dfmy0x`}]]),ct=P(`ShieldAlert`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`M12 8v4`,key:`1got3b`}],[`path`,{d:`M12 16h.01`,key:`1drbdi`}]]),lt=P(`ShieldCheck`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),ut=P(`ShieldOff`,[[`path`,{d:`m2 2 20 20`,key:`1ooewy`}],[`path`,{d:`M5 5a1 1 0 0 0-1 1v7c0 5 3.5 7.5 7.67 8.94a1 1 0 0 0 .67.01c2.35-.82 4.48-1.97 5.9-3.71`,key:`1jlk70`}],[`path`,{d:`M9.309 3.652A12.252 12.252 0 0 0 11.24 2.28a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1v7a9.784 9.784 0 0 1-.08 1.264`,key:`18rp1v`}]]),dt=P(`Shield`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}]]),ft=P(`SlidersHorizontal`,[[`line`,{x1:`21`,x2:`14`,y1:`4`,y2:`4`,key:`obuewd`}],[`line`,{x1:`10`,x2:`3`,y1:`4`,y2:`4`,key:`1q6298`}],[`line`,{x1:`21`,x2:`12`,y1:`12`,y2:`12`,key:`1iu8h1`}],[`line`,{x1:`8`,x2:`3`,y1:`12`,y2:`12`,key:`ntss68`}],[`line`,{x1:`21`,x2:`16`,y1:`20`,y2:`20`,key:`14d8ph`}],[`line`,{x1:`12`,x2:`3`,y1:`20`,y2:`20`,key:`m0wm8r`}],[`line`,{x1:`14`,x2:`14`,y1:`2`,y2:`6`,key:`14e1ph`}],[`line`,{x1:`8`,x2:`8`,y1:`10`,y2:`14`,key:`1i6ji0`}],[`line`,{x1:`16`,x2:`16`,y1:`18`,y2:`22`,key:`1lctlv`}]]),pt=P(`Smile`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M8 14s1.5 2 4 2 4-2 4-2`,key:`1y1vjs`}],[`line`,{x1:`9`,x2:`9.01`,y1:`9`,y2:`9`,key:`yxxnd0`}],[`line`,{x1:`15`,x2:`15.01`,y1:`9`,y2:`9`,key:`1p4y9e`}]]),mt=P(`Stamp`,[[`path`,{d:`M5 22h14`,key:`ehvnwv`}],[`path`,{d:`M19.27 13.73A2.5 2.5 0 0 0 17.5 13h-11A2.5 2.5 0 0 0 4 15.5V17a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-1.5c0-.66-.26-1.3-.73-1.77Z`,key:`1sy9ra`}],[`path`,{d:`M14 13V8.5C14 7 15 7 15 5a3 3 0 0 0-3-3c-1.66 0-3 1-3 3s1 2 1 3.5V13`,key:`cnxgux`}]]),ht=P(`Star`,[[`path`,{d:`M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z`,key:`r04s7s`}]]),gt=P(`Sticker`,[[`path`,{d:`M15.5 3H5a2 2 0 0 0-2 2v14c0 1.1.9 2 2 2h14a2 2 0 0 0 2-2V8.5L15.5 3Z`,key:`1wis1t`}],[`path`,{d:`M14 3v4a2 2 0 0 0 2 2h4`,key:`36rjfy`}],[`path`,{d:`M8 13h.01`,key:`1sbv64`}],[`path`,{d:`M16 13h.01`,key:`wip0gl`}],[`path`,{d:`M10 16s.8 1 2 1c1.3 0 2-1 2-1`,key:`1vvgv3`}]]),_t=P(`Sun`,[[`circle`,{cx:`12`,cy:`12`,r:`4`,key:`4exip2`}],[`path`,{d:`M12 2v2`,key:`tus03m`}],[`path`,{d:`M12 20v2`,key:`1lh1kg`}],[`path`,{d:`m4.93 4.93 1.41 1.41`,key:`149t6j`}],[`path`,{d:`m17.66 17.66 1.41 1.41`,key:`ptbguv`}],[`path`,{d:`M2 12h2`,key:`1t8f8n`}],[`path`,{d:`M20 12h2`,key:`1q8mjw`}],[`path`,{d:`m6.34 17.66-1.41 1.41`,key:`1m8zz5`}],[`path`,{d:`m19.07 4.93-1.41 1.41`,key:`1shlcs`}]]),vt=P(`Timer`,[[`line`,{x1:`10`,x2:`14`,y1:`2`,y2:`2`,key:`14vaq8`}],[`line`,{x1:`12`,x2:`15`,y1:`14`,y2:`11`,key:`17fdiu`}],[`circle`,{cx:`12`,cy:`14`,r:`8`,key:`1e1u0o`}]]),yt=P(`Trash2`,[[`path`,{d:`M3 6h18`,key:`d0wm0j`}],[`path`,{d:`M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6`,key:`4alrt4`}],[`path`,{d:`M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2`,key:`v07s0e`}],[`line`,{x1:`10`,x2:`10`,y1:`11`,y2:`17`,key:`1uufr5`}],[`line`,{x1:`14`,x2:`14`,y1:`11`,y2:`17`,key:`xtxkd`}]]),bt=P(`Trophy`,[[`path`,{d:`M6 9H4.5a2.5 2.5 0 0 1 0-5H6`,key:`17hqa7`}],[`path`,{d:`M18 9h1.5a2.5 2.5 0 0 0 0-5H18`,key:`lmptdp`}],[`path`,{d:`M4 22h16`,key:`57wxv0`}],[`path`,{d:`M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22`,key:`1nw9bq`}],[`path`,{d:`M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22`,key:`1np0yb`}],[`path`,{d:`M18 2H6v7a6 6 0 0 0 12 0V2Z`,key:`u46fv3`}]]),xt=P(`Undo2`,[[`path`,{d:`M9 14 4 9l5-5`,key:`102s5s`}],[`path`,{d:`M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11`,key:`f3b9sd`}]]),St=P(`Upload`,[[`path`,{d:`M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4`,key:`ih7n3h`}],[`polyline`,{points:`17 8 12 3 7 8`,key:`t8dd8p`}],[`line`,{x1:`12`,x2:`12`,y1:`3`,y2:`15`,key:`widbto`}]]),Ct=P(`UserRoundPen`,[[`path`,{d:`M2 21a8 8 0 0 1 10.821-7.487`,key:`1c8h7z`}],[`path`,{d:`M21.378 16.626a1 1 0 0 0-3.004-3.004l-4.01 4.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z`,key:`1817ys`}],[`circle`,{cx:`10`,cy:`8`,r:`5`,key:`o932ke`}]]),wt=P(`User`,[[`path`,{d:`M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2`,key:`975kel`}],[`circle`,{cx:`12`,cy:`7`,r:`4`,key:`17ys0d`}]]),Tt=P(`Users`,[[`path`,{d:`M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2`,key:`1yyitq`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}],[`path`,{d:`M22 21v-2a4 4 0 0 0-3-3.87`,key:`kshegd`}],[`path`,{d:`M16 3.13a4 4 0 0 1 0 7.75`,key:`1da9ce`}]]),Et=P(`Vault`,[[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,key:`afitv7`}],[`circle`,{cx:`7.5`,cy:`7.5`,r:`.5`,fill:`currentColor`,key:`kqv944`}],[`path`,{d:`m7.9 7.9 2.7 2.7`,key:`hpeyl3`}],[`circle`,{cx:`16.5`,cy:`7.5`,r:`.5`,fill:`currentColor`,key:`w0ekpg`}],[`path`,{d:`m13.4 10.6 2.7-2.7`,key:`264c1n`}],[`circle`,{cx:`7.5`,cy:`16.5`,r:`.5`,fill:`currentColor`,key:`nkw3mc`}],[`path`,{d:`m7.9 16.1 2.7-2.7`,key:`p81g5e`}],[`circle`,{cx:`16.5`,cy:`16.5`,r:`.5`,fill:`currentColor`,key:`fubopw`}],[`path`,{d:`m13.4 13.4 2.7 2.7`,key:`abhel3`}],[`circle`,{cx:`12`,cy:`12`,r:`2`,key:`1c9p78`}]]),Dt=P(`X`,[[`path`,{d:`M18 6 6 18`,key:`1bl5f8`}],[`path`,{d:`m6 6 12 12`,key:`d8bk6v`}]]),Ot=o((e=>{var t=u(),n=Symbol.for(`react.element`),r=Symbol.for(`react.fragment`),i=Object.prototype.hasOwnProperty,a=t.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,o={key:!0,ref:!0,__self:!0,__source:!0};function s(e,t,r){var s,c={},l=null,u=null;for(s in r!==void 0&&(l=``+r),t.key!==void 0&&(l=``+t.key),t.ref!==void 0&&(u=t.ref),t)i.call(t,s)&&!o.hasOwnProperty(s)&&(c[s]=t[s]);if(e&&e.defaultProps)for(s in t=e.defaultProps,t)c[s]===void 0&&(c[s]=t[s]);return{$$typeof:n,type:e,key:l,ref:u,props:c,_owner:a.current}}e.Fragment=r,e.jsx=s,e.jsxs=s})),H=o(((e,t)=>{t.exports=Ot()}))(),kt=`telesrv.admin.lang`,At={en:{"app.adminConsole":`Admin Console`,"app.localAccess":`Local access`,"app.title":`telesrv admin`,"common.actions":`Actions`,"common.admins":`Admins`,"common.backToList":`Back to list`,"common.channel":`Channel`,"common.channelOrGroup":`Channel / Group`,"common.clear":`Clear`,"common.close":`Close`,"common.count":`Count`,"common.deleted":`Deleted`,"common.detail":`Details`,"common.device":`Device`,"common.disabled":`Disabled`,"common.enabled":`Enabled`,"common.fromPeer":`From Peer`,"common.group":`Group`,"common.id":`ID`,"common.limit":`Limit`,"common.loading":`Loading`,"common.member":`Member`,"common.members":`Members`,"common.messageId":`Message ID`,"common.name":`Name`,"common.no":`No`,"common.noResults":`No results`,"common.none":`None`,"common.normal":`Normal`,"common.operations":`Operations`,"common.owner":`Owner`,"common.platform":`Platform`,"common.refresh":`Refresh`,"common.search":`Search`,"common.sender":`Sender`,"common.status":`Status`,"common.survived":`Live`,"common.time":`Time`,"common.type":`Type`,"common.updatedAt":`Updated`,"common.username":`Username`,"common.valid":`Valid`,"common.verified":`Verified`,"common.views":`Views`,"common.yes":`Yes`,"route.accounts":`Accounts`,"route.accountsSubtitle":`Console / Accounts`,"route.channels":`Supergroups and Channels`,"route.channelsSubtitle":`Console / Channels`,"route.moderation":`Reports and Moderation`,"route.moderationSubtitle":`Console / Moderation`,"route.dashboard":`Operations Console`,"route.dashboardSubtitle":`Console / Overview`,"route.messages":`Message Audit`,"route.messagesSubtitle":`Console / Messages`,"route.gifts":`Star Gifts`,"route.giftsSubtitle":`Console / Star Gifts`,"route.auctions":`Auctions & Drops`,"route.auctionsSubtitle":`Console / Auctions & Drops`,"route.premium":`Stars & Premium`,"route.premiumSubtitle":`Console / Monetization`,"route.giveGifts":`Give Gifts`,"route.giveGiftsSubtitle":`Console / Give Gifts`,"layout.navigation":`Navigation`,"layout.primaryNav":`Primary navigation`,"layout.dashboard":`Overview`,"layout.accounts":`Accounts`,"layout.channels":`Supergroups / Channels`,"layout.moderation":`Reports / Moderation`,"layout.messages":`Messages`,"layout.gifts":`Star Gifts`,"layout.auctions":`Auctions & Drops`,"layout.premium":`Stars & Premium`,"layout.giveGifts":`Give Gifts`,"layout.privateMessages":`Private`,"layout.groupMessages":`Groups`,"layout.runtime":`Runtime`,"layout.adminBackend":`Admin backend`,"layout.ready":`Ready`,"layout.pgRead":`PG read`,"layout.readOnly":`Read-only`,"layout.writeOps":`Write operations`,"layout.dryRun":`Dry-run`,"layout.actor":`Actor: {actor}`,"layout.logout":`Log out`,"language.en":`EN`,"language.zh":`中文`,"language.ru":`RU`,"theme.switchToDark":`Switch to dark theme`,"theme.switchToLight":`Switch to light theme`,"login.heading":`Operations Admin`,"login.body":`Enter credentials to open the console.`,"login.secret":`Admin password or token`,"login.submit":`Log in`,"login.submitting":`Logging in`,"dashboard.eyebrow":`Runtime Overview`,"dashboard.title":`Console Overview`,"dashboard.readPath":`Read path`,"dashboard.readPathValue":`PG read-only`,"dashboard.writePath":`Write path`,"dashboard.executionPolicy":`Execution policy`,"dashboard.dryRunFirst":`Dry-run first`,"dashboard.accountsText":`Account status, premium, verification, sessions.`,"dashboard.channelsText":`Public entities, member counts, verification state.`,"dashboard.messagesText":`Message boxes, updates, outbox state.`,"dashboard.strip.dryRun":`All dangerous actions start with dry-run`,"dashboard.strip.token":`Browser never stores internal tokens`,"dashboard.strip.pagination":`Lists use cursor pagination`,"dashboard.strip.snapshot":`Detail pages retain raw state snapshots`,"account.pageTitle":`Accounts`,"account.queryResults":`Search results`,"account.recentActive":`Recently active accounts`,"account.currentPage":`Accounts on page`,"account.onlineDevices":`Online device records`,"account.premium":`Premium`,"account.frozen":`Frozen`,"account.searchPlaceholder":`User ID / phone / username`,"account.userID":`User ID`,"account.phone":`Phone`,"account.lastActive":`Last active`,"account.notVerified":`Not verified`,"account.notPremium":`Not premium`,"account.premiumUntil":`Premium expires`,"account.starsBalance":`Stars balance`,"account.startingGrantApplied":`initial grant applied`,"account.startingGrantPending":`initial grant pending`,"account.activeSessions":`Authorized devices`,"account.accountFlags":`Account flags`,"account.restriction":`Restriction`,"account.restricted":`Restricted`,"account.createdAt":`Created`,"account.detailTitle":`Account #{id}`,"account.profile":`Account Profile`,"account.loadingDetail":`Loading account detail`,"account.waitingData":`Waiting for data`,"account.noUsername":`No username`,"account.noPhone":`No phone`,"account.accountFrozen":`Account frozen`,"account.accountActive":`Account active`,"account.authorizationsTitle":`Authorized Devices`,"account.authorizationsCount":`{count} authorizations`,"account.recentAdminOps":`Recent Admin Actions`,"account.recent30Audit":`Last 30 audit rows`,"account.actionDock":`Account Actions`,"account.freezeAccount":`Freeze account`,"account.updateFreeze":`Update freeze`,"account.unfreezeAccount":`Unfreeze account`,"account.freezeSince":`Frozen since`,"account.freezeUntil":`Appeal deadline`,"account.freezeUntilAria":`Freeze appeal deadline`,"account.freezeAppealURL":`Appeal URL`,"account.freezeAppealURLAria":`Freeze appeal URL`,"account.premiumMonths":`Premium duration (months)`,"account.premiumMonthsAria":`Set premium duration in months`,"account.setPremium":`Set premium`,"account.clearPremium":`Clear premium`,"premium.title":`Stars & Premium`,"premium.eyebrow":`Unified monetization`,"premium.starsChannel":`Stars purchases`,"premium.starsChannelHint":`Native self top-ups and gifts to other users settle through one Stars ledger.`,"premium.premiumChannel":`Premium purchases`,"premium.premiumChannelHint":`The native menu and @premiumbot share plans, prices, payment forms, and entitlements.`,"premium.sharedLedger":`Shared ledger`,"premium.sharedCatalog":`Shared catalog`,"premium.operations":`Manual account operations`,"premium.operationsHint":`Grant Stars or Premium from the same workspace. Every write uses confirmation and audit metadata.`,"premium.userID":`User ID`,"premium.userIDPlaceholder":`e.g. 1000000001`,"premium.starsAmount":`Stars amount`,"premium.grantStars":`Grant Stars`,"premium.grantPremium":`Grant Premium`,"premium.catalog":`Premium catalog`,"premium.catalogHint":`Edit Stars prices, durations, visibility, labels, and display order. Existing payment forms keep their original snapshot and are rejected if the price changed.`,"premium.catalogSummary":`{enabled} enabled · {total} total`,"premium.addPlan":`Add plan`,"premium.newPlan":`New Premium plan`,"premium.newPlanHint":`The month count is the stable catalog key and cannot be changed after creation.`,"premium.defaultPlanLabel":`1 month`,"premium.planTerm":`{months} mo · {days} days`,"premium.planDetails":`Plan details`,"premium.pricing":`Pricing`,"premium.fiatPrice":`Fiat price`,"premium.fiatCurrency":`Currency (ISO 4217)`,"premium.fiatAmount":`Fiat amount (minor units)`,"premium.storeSettings":`Store purchase`,"premium.storeProduct":`Store product`,"premium.storeProductPlaceholder":`e.g. telegram_premium_3m`,"premium.storeQuantity":`Store quantity`,"premium.storeSettingsHint":`Leave the product empty and quantity at 0 when no native store product is used.`,"premium.editPlan":`Edit`,"premium.months":`Months`,"premium.durationDays":`Duration, days`,"premium.amountStars":`Price`,"premium.label":`Label`,"premium.sortOrder":`Order`,"premium.source":`Managed by`,"premium.sourceAdmin":`Web admin`,"premium.sourceConfig":`Environment`,"premium.version":`Version`,"premium.save":`Save`,"premium.create":`Create plan`,"account.starsAmount":`Stars to grant`,"account.starsAmountAria":`Set Stars amount to grant`,"account.grantStars":`Grant Stars`,"account.setVerified":`Set verified`,"account.clearVerified":`Clear verified`,"channel.pageTitle":`Supergroups and Channels`,"channel.recentUpdated":`Recently updated`,"channel.currentPage":`Entities on page`,"channel.megagroups":`Supergroups`,"channel.broadcasts":`Channels`,"channel.verifiedCount":`Verified`,"channel.searchPlaceholder":`Channel ID / username / title`,"channel.channelID":`Channel ID`,"channel.kind":`Kind`,"channel.title":`Title`,"channel.pts":`PTS`,"channel.detailProfile":`Channel Profile`,"channel.loadingDetail":`Loading channel detail`,"channel.creator":`Creator {id}`,"channel.governance":`Moderation`,"channel.governanceValue":`Banned {banned} / Kicked {kicked}`,"channel.flags":`Channel flags`,"channel.rawRow":`Channel Raw Row`,"channel.rawRowText":`Database read-only snapshot`,"channel.actionDock":`Channel Actions`,"channel.setVerified":`Set verified`,"channel.clearVerified":`Clear verified`,"channel.kind.broadcast":`Channel`,"channel.kind.forum":`Supergroup / Forum`,"channel.kind.megagroup":`Supergroup`,"channel.kind.generic":`Channel / Group`,"route.bots":`Bots`,"route.botsSubtitle":`Console / Bots`,"layout.bots":`Bots`,"route.broadcasts":`Broadcasts`,"route.broadcastsSubtitle":`Console / Broadcasts`,"layout.broadcasts":`Broadcasts`,"broadcast.pageTitle":`System broadcasts`,"broadcast.pageHint":`Durable messages from the official 777000 account`,"broadcast.createTitle":`Create a system broadcast`,"broadcast.createHint":`Dry-run validates the target. Confirmation queues bounded background delivery; it never sends every message in this request.`,"broadcast.create":`Create broadcast`,"broadcast.message":`Message`,"broadcast.messagePlaceholder":`Message shown in the 777000 conversation`,"broadcast.messageBytes":`{count} / 4096 bytes`,"broadcast.target":`Recipients`,"broadcast.targetAll":`All accounts`,"broadcast.targetSelected":`Selected accounts`,"broadcast.userIDs":`User IDs`,"broadcast.userIDsHint":`Comma or whitespace separated; 1-200 unique live account IDs.`,"broadcast.currentPage":`Campaigns on page`,"broadcast.active":`Active`,"broadcast.failed":`Failed`,"broadcast.id":`Campaign ID`,"broadcast.progress":`Delivered / target`,"broadcast.status":`Status`,"broadcast.createdBy":`Created by`,"broadcast.enumerating":`Enumerating`,"broadcast.delivering":`Delivering`,"broadcast.completed":`Completed`,"bots.pageTitle":`Bots`,"bots.queryResults":`Search results`,"bots.recent":`Recently created bots`,"bots.currentPage":`Bots on page`,"bots.banned":`Banned`,"bots.active":`Active`,"bots.createTitle":`Create a system bot`,"bots.createHint":`Provision a bot account owned by the given user. The token is shown once after confirmation.`,"bots.ownerUserID":`Owner user ID`,"bots.name":`Display name`,"bots.namePlaceholder":`e.g. Service Bot`,"bots.username":`Username`,"bots.usernameHint":`Username must be 5-32 characters and end with 'bot'.`,"bots.tokenResultHint":`After confirmation, keep the result open and copy the one-time token before closing either dialog.`,"bots.create":`Create bot`,"bots.searchPlaceholder":`Bot ID / username`,"bots.botID":`Bot ID`,"bots.owner":`Owner`,"bots.status":`Status`,"bots.detailTitle":`Bot #{id}`,"bots.profile":`Bot Profile`,"bots.loadingDetail":`Loading bot detail`,"bots.unnamed":`Unnamed bot`,"bots.restriction":`Restriction`,"bots.actionDock":`Bot Actions`,"bots.banUntil":`Ban until`,"bots.ban":`Ban bot`,"bots.updateBan":`Update ban`,"bots.unban":`Unban bot`,"bots.type":`Type`,"bots.system":`System`,"bots.user":`User`,"bots.delete":`Delete bot`,"bots.deleteHint":`Permanently deletes this user-created bot and invalidates its token. This cannot be undone.`,"bots.systemHint":`System bots are built in and cannot be deleted.`,"flags.scam":`SCAM`,"flags.fake":`FAKE`,"flags.setScam":`Mark as SCAM`,"flags.clearScam":`Clear SCAM`,"flags.setFake":`Mark as FAKE`,"flags.clearFake":`Clear FAKE`,"attr.attributes":`Attributes`,"attr.settings":`Settings`,"attr.username":`Username`,"attr.setUsername":`Set username`,"attr.setSupport":`Mark as support`,"attr.clearSupport":`Clear support`,"attr.forProfile":`Profile color`,"attr.hasColor":`Enable color`,"attr.colorIndex":`Color index`,"attr.bgEmojiID":`Background emoji ID`,"attr.setColor":`Set color`,"attr.emojiDocID":`Emoji document ID`,"attr.emojiUntil":`Until (unix, 0 = permanent)`,"attr.setEmojiStatus":`Set emoji status`,"attr.gigagroup":`Gigagroup`,"attr.antispam":`Aggressive anti-spam`,"attr.participantsHidden":`Hide members`,"attr.noforwards":`Restrict forwarding`,"attr.joinToSend":`Join to send messages`,"attr.joinRequest":`Join by request`,"attr.slowmode":`Slowmode (seconds)`,"attr.applySettings":`Apply settings`,"route.stickers":`Stickers`,"route.stickersSubtitle":`Console / Stickers`,"layout.stickers":`Stickers`,"route.emoji":`Emoji`,"route.emojiSubtitle":`Console / Emoji`,"layout.emoji":`Emoji`,"route.gifCatalog":`GIF catalog`,"route.gifCatalogSubtitle":`Console / GIF catalog`,"layout.gifCatalog":`GIFs`,"emoji.pageTitle":`Custom Emoji`,"emoji.queryResults":`Search results`,"emoji.recent":`Custom emoji catalog`,"emoji.currentPage":`Emoji on page`,"emoji.searchPlaceholder":`Document ID or emoji`,"emoji.copyID":`Copy document ID`,"emoji.noSet":`No set`,"emoji.hint":`Document IDs here can be pasted into the Emoji status field on account, bot and channel profiles.`,"emoji.packCatalog":`Emoji packs`,"emoji.documentCatalog":`All documents`,"messages.privateTitle":`Private Messages`,"messages.privateEyebrow":`Private message boxes`,"messages.groupTitle":`Group Messages`,"messages.groupEyebrow":`Supergroup / channel messages`,"messages.selectPrivatePeers":`Search and select the owner user and peer user first`,"messages.selectChannel":`Search and select a supergroup or channel first`,"messages.ownerUser":`Owner user`,"messages.peerUser":`Peer user`,"messages.beforeDatePlaceholder":`before_date cursor`,"messages.beforeIDPlaceholder":`before_msg_id cursor`,"messages.limitPlaceholder":`limit <= 100`,"messages.searchMessages":`Search messages`,"messages.nextPage":`Next page`,"messages.previousPage":`Previous page`,"messages.currentPage":`Messages on page`,"messages.deleted":`Deleted`,"messages.outgoing":`Outgoing`,"messages.incoming":`Incoming`,"messages.ownerPeer":`Owner / Peer`,"messages.deleteSelected":`Delete selected messages`,"messages.idsPlaceholder":`Message IDs, comma separated`,"messages.revoke":`Revoke for both sides`,"messages.previewDelete":`Dry-run delete`,"messages.clearHistory":`Clear private history`,"messages.maxIDPlaceholder":`max_id cutoff`,"messages.maxBatchesPlaceholder":`max_batches`,"messages.justClear":`Clear only this side`,"messages.previewClearHistory":`Dry-run clear history`,"messages.direction":`Direction`,"messages.body":`Body`,"messages.privateDetailTitle":`Message #{id}`,"messages.detailEyebrow":`Message Detail`,"messages.backPrivate":`Back to private messages`,"messages.backGroup":`Back to group messages`,"messages.ownerPeerTitle":`Owner {owner} · Peer {peer}`,"messages.senderSubtitle":`Sender {sender} · {date}`,"messages.boxID":`Message box ID`,"messages.privateMessageID":`Private message ID`,"messages.messageSender":`Message sender`,"messages.messageBox":`Message Box`,"messages.dialogRow":`Dialog Row`,"messages.privateRow":`Private Message Row`,"messages.channelMessageRow":`Channel Message Row`,"messages.channelRow":`Channel Row`,"messages.userUpdateEvents":`Update Events`,"messages.channelUpdateEvents":`Channel Update Events`,"messages.eventJson":`Event JSON`,"messages.dispatchOutbox":`Dispatch Queue`,"messages.messageBoxesSnapshot":`message_boxes read-only snapshot`,"messages.dialogSnapshot":`dialogs read-only snapshot`,"messages.privateSnapshot":`private_messages read-only snapshot`,"messages.channelMessagesSnapshot":`channel_messages read-only snapshot`,"messages.channelSnapshot":`channels read-only snapshot`,"messages.userEventsSource":`durable user_update_events`,"messages.channelEventsSource":`durable channel_update_events`,"messages.outboxSource":`online/offline dispatch_outbox`,"messages.attempts":`Attempts`,"messages.deleteThis":`Delete this message`,"messages.groupDetailTitle":`Group Message #{id}`,"messages.channelGroupTitle":`Channel / Group {id}`,"messages.mediaCount":`With media`,"messages.channelPosts":`Channel posts`,"messages.channelGroup":`Channel / Group`,"messages.pinned":`Pinned`,"messages.channelPost":`Channel post`,"gifts.pageTitle":`Star Gift Catalog`,"giveGift.action":`Give`,"giveGift.eyebrow":`Grant a gift · no charge`,"giveGift.title":`Give gift`,"giveGift.recipientKind":`Recipient type`,"giveGift.recipientUser":`User`,"giveGift.recipientChannel":`Channel`,"giveGift.pickUser":`Recipient user`,"giveGift.pickChannel":`Recipient channel`,"giveGift.recipientRequired":`Select a recipient first`,"giveGift.sender":`Sender account ID`,"giveGift.senderHint":`Gifts are always sent from the system account 777000 (Telesrv).`,"giveGift.message":`Attached message (optional)`,"giveGift.messagePlaceholder":`Shown with the gift`,"giveGift.hideName":`Hide sender name from recipient`,"giveGift.upgrade":`Deliver as upgraded collectible`,"giveGift.upgradeNote":`The gift is minted as a unique collectible. Pick specific attributes below, or leave them on Random to draw from the published pool. The collectible number is assigned automatically. Requires a published collectible upgrade with remaining supply.`,"giveGift.model":`Model`,"giveGift.pattern":`Pattern`,"giveGift.backdrop":`Backdrop`,"giveGift.random":`Random`,"giveGift.confirm":`Give gift`,"giveGifts.pageTitle":`Give Gifts`,"giveGifts.eyebrow":`Grant catalog gifts to any user or channel`,"giveGifts.available":`Available gifts`,"giveGifts.sender":`Default sender`,"giveGifts.searchPlaceholder":`Search by title or gift ID`,"giveGifts.hint":`Pick a gift to grant. Delivery is free of charge and sent from the system account 777000 (Telesrv) by default.`,"giveGifts.pickGift":`Select a gift`,"giveGifts.selectPrompt":`Select a gift from the list to start.`,"gifts.eyebrow":`Catalog, immutable revisions and animation assets`,"gifts.total":`Catalog entries`,"gifts.enabled":`Enabled`,"gifts.received":`Received gifts`,"gifts.formats":`Accepted formats`,"gifts.add":`Add gift`,"gifts.searchPlaceholder":`Search gift ID, title or format`,"gifts.listSummary":`Showing {shown} of {total}`,"gifts.idRevision":`ID / Revision`,"gifts.price":`Price / Conversion`,"gifts.importTitle":`Import a Star Gift`,"gifts.importEyebrow":`Gift catalog operation`,"gifts.newRevision":`Create revision for gift #{id}`,"gifts.importHint":`Upload TGS or plain Lottie JSON. Lottie is normalized and compressed to TGS.`,"gifts.officialSource":`Official snapshot`,"gifts.fileSource":`Upload file`,"gifts.officialHint":`Choose a verified gift from data/official-gifts. Complete collectible pools are imported atomically.`,"gifts.officialSearch":`Search official gift ID or title`,"gifts.officialSelect":`Choose an official gift`,"gifts.officialRequired":`Choose an official gift first`,"gifts.officialResults":`Showing {shown} of {total}`,"gifts.officialCategoryLabel":`Official gift capability category`,"gifts.officialCategory.all":`All`,"gifts.officialCategory.upgrade":`Upgradable`,"gifts.officialCategory.craft":`Craftable`,"gifts.officialCategory.basic":`Not upgradable`,"gifts.officialUnnamed":`Unnamed official gift #{id}`,"gifts.officialAttributes":`{count} attributes`,"gifts.canUpgrade":`Can upgrade`,"gifts.cannotUpgrade":`Cannot upgrade`,"gifts.canCraft":`Can Craft`,"gifts.cannotCraft":`Cannot Craft`,"gifts.officialEmpty":`No official gifts match this category and search.`,"gifts.includeCollectible":`Import the complete collectible pool, including crafted models`,"gifts.animation":`Animation file`,"gifts.filePrompt":`Drop or choose a TGS / Lottie file`,"gifts.fileHint":`TGS, JSON or Lottie · validated before import`,"gifts.chooseFile":`Choose file`,"gifts.changeFile":`Change file`,"gifts.title":`Display title`,"gifts.titlePlaceholder":`e.g. Celebration Star`,"gifts.stars":`Price in Stars`,"gifts.convertStars":`Conversion Stars`,"gifts.sortOrder":`Sort order`,"gifts.lifecycle.label":`Gift lifecycle`,"gifts.lifecycle.regular":`Regular`,"gifts.lifecycle.auction":`Auction`,"gifts.lifecycle.drop":`Scheduled drop`,"gifts.lifecycle.hintRegular":`Purchasable as soon as the gift is enabled.`,"gifts.lifecycle.hintAuction":`The supply is sold in timed rounds and the highest bidders win. The price in Stars is the minimum bid.`,"gifts.lifecycle.hintDrop":`The gift stays locked behind a countdown and becomes purchasable automatically at the unlock time.`,"gifts.lifecycle.auctionFileOnly":`An auction needs its own animation, so it is authored on the upload tab. A snapshot gift can still be scheduled.`,"gifts.drop.schedule":`Schedule the release`,"gifts.auction.slug":`Auction slug`,"gifts.auction.slugPlaceholder":`summer-auction`,"gifts.auction.supply":`Total supply`,"gifts.auction.perRound":`Gifts per round`,"gifts.auction.roundDuration":`Round duration (seconds)`,"gifts.auction.startAt":`Start time (empty = start immediately)`,"gifts.auction.plan":`{rounds} rounds × {perRound} gifts, {minutes} min each`,"gifts.drop.unlockAt":`Unlock time`,"gifts.lifecycle.slugRequired":`An auction needs a slug.`,"gifts.lifecycle.supplyRequired":`Total supply must be greater than 0.`,"gifts.lifecycle.perRoundRequired":`Gifts per round must be greater than 0.`,"gifts.lifecycle.perRoundTooLarge":`Gifts per round cannot exceed the total supply.`,"gifts.lifecycle.roundDurationRequired":`Round duration must be greater than 0.`,"gifts.lifecycle.startPast":`The auction start time is in the past.`,"gifts.lifecycle.unlockRequired":`Pick an unlock time in the future.`,"auctions.title":`Auctions and scheduled drops`,"auctions.eyebrow":`Gift lifecycle operations`,"auctions.newAuction":`New auction`,"auctions.newDrop":`New scheduled drop`,"auctions.metricAuctions":`Auctions`,"auctions.metricLive":`Running now`,"auctions.metricDrops":`Scheduled drops`,"auctions.metricPendingDrops":`Awaiting unlock`,"auctions.metricCommitted":`Stars committed in bids`,"auctions.colGift":`Gift`,"auctions.colKind":`Kind`,"auctions.colRounds":`Round`,"auctions.colSupply":`Supply left`,"auctions.colMinBid":`Min bid`,"auctions.colBids":`Bids`,"auctions.colNext":`Next deadline`,"auctions.kindAuction":`Auction`,"auctions.kindDrop":`Scheduled drop`,"auctions.untitled":`Untitled gift`,"auctions.unlimited":`Unlimited`,"auctions.roundOf":`{current} of {total}`,"auctions.perRoundShort":`{minutes} min per round`,"auctions.awarded":`{count} awarded`,"auctions.bidCount":`{count} active`,"auctions.topBid":`top {amount}`,"auctions.minBid":`Minimum bid in Stars`,"auctions.enableHint":`Enable immediately (while a gift is disabled its auction or drop is invisible to clients)`,"auctions.lazyNote":`The engine creates an auction’s live state on the first client request or sweeper pass, so a brand-new auction reads “awaiting engine” until then.`,"auctions.now":`due now`,"auctions.durationDH":`{days}d {hours}h`,"auctions.durationHM":`{hours}h {minutes}m`,"auctions.durationMS":`{minutes}m {seconds}s`,"auctions.durationS":`{seconds}s`,"auctions.status.pending":`Pending`,"auctions.status.awaiting":`Awaiting engine`,"auctions.status.active":`Running`,"auctions.status.completed":`Completed`,"auctions.status.cancelled":`Cancelled`,"auctions.status.scheduled":`Locked`,"auctions.status.released":`Released`,"gifts.reason":`Audit reason`,"gifts.reasonPlaceholder":`Briefly describe why this gift is being imported`,"gifts.enableAfterImport":`Enable after import`,"gifts.validate":`Dry-run validation`,"gifts.confirmImport":`Confirm import`,"gifts.stepDetails":`File and details`,"gifts.stepValidate":`Dry-run validation`,"gifts.stepImport":`Confirm import`,"gifts.fileRequired":`Choose a TGS or Lottie file first`,"gifts.source":`Source`,"gifts.replace":`New revision`,"gifts.disable":`Disable`,"gifts.enable":`Enable`,"gifts.empty":`No Star Gifts have been imported.`,"gifts.emptyHint":`Import the first animation above to build the gift catalog.`,"gifts.validationReady":`Validation passed`,"gifts.validationHint":`Review the normalized metadata, then confirm the import.`,"gifts.confirmState":`Apply the validated state change to gift #{id}?`,"collectibles.manage":`Attribute pool`,"collectibles.title":`Collectible pool · Gift #{id}`,"collectibles.eyebrow":`Unique gift attributes`,"collectibles.activeRevision":`Published revision {revision}`,"collectibles.published":`Published`,"collectibles.noPool":`No collectible pool published`,"collectibles.noPoolHint":`Publish models, patterns and backdrops to enable upgrades.`,"collectibles.publishNew":`Publish a new immutable revision`,"collectibles.immutableHint":`Dry-run checks every file and the attribute-pool structure before the revision becomes active.`,"collectibles.upgradeStars":`Upgrade price in Stars`,"collectibles.supply":`Unique supply`,"collectibles.slug":`Public slug prefix`,"collectibles.models":`Models`,"collectibles.patterns":`Patterns`,"collectibles.backdrops":`Backdrops`,"collectibles.model":`Model`,"collectibles.pattern":`Pattern`,"collectibles.backdrop":`Backdrop`,"collectibles.rarity":`Rarity ‰`,"collectibles.rarityHint":`Keep at least two attributes. Permille values are relative regular-upgrade weights; add/remove redistributes them to 1000.`,"collectibles.minimumAttributes":`Models, patterns, and backdrops must each contain at least two attributes.`,"collectibles.duplicateBackdropID":`Backdrop IDs must be unique within the pool.`,"collectibles.colorHint":`Colors are stored as 24-bit RGB values.`,"collectibles.addAttribute":`Add`,"collectibles.remove":`Remove attribute`,"collectibles.fileRequired":`Every model and pattern needs a TGS or Lottie file.`,"collectibles.backdropID":`Backdrop ID`,"collectibles.color.center":`Center`,"collectibles.color.edge":`Edge`,"collectibles.color.pattern":`Pattern`,"collectibles.color.text":`Text`,"collectibles.validationReady":`Attribute pool is valid`,"collectibles.validationHint":`Review the normalized assets, then publish this immutable revision.`,"collectibles.publish":`Publish revision`,"moderation.casesEyebrow":`Moderation / Cases`,"moderation.currentQueue":`Current queue`,"moderation.criticalCases":`Critical cases`,"moderation.pendingOrFailed":`Pending / failed actions`,"moderation.statusFilter":`Case status filter`,"moderation.statusFilter.active":`Active queue`,"moderation.statusFilter.all":`All statuses`,"moderation.assignee":`Reviewer`,"moderation.allAssignees":`Leave blank for all`,"moderation.case":`Case`,"moderation.target":`Target`,"moderation.severity":`Severity`,"moderation.reportsAndReporters":`Reports / Reporters`,"moderation.latestReport":`Latest report`,"moderation.review":`Review`,"moderation.status.open":`Open`,"moderation.status.in_review":`In review`,"moderation.status.action_pending":`Action pending`,"moderation.status.action_failed":`Action failed`,"moderation.status.resolved":`Resolved`,"moderation.status.dismissed":`Dismissed`,"moderation.status.appeal_review":`Appeal review`,"moderation.severity.low":`Low`,"moderation.severity.medium":`Medium`,"moderation.severity.high":`High`,"moderation.severity.critical":`Critical`,"moderation.targetType.user":`Account`,"moderation.targetType.chat":`Group`,"moderation.targetType.channel":`Channel`,"moderation.caseDetailTitle":`Review case #{id}`,"moderation.caseDetailEyebrow":`Moderation / Case detail`,"moderation.backToQueue":`Back to queue`,"moderation.loadingCase":`Loading moderation case…`,"moderation.versionAndUpdated":`Version {version} · Updated {time}`,"moderation.reportCount":`Reports`,"moderation.reportCountValue":`{reports} reports from {reporters} reporters`,"moderation.firstAndLatestReport":`First / latest report`,"moderation.evidence":`Report evidence`,"moderation.evidenceHint":`Shows up to the latest 100 reports; snapshots are frozen when reports are admitted.`,"moderation.sourceAndReason":`Source / Reason`,"moderation.reporter":`Reporter`,"moderation.option":`Option`,"moderation.decisionAudit":`Decision and action audit`,"moderation.decisionAuditHint":`Actions run idempotently through a lease worker; failures retain their error and attempt count.`,"moderation.appeals":`Appeals`,"moderation.caseActions":`Case actions`,"moderation.renewClaim":`Renew claim`,"moderation.claimCase":`Claim case`,"moderation.reviewReason":`Review reason`,"moderation.decisionPreset":`Decision template`,"moderation.preset.noViolation":`No violation (dismiss report)`,"moderation.preset.scam":`Mark as SCAM`,"moderation.preset.fake":`Mark as FAKE`,"moderation.preset.freeze":`Freeze account`,"moderation.preset.scamFreeze":`SCAM + freeze`,"moderation.preset.fakeFreeze":`FAKE + freeze`,"moderation.preset.deleteMessages":`Delete messages covered by evidence`,"moderation.preset.deleteAccount":`Delete account`,"moderation.evidenceMessageIDs":`Evidence message IDs (comma-separated)`,"moderation.privateOwnerUserID":`Private-chat owner_user_id`,"moderation.revokeForBoth":`Revoke for both sides`,"moderation.evidenceValidationHint":`The server will verify again that every message ID exists in this case's immutable report evidence.`,"moderation.failedActionHint":`The action was partially executed and cannot be changed directly to no violation. Select a new action to retry while retaining the previous failure audit.`,"moderation.retryAction":`Retry action`,"moderation.submitDecision":`Submit decision`,"moderation.appealReviewTitle":`Appeal review #{id}`,"moderation.automaticRemedy":`Automatic remedy after approval`,"moderation.irreversibleAppealHint":`The case contains a completed irreversible deletion. It cannot be marked as approved and restored; deny it or escalate for manual handling.`,"moderation.denyAppeal":`Deny appeal`,"moderation.grantAppeal":`Grant appeal`,"moderation.reasonRequired":`A review reason is required.`,"moderation.appealReasonRequired":`An appeal review reason is required.`,"moderation.privateDeleteValidation":`Private-message deletion requires valid evidence message IDs and the reporter's owner_user_id.`,"moderation.channelDeleteValidation":`Channel-message deletion requires at least one valid evidence message ID.`,"moderation.confirmDecision":`Submit the “{decision}” decision? The action will run through the durable action queue.`,"moderation.confirmGrantAppeal":`Grant this appeal?`,"moderation.confirmDenyAppeal":`Deny this appeal?`,"moderation.remedy.clearFlags":`Clear SCAM / FAKE`,"moderation.remedy.unfreeze":`Unfreeze account`,"moderation.remedy.none":`No recovery action needed`,"moderation.source.account_peer":`Account / peer`,"moderation.source.profile_photo":`Profile photo`,"moderation.source.messages_spam":`Message spam`,"moderation.source.messages":`Messages`,"moderation.source.encrypted_spam":`Encrypted-chat spam`,"moderation.source.reaction":`Reaction`,"moderation.source.channel_spam":`Channel spam`,"moderation.source.story":`Story`,"moderation.source.ephemeral":`Ephemeral media`,"moderation.source.sponsored":`Sponsored message`,"moderation.source.antispam_false_positive":`Anti-spam false positive`,"moderation.reason.spam":`Spam`,"moderation.reason.violence":`Violence`,"moderation.reason.pornography":`Pornography`,"moderation.reason.child_abuse":`Child abuse`,"moderation.reason.other":`Other`,"moderation.reason.copyright":`Copyright`,"moderation.reason.geo_irrelevant":`Location-irrelevant`,"moderation.reason.fake":`Fake`,"moderation.reason.illegal_drugs":`Illegal drugs`,"moderation.reason.personal_details":`Personal details`,"messages.msgIDsInvalid":`Message IDs are invalid`,"auth.device":`Device`,"auth.platform":`Platform`,"auth.ip":`IP`,"auth.lastActive":`Last active`,"auth.revokeCurrent":`Revoke current`,"auth.keepCurrent":`Keep current`,"auth.revokeAll":`Revoke all devices`,"picker.userPlaceholder":`Search user_id / phone / username`,"picker.channelPlaceholder":`Search channel_id / username / title`,"picker.verified":`Verified`,"picker.regular":`Regular`,"action.reasonRequired":`Please enter an operation reason`,"action.flow":`Action Flow`,"action.close":`Close`,"action.stepReason":`Enter reason`,"action.stepDryRun":`Dry-run check`,"action.stepConfirm":`Confirm execution`,"action.reason":`Operation reason`,"action.reasonPlaceholder":`Describe why this operation is being performed`,"action.requestPreview":`Request preview`,"action.result":`Action result`,"action.commandID":`Command ID`,"action.status":`Status`,"action.dryRun":`Dry-run`,"action.runAgain":`Run dry-run again`,"action.runDry":`Run dry-run first`,"action.confirm":`Confirm execution`,"audit.id":`ID`,"audit.commandID":`Command ID`,"audit.action":`Action`,"audit.actor":`Actor`,"audit.status":`Status`,"audit.dryRun":`Dry-run`,"audit.reason":`Reason`,"audit.time":`Time`,"common.loadMore":`Load more`,"route.collectibleUsernames":`Collectible Usernames`,"route.collectibleUsernamesSubtitle":`Console / Collectible usernames`,"route.accountRatings":`Account Rating`,"route.accountRatingsSubtitle":`Console / Account rating`,"route.storage":`Storage`,"route.storageSubtitle":`Console / Storage`,"layout.storage":`Storage`,"storage.title":`Storage`,"storage.eyebrow":`Media / Read-only inventory`,"storage.physical":`Physical bytes`,"storage.logical":`Logical reference bytes`,"storage.dedupSaved":`Saved by dedup`,"storage.objects":`Unique objects`,"storage.references":`Blob references`,"storage.documents":`Documents`,"storage.photos":`Photos`,"storage.backend":`Backend`,"storage.retentionBlocked":`Physical deletion is disabled until the complete media-reference graph is implemented and reconciled.`,"layout.collectibleUsernames":`NFT Usernames`,"route.collectiblePhones":`Anonymous Numbers`,"route.collectiblePhonesSubtitle":`Console / NFT +888 numbers`,"layout.collectiblePhones":`NFT Numbers`,"phones.pageTitle":`Anonymous +888 numbers`,"phones.eyebrow":`Fragment numbers / Ownership registry`,"phones.number":`Number`,"phones.class":`Class`,"phones.standard":`Standard`,"phones.exclusive":`Exclusive`,"phones.allClasses":`All classes`,"phones.mintTitle":`Issue an anonymous number`,"phones.mintHint":`Standard numbers follow phone privacy; exclusive numbers stay visible in every profile view. The login number is not changed.`,"phones.ownerOptional":`Initial owner (optional)`,"phones.mint":`Issue number`,"phones.search":`Search +888 number`,"phones.manage":`Manage`,"phones.manageHint":`Transfer, return to vault or burn this asset.`,"phones.fiatAmount":`Fiat price (USD)`,"phones.tonAmount":`Price in TON`,"phones.pricePreview":`Telegram will show: {crypto} ({fiat}). Values are entered as regular amounts; the panel converts USD to cents and TON to nanotons.`,"phones.priceInvalid":`Enter a positive USD price (up to 2 decimals) and TON price (up to 9 decimals).`,"phones.updatePrice":`Save price`,"layout.accountRatings":`Account Rating`,"usernames.pageTitle":`Collectible usernames`,"usernames.eyebrow":`NFT usernames / Registry`,"usernames.metricLoaded":`Loaded rows`,"usernames.metricVault":`In vault`,"usernames.metricOwned":`Held by owners`,"usernames.metricBurned":`Burned`,"usernames.mintTitle":`Mint a collectible username`,"usernames.mintHint":`Creates the asset together with its purchase record. Keep the owner as vault to mint it unassigned.`,"usernames.mint":`Mint username`,"usernames.mintNote":`Username, currency and amount are required; the dry-run checks availability first.`,"usernames.ownerKind":`Owner type`,"usernames.ownerVault":`Vault (no owner)`,"usernames.ownerUser":`User owner`,"usernames.ownerChannel":`Channel owner`,"usernames.currency":`Currency`,"usernames.amount":`Amount ({currency})`,"usernames.cryptoCurrency":`Crypto currency`,"usernames.cryptoNone":`None`,"usernames.cryptoAmount":`Crypto amount ({currency})`,"usernames.amountHint":`Amounts are typed in whole {currency} and stored as the smallest units the API and fragment.collectibleInfo carry, so clients render the price you meant. Up to {decimals} decimal places. Clients will show: {preview}.`,"usernames.amountInvalid":`That is not a valid {currency} amount: digits only, with at most {decimals} decimal places.`,"usernames.inactive":`inactive`,"usernames.url":`Marketplace URL`,"usernames.purchaseDate":`Purchase date (UTC)`,"usernames.purchaseTime":`Purchase time (UTC)`,"usernames.searchPlaceholder":`Search by username`,"usernames.statusAll":`All statuses`,"usernames.statusVault":`Vault`,"usernames.statusOwned":`Owned`,"usernames.statusBurned":`Burned`,"usernames.price":`Price`,"usernames.transfers":`Transfers`,"usernames.registryActive":`Active in profile`,"usernames.registryHidden":`Hidden in profile`,"usernames.loadingDetail":`Loading collectible username…`,"usernames.detailTitle":`Collectible {username}`,"usernames.detailEyebrow":`NFT usernames / Asset`,"usernames.assetID":`Asset #{id}`,"usernames.transferCount":`{count} transfers`,"usernames.originalOwner":`Original owner`,"usernames.openOwnerAccount":`Open owner account`,"usernames.openOwnerChannel":`Open owner channel`,"usernames.openMarketplace":`Open marketplace page`,"usernames.transferTitle":`Transfer ownership`,"usernames.transferHint":`Pick the recipient; the transfer is appended to the provenance history.`,"usernames.recipientKind":`Recipient type`,"usernames.recipientUser":`To user`,"usernames.recipientChannel":`To channel`,"usernames.transferNote":`The current owner loses the username immediately after confirmation.`,"usernames.transfer":`Transfer`,"usernames.historyTitle":`Provenance history`,"usernames.historyHint":`Mint, transfer, revoke and burn events in chronological order.`,"usernames.eventKind":`Event`,"usernames.fromPeer":`From`,"usernames.toPeer":`To`,"usernames.actionDock":`Asset operations`,"usernames.revoke":`Revoke to vault`,"usernames.revokeHint":`Takes the username away from its owner and returns it to the vault; it can be issued again later.`,"usernames.burn":`Burn permanently`,"usernames.burnHint":`Irreversible: the username is destroyed and can never be issued again.`,"usernames.burnedHint":`This username is burned — no further operations are possible.`,"usernames.delete":`Delete record`,"usernames.deleteHint":`Erases the asset and its ownership history, and frees the username for a fresh issue. Use this for a username issued by mistake; a burn keeps the history instead.`,"usernames.kind.mint":`Mint`,"usernames.kind.transfer":`Transfer`,"usernames.kind.revoke":`Revoke`,"usernames.kind.burn":`Burn`,"rating.pageTitle":`Account rating leaderboard`,"rating.eyebrow":`Rating / Leaderboard`,"rating.metricLoaded":`Loaded rows`,"rating.metricTopLevel":`Top level`,"rating.metricAvgLevel":`Average level`,"rating.metricPending":`With pending points`,"rating.searchPlaceholder":`Search by username, name or user ID`,"rating.minLevel":`Min level`,"rating.userID":`User ID`,"rating.level":`Level`,"rating.stars":`Points`,"rating.progress":`Progress to next level`,"rating.pending":`Pending`,"rating.computedAt":`Computed`,"rating.levelValue":`Level {level}`,"rating.maxLevel":`Max level reached`,"rating.progressHint":`{remaining} left to reach {target}`,"rating.loadingDetail":`Loading account rating…`,"rating.detailTitle":`Rating of {user}`,"rating.detailEyebrow":`Rating / Component breakdown`,"rating.pendingBadge":`Pending {amount}`,"rating.nextLevel":`Next level threshold`,"rating.toNextLevel":`Points to next level`,"rating.breakdownTitle":`How the rating adds up`,"rating.breakdownHint":`Contribution of every source: stars, activity, moderation penalties and manual corrections.`,"rating.breakdownMismatch":`Components add up to {sum} while the stored rating is {total}. Recompute to resolve the drift.`,"rating.breakdownPending":`Components already include {amount} that reaches the score only on the date below.`,"rating.currentLevelStars":`Current level threshold`,"rating.nextLevelStars":`Next level threshold`,"rating.pendingTitle":`Pending points`,"rating.pendingHint":`Already earned, but counted towards the rating only on the date below.`,"rating.pendingDate":`Applied on`,"rating.eventsTitle":`Rating events`,"rating.eventsHint":`Every rating change with its source, actor and reason.`,"rating.eventKind":`Source`,"rating.amount":`Change`,"rating.actionDock":`Rating operations`,"rating.openAccount":`Open account`,"rating.recompute":`Recompute`,"rating.recomputeHint":`Rebuilds the rating from stars, activity, penalties and manual corrections.`,"rating.adjustTitle":`Manual correction`,"rating.adjustAmount":`Value (negative allowed)`,"rating.adjust":`Apply correction`,"rating.adjustHint":`The value is added to the manual component; a negative number lowers the rating.`,"rating.componentStars":`Stars`,"rating.componentStarsHint":`Purchased and received stars`,"rating.componentActivity":`Activity`,"rating.componentActivityHint":`Messages, sessions and long-term engagement`,"rating.componentPenalty":`Penalties`,"rating.componentPenaltyHint":`Moderation decisions and restrictions`,"rating.componentManual":`Manual corrections`,"rating.componentManualHint":`Adjustments made by admins`,"rating.componentTotal":`Total rating`,"rating.kind.stars":`Stars`,"rating.kind.activity":`Activity`,"rating.kind.moderation":`Moderation`,"rating.kind.manual":`Manual`,"rating.kind.recompute":`Recompute`,"route.verification":`Official Verification`,"route.verificationSubtitle":`Console / Verification`,"layout.verification":`Verification`,"permission.deniedTitle":`Not enough rights`,"permission.deniedEyebrow":`Console / Access`,"permission.deniedBody":`This session was not granted the {permission} permission, so the section stays closed.`,"permission.deniedHeading":`Section unavailable`,"permission.deniedHint":`Ask an operator to add the permission to TELESRV_ADMIN_UI_PERMISSIONS and sign in again.`,"verification.pageTitle":`Verification queue`,"verification.eyebrow":`Verification / Queue`,"verification.searchPlaceholder":`Application id, peer id, username or title`,"verification.statusAll":`All statuses`,"verification.targetType":`Target type`,"verification.targetTypeAll":`All types`,"verification.reviewer":`Reviewer`,"verification.reviewerPlaceholder":`Any reviewer`,"verification.target":`Target`,"verification.applicant":`Applicant`,"verification.category":`Category`,"verification.submittedAt":`Submitted`,"verification.alreadyVerified":`Badge already on`,"verification.status.draft":`Draft`,"verification.status.submitted":`Submitted`,"verification.status.in_review":`In review`,"verification.status.approved":`Approved`,"verification.status.rejected":`Rejected`,"verification.status.cancelled":`Cancelled`,"verification.type.bot":`Bot`,"verification.type.channel":`Channel`,"verification.type.supergroup":`Supergroup`,"verification.type.user":`User`,"verification.loadingDetail":`Loading the application…`,"verification.detailTitle":`Application #{id}`,"verification.detailEyebrow":`Verification / Review`,"verification.conflict":`Another admin has already changed this application. The data has been reloaded — check the status before deciding again.`,"verification.controlsOk":`Control confirmed`,"verification.controlsLost":`No control over the target`,"verification.controlsOkHint":`The applicant controls the target right now — checked against the live records, not against the submission snapshot.`,"verification.controlsLostHint":`The applicant no longer controls the target. Approving would hand the badge to someone who does not hold the peer — normally a reason to reject.`,"verification.targetSection":`Target`,"verification.targetHint":`The peer the badge would be attached to, as it exists right now.`,"verification.openTarget":`Open target`,"verification.targetTitle":`Title`,"verification.targetID":`Peer ID`,"verification.applicantSection":`Applicant`,"verification.applicantHint":`Who filed the application and whether they still hold rights on the target.`,"verification.openApplicant":`Open account`,"verification.applicantID":`User ID`,"verification.applicationSection":`Application`,"verification.applicationHint":`Everything the applicant submitted, rendered as plain text.`,"verification.correlationID":`Correlation ID`,"verification.createdAt":`Created`,"verification.description":`Description`,"verification.officialWebsite":`Official website`,"verification.socialLinks":`Social links`,"verification.pressLinks":`Press coverage`,"verification.additionalNote":`Applicant comment`,"verification.notProvided":`Not provided`,"verification.linkSafetyHint":`Only http:// and https:// links are clickable and open in a new tab; anything else is shown as text.`,"verification.decisionSection":`Decision`,"verification.decisionHint":`What was decided, by whom, and with which wording.`,"verification.reviewedAt":`Decided`,"verification.version":`Version (optimistic lock)`,"verification.decisionReason":`Decision reason`,"verification.noDecision":`No decision yet`,"verification.internalNote":`Internal note`,"verification.adminOnly":`admins only`,"verification.eventsSection":`History`,"verification.eventsHint":`Immutable trail of every status transition, with actor and reason.`,"verification.eventKind":`Event`,"verification.transition":`From → to`,"verification.eventNote":`Internal note`,"verification.kind.created":`Created`,"verification.kind.updated":`Updated`,"verification.kind.submitted":`Submitted`,"verification.kind.claimed":`Claimed`,"verification.kind.approved":`Approved`,"verification.kind.rejected":`Rejected`,"verification.kind.cancelled":`Cancelled`,"verification.kind.revoked":`Badge revoked`,"verification.kind.notified":`Applicant notified`,"verification.actionDock":`Review actions`,"verification.noActions":`This status has no available actions.`,"verification.claim":`Take into review`,"verification.claimHint":`Assigns the application to you and moves it to in review, so two reviewers never work on the same one.`,"verification.internalNotePlaceholder":`Handover note for other reviewers`,"verification.internalNoteHint":`Optional. Stored with the decision and visible to admins only — never sent to the applicant.`,"verification.alreadyVerifiedHint":`The target already carries the badge; approving only records the decision.`,"verification.approve":`Approve`,"verification.approveHint":`Grants the official badge to the target and closes the application.`,"verification.reject":`Reject`,"verification.rejectHint":`The reason is mandatory: it is the wording the applicant is told, so write what exactly was missing.`,"verification.dangerZone":`Danger zone`,"verification.revoke":`Revoke verification`,"verification.revokeHint":`Clears the badge from the target. The approved application stays in history.`,"verification.revokeNotVerified":`The target carries no badge right now — there is nothing to revoke.`,"route.botVerification":`Third-party verification`,"route.botVerificationSubtitle":`Console / Third-party verification`,"layout.botVerification":`Third-party marks`,"picker.system":`System`,"picker.botPlaceholder":`Bot username or id`,"botverification.pageTitle":`Third-party verification`,"botverification.eyebrow":`Third-party verification / Verifiers, icons, marks`,"botverification.explainTitle":`A verifier company's icon — not the official checkmark`,"botverification.explainText":`A third-party mark is a verifier bot's own icon, drawn right BEFORE the name of an account, a bot or a channel, plus one line of description in the profile. It says “this verifier vouches for this peer”, and nothing more.`,"botverification.explainIcon":`The icon is a custom emoji document. The client fetches it through messages.getCustomEmojiDocuments, so a document id that resolves to nothing renders as no badge at all — which is why marks are granted from the catalogue below rather than from a typed number.`,"botverification.explainOfficial":`The official checkmark is a different mechanism, granted by the platform in the Verification section. The two are stored, shown and taken away separately, and neither one implies the other.`,"botverification.openOfficial":`Official verification`,"botverification.manageMissing":`This session can read the section and decide applications, but not change verifiers or the icon catalogue — that needs the botverification.manage permission.`,"botverification.tabRequests":`Applications`,"botverification.tabVerifiers":`Verifiers`,"botverification.tabIcons":`Icon catalogue`,"botverification.tabMarks":`Granted marks`,"botverification.queueTitle":`Application queue`,"botverification.queueHint":`Applications filed with a verifier bot by the owner of the peer. The counters cover the whole queue, not the page below.`,"botverification.searchPlaceholder":`Application id, peer id, username or title`,"botverification.statusAll":`All statuses`,"botverification.status.pending":`Pending`,"botverification.status.approved":`Approved`,"botverification.status.rejected":`Rejected`,"botverification.status.revoked":`Mark revoked`,"botverification.peer.user":`Account`,"botverification.peer.channel":`Channel`,"botverification.peerType":`Peer type`,"botverification.peerTypeAll":`All types`,"botverification.verifier":`Verifier`,"botverification.verifierAll":`All verifiers`,"botverification.verifierID":`Verifier bot ID`,"botverification.applicant":`Applicant`,"botverification.applicantID":`User ID`,"botverification.target":`Peer`,"botverification.targetTitle":`Title`,"botverification.targetID":`Peer ID`,"botverification.reason":`Stated reason`,"botverification.requestedDescription":`Requested description`,"botverification.description":`Description`,"botverification.createdAt":`Filed`,"botverification.company":`Company`,"botverification.companyPlaceholder":`Acme Verification Ltd`,"botverification.bot":`Bot`,"botverification.icon":`Icon`,"botverification.iconDocument":`Document ID`,"botverification.iconName":`Name`,"botverification.markCount":`Marks`,"botverification.grantedBy":`Granted by`,"botverification.notProvided":`Not set`,"botverification.verifiersTitle":`Verifier bots`,"botverification.verifiersHint":`Bots allowed to hand out their own mark. Verifier status is granted per deployment, so every row here is a badge printer an operator switched on by hand.`,"botverification.grantTitle":`Grant verifier status`,"botverification.updateTitle":`Update verifier`,"botverification.grantHint":`The bot gets an icon from the catalogue and a company name to vouch under. The same call updates an existing verifier, which is why it carries a version.`,"botverification.grantBot":`Bot`,"botverification.grantIcon":`Icon from the catalogue`,"botverification.grantIconPick":`Pick an icon`,"botverification.defaultDescription":`Default description`,"botverification.defaultDescriptionPlaceholder":`Verified by Acme`,"botverification.canModify":`The verifier may replace the description per peer`,"botverification.canModifyShort":`Own description`,"botverification.canModifyHint":`This is botVerifierSettings.can_modify_custom_description: with it off, every mark this verifier grants carries the default description above, whatever the applicant asked for.`,"botverification.noActiveIcons":`The catalogue has no active icon, so there is nothing to grant. Add one in the icon catalogue first.`,"botverification.grantNote":`The bot can mark peers as soon as the row exists and is enabled.`,"botverification.grant":`Grant verifier status`,"botverification.update":`Update verifier`,"botverification.editing":`Updating {bot} — version {version} is sent as the optimistic lock, so a row somebody else changed meanwhile is refused instead of overwritten.`,"botverification.cancelEdit":`Cancel update`,"botverification.edit":`Edit`,"botverification.enable":`Enable`,"botverification.disable":`Disable`,"botverification.enabled":`Enabled`,"botverification.disabled":`disabled`,"botverification.disableHint":`Disabling is the per-verifier kill switch: the marks already granted keep rendering, but the bot can no longer mark anything new and its settings stop being projected into botInfo.`,"botverification.revokeVerifier":`Revoke status`,"botverification.revokeVerifierHint":`Revoking verifier status removes the row and every mark this verifier granted — the icon disappears from all of its peers at once.`,"botverification.iconsTitle":`Icon catalogue`,"botverification.iconsHint":`The custom emoji documents a verifier may mark with. Nothing else can be used as an icon, so the catalogue is where a wrong badge is prevented rather than fixed.`,"botverification.addIconTitle":`Add or rename an icon`,"botverification.addIconHint":`The document id has to name a real custom emoji document on this deployment; the Emoji section lists them with their ids. Adding an id that already exists renames it instead of duplicating it.`,"botverification.iconNamePlaceholder":`Acme blue tick`,"botverification.iconOwner":`Owner`,"botverification.iconOwnerShared":`Shared`,"botverification.iconOwnerHint":`A shared icon may be granted to any verifier; picking an owner reserves it for that one bot.`,"botverification.iconDocumentHint":`A document id that resolves to nothing produces an invisible badge: the peer is marked in the database and the client draws nothing.`,"botverification.addIconNote":`Adding an icon grants nothing by itself — it only makes the document available to grant.`,"botverification.addIcon":`Save icon`,"botverification.iconActive":`Active`,"botverification.iconInactive":`Retired`,"botverification.usedBy":`Verifiers using it`,"botverification.activateIcon":`Activate`,"botverification.deactivateIcon":`Retire`,"botverification.deactivateIconHint":`Retiring an icon stops it from being granted to anybody new. Marks already carrying it keep it: the icon is copied onto the mark when it is granted.`,"botverification.marksTitle":`Granted marks`,"botverification.marksHint":`Every peer currently carrying a third-party mark, whoever granted it — an operator decision, the verifier bot itself, or the peer's owner through bots.setCustomVerification.`,"botverification.markSearchPlaceholder":`Peer id, username or title`,"botverification.revokeMark":`Remove mark`,"botverification.revokeMarkHint":`Removing a mark clears the icon and the description from the peer. The application it came from keeps its history.`,"botverification.loadingDetail":`Loading the application…`,"botverification.detailTitle":`Application #{id}`,"botverification.detailEyebrow":`Third-party verification / Review`,"botverification.conflict":`Another admin has already changed this application. The data has been reloaded — check the status before deciding again.`,"botverification.markActive":`Mark is live`,"botverification.markInactive":`No mark on the peer`,"botverification.markActiveHint":`This peer already carries this verifier's mark; approving refreshes the description and records the decision.`,"botverification.verifierSection":`Verifier`,"botverification.verifierHint":`The company whose icon the peer would carry, as its row stands right now.`,"botverification.openVerifier":`Open verifier bot`,"botverification.verifierMissing":`The verifier row is gone: its status was revoked after this application was filed. There is no icon to grant, so the application can only be rejected.`,"botverification.verifierDisabledHint":`This verifier is disabled. It cannot mark anything new until an operator enables it again.`,"botverification.targetSection":`Peer`,"botverification.targetHint":`The account, bot or channel the icon would be attached to.`,"botverification.openTarget":`Open peer`,"botverification.applicantSection":`Applicant`,"botverification.applicantHint":`Who filed the application with the verifier bot.`,"botverification.openApplicant":`Open account`,"botverification.requestSection":`Application`,"botverification.requestHint":`What the applicant wrote, rendered as plain text.`,"botverification.correlationID":`Correlation ID`,"botverification.markPreview":`Description the mark would carry`,"botverification.markPreviewHint":`Resolved the same way the backend resolves it: the applicant's wording only when this verifier may set its own description, otherwise the verifier's default.`,"botverification.descriptionIgnoredHint":`This verifier may not set a per-peer description, so the requested wording is ignored and the default is applied.`,"botverification.decisionSection":`Decision`,"botverification.decisionHint":`What was decided, by whom, and with which wording.`,"botverification.decidedBy":`Decided by`,"botverification.approvedAt":`Approved`,"botverification.rejectedAt":`Rejected`,"botverification.version":`Version (optimistic lock)`,"botverification.decisionReason":`Decision reason`,"botverification.noDecision":`No decision yet`,"botverification.internalNote":`Internal note`,"botverification.adminOnly":`admins only`,"botverification.internalNotePlaceholder":`Handover note for other admins`,"botverification.internalNoteHint":`Optional. Stored with the decision and visible to admins only — never sent to the applicant.`,"botverification.actionDock":`Decision`,"botverification.noActions":`This status has no available actions.`,"botverification.approve":`Approve`,"botverification.approveHint":`Puts the verifier's icon before the peer's name and its description in the profile, and messages the applicant.`,"botverification.reject":`Reject`,"botverification.rejectHint":`The reason is mandatory: it is the wording the applicant is told, so write what exactly was missing.`,"botverification.dangerZone":`Danger zone`,"botverification.revokeRequest":`Revoke mark`,"botverification.revokeRequestHint":`Takes the icon and the description off the peer and closes the application as revoked. The official checkmark, if the peer has one, is untouched.`,"botverification.revokeNoMark":`The peer carries no mark right now — revoking only closes the application.`,"botverification.rosterDenied":`The server refused the verifier roster and the icon catalogue for this session (403), so both lists are empty here — applications can still be reviewed.`,"common.account":`Account`,"common.all":`All`,"common.archive":`Archive`,"common.archived":`Archived`,"common.bot":`Bot`,"common.changeFile":`Change file`,"common.choose":`Choose`,"common.chooseFile":`Choose file`,"common.copied":`Copied`,"common.copy":`Copy`,"common.delete":`Delete`,"common.disable":`Disable`,"common.document":`Document`,"common.documents":`Documents`,"common.enable":`Enable`,"common.logo":`Logo`,"common.next":`Next`,"common.official":`Official`,"common.order":`Order`,"common.pageOf":`Page {current} of {total}`,"common.perPage":`Per page`,"common.previous":`Previous`,"common.preview":`Preview`,"common.remove":`Remove`,"common.save":`Save`,"common.shortName":`Short name`,"common.showing":`Showing {visible} of {total}`,"common.showingRange":`Showing {start}-{end} of {total}`,"common.sortOrder":`Sort order`,"common.source":`Source`,"common.title":`Title`,"common.unarchive":`Unarchive`,"common.upload":`Upload`,"common.view":`View`,"language.label":`Language`,"dashboard.loadError":`Failed to load dashboard`,"dashboard.needsAttention":`Needs attention`,"dashboard.pendingReports":`Pending reports`,"dashboard.verificationRequests":`Verification requests`,"dashboard.peopleChats":`People & chats`,"dashboard.users":`Users`,"dashboard.onlineNow":`Online now`,"dashboard.lastFiveMinutes":`last 5 min`,"dashboard.bots":`Bots`,"dashboard.channels":`Channels`,"dashboard.supergroups":`Supergroups`,"dashboard.content":`Content`,"dashboard.stickerPacks":`Sticker packs`,"dashboard.emojiPacks":`Emoji packs`,"dashboard.gifs":`GIFs`,"dashboard.savedByUsers":`saved by users`,"dashboard.mediaStorageUsed":`Media storage used`,"dashboard.serverHealth":`Server health`,"dashboard.waitingForSample":`waiting for first sample…`,"dashboard.cpuLoad":`CPU load`,"dashboard.ramUsed":`RAM used`,"dashboard.diskFree":`Disk free`,"dashboard.ofTotal":`of {total}`,"dashboard.singleBackend":`{backend} backend`,"dashboard.multipleBackends":`{count} backends`,"dashboard.noMediaObjects":`no media objects`,"attr.firstName":`First name`,"attr.lastName":`Last name`,"attr.setProfileName":`Set profile name`,"attr.phoneNumber":`Phone number`,"attr.setPhone":`Set phone`,"attr.loginEmail":`Login email`,"attr.loginEmailPlaceholder":`Empty clears the email`,"attr.setLoginEmail":`Set login email`,"avatar.change":`Change avatar`,"avatar.new":`New avatar`,"avatar.chooseImage":`Choose a JPEG, PNG, or WebP image`,"avatar.chooseFirst":`Choose an image file first.`,"avatar.reasonPlaceholder":`Briefly describe why this avatar is being changed`,"avatar.upload":`Upload avatar`,"bots.copyToken":`Copy bot token`,"bots.copyTokenHint":`The token is written directly to the clipboard and is never rendered on screen.`,"bots.noTokenReturned":`No token returned.`,"bots.tokenCopied":`Token copied to clipboard.`,"bots.copyAgain":`Copy again`,"gifCatalog.title":`GIF catalog`,"gifCatalog.eyebrow":`Bounded media catalog served by the built-in @gif inline bot`,"gifCatalog.add":`Add GIF`,"gifCatalog.entries":`Catalog entries`,"gifCatalog.adminUpload":`Admin upload`,"gifCatalog.newMaterial":`New @gif material`,"gifCatalog.chooseMedia":`Choose GIF or MP4`,"gifCatalog.required":`Title, audit reason and a GIF/MP4 file are required.`,"stickers.emojiEyebrow":`Custom emoji packs — system packs are hidden from hand editing`,"stickers.stickerEyebrow":`Sticker packs — system packs are hidden from hand editing`,"stickers.createEmojiPack":`Create emoji pack`,"stickers.createStickerPack":`Create sticker pack`,"stickers.totalSets":`Total sets`,"stickers.searchPlaceholder":`Search set ID, short name or title`,"stickers.newSet":`New set`,"stickers.firstEmoji":`First emoji`,"stickers.firstSticker":`First sticker`,"stickers.chooseMedia":`Choose a TGS, Lottie JSON, or WebP file`,"stickers.createReasonPlaceholder":`Briefly describe why this pack is being created`,"stickers.emojiRequired":`Title, short name, emoji and a first emoji file are required.`,"stickers.stickerRequired":`Title, short name, emoji and a first sticker file are required.`,"stickers.setContents":`Set contents`,"stickers.emptySet":`This set has no documents.`,"stickers.chooseEmojiFirst":`Choose an emoji file first.`,"stickers.chooseStickerFirst":`Choose a sticker file first.`,"stickers.emojiValueRequired":`An emoji is required.`,"stickers.addEmoji":`Add emoji`,"stickers.packFull":`This pack already has {count} items. The current limit is {max}; create another pack to add more.`,"stickers.addSticker":`Add sticker`,"action.oneTimeSecret":`One-time secret — copy it now; it will not be shown again.`},zh:{"app.adminConsole":`管理控制台`,"app.localAccess":`本地访问`,"app.title":`telesrv 管理后台`,"common.actions":`操作`,"common.admins":`管理员`,"common.backToList":`返回列表`,"common.channel":`频道`,"common.channelOrGroup":`频道/群`,"common.clear":`清除`,"common.close":`关闭`,"common.count":`数量`,"common.deleted":`已删除`,"common.detail":`详情`,"common.device":`设备`,"common.disabled":`已禁用`,"common.enabled":`已启用`,"common.fromPeer":`来源对象`,"common.group":`群组`,"common.id":`ID`,"common.limit":`条数`,"common.loading":`加载中`,"common.member":`成员`,"common.members":`成员`,"common.messageId":`消息 ID`,"common.name":`姓名`,"common.no":`否`,"common.noResults":`无结果`,"common.none":`无`,"common.normal":`正常`,"common.operations":`操作`,"common.owner":`所属`,"common.platform":`平台`,"common.refresh":`刷新`,"common.search":`查询`,"common.sender":`发送方`,"common.status":`状态`,"common.survived":`存活`,"common.time":`时间`,"common.type":`类型`,"common.updatedAt":`更新时间`,"common.username":`用户名`,"common.valid":`有效`,"common.verified":`已认证`,"common.views":`浏览`,"common.yes":`是`,"route.accounts":`账号管理`,"route.accountsSubtitle":`控制台 / 账号`,"route.channels":`超级群与频道`,"route.channelsSubtitle":`控制台 / 频道`,"route.moderation":`举报与审核`,"route.moderationSubtitle":`控制台 / 内容安全`,"route.dashboard":`运维控制台`,"route.dashboardSubtitle":`控制台 / 总览`,"route.messages":`消息审计`,"route.messagesSubtitle":`控制台 / 消息`,"route.gifts":`星星礼物`,"route.giftsSubtitle":`控制台 / 星星礼物`,"route.auctions":`拍卖与定时发售`,"route.auctionsSubtitle":`控制台 / 拍卖与定时发售`,"route.premium":`Stars 与 Premium`,"route.premiumSubtitle":`控制台 / 统一商业化`,"route.giveGifts":`赠送礼物`,"route.giveGiftsSubtitle":`控制台 / 赠送礼物`,"layout.navigation":`导航`,"layout.primaryNav":`主导航`,"layout.dashboard":`总览`,"layout.accounts":`账号`,"layout.channels":`超级群/频道`,"layout.moderation":`举报/审核`,"layout.messages":`消息`,"layout.gifts":`礼物目录`,"layout.auctions":`拍卖与定时发售`,"layout.premium":`Stars 与 Premium`,"layout.giveGifts":`赠送礼物`,"layout.privateMessages":`私聊`,"layout.groupMessages":`群聊`,"layout.runtime":`运行状态`,"layout.adminBackend":`管理后台`,"layout.ready":`就绪`,"layout.pgRead":`PG 读取`,"layout.readOnly":`只读`,"layout.writeOps":`写操作`,"layout.dryRun":`预演`,"layout.actor":`操作者:{actor}`,"layout.logout":`退出`,"language.en":`EN`,"language.zh":`中文`,"language.ru":`RU`,"theme.switchToDark":`切换到深色主题`,"theme.switchToLight":`切换到浅色主题`,"login.heading":`运维后台`,"login.body":`输入凭据后进入控制台。`,"login.secret":`管理员密码或 token`,"login.submit":`登录`,"login.submitting":`登录中`,"dashboard.eyebrow":`运行总览`,"dashboard.title":`控制台总览`,"dashboard.readPath":`读路径`,"dashboard.readPathValue":`PG 只读`,"dashboard.writePath":`写路径`,"dashboard.executionPolicy":`执行策略`,"dashboard.dryRunFirst":`先预演`,"dashboard.accountsText":`账号状态、会员、认证、会话。`,"dashboard.channelsText":`公开实体、成员计数、认证状态。`,"dashboard.messagesText":`消息盒、update、outbox 状态。`,"dashboard.strip.dryRun":`所有危险操作先预演`,"dashboard.strip.token":`浏览器不持有内部 token`,"dashboard.strip.pagination":`列表使用游标分页`,"dashboard.strip.snapshot":`详情页保留原始状态快照`,"account.pageTitle":`账号`,"account.queryResults":`查询结果`,"account.recentActive":`最近活跃账号`,"account.currentPage":`当前页账号`,"account.onlineDevices":`在线设备记录`,"account.premium":`会员`,"account.frozen":`冻结`,"account.searchPlaceholder":`用户 ID / 手机号 / 用户名`,"account.userID":`用户 ID`,"account.phone":`手机号`,"account.lastActive":`最近活跃`,"account.notVerified":`未认证`,"account.notPremium":`非会员`,"account.premiumUntil":`会员到期`,"account.starsBalance":`Stars 余额`,"account.startingGrantApplied":`初始赠送已发放`,"account.startingGrantPending":`初始赠送未触发`,"account.activeSessions":`授权设备`,"account.accountFlags":`账号标记`,"account.restriction":`限制状态`,"account.restricted":`已限制`,"account.createdAt":`创建时间`,"account.detailTitle":`账号 #{id}`,"account.profile":`账号档案`,"account.loadingDetail":`加载账号详情`,"account.waitingData":`等待数据`,"account.noUsername":`无用户名`,"account.noPhone":`无手机号`,"account.accountFrozen":`账号已冻结`,"account.accountActive":`账号正常`,"account.authorizationsTitle":`授权设备`,"account.authorizationsCount":`共 {count} 个授权`,"account.recentAdminOps":`最近后台操作`,"account.recent30Audit":`最近 30 条审计`,"account.actionDock":`账号操作`,"account.freezeAccount":`冻结账号`,"account.updateFreeze":`更新冻结信息`,"account.unfreezeAccount":`解冻账号`,"account.freezeSince":`冻结开始时间`,"account.freezeUntil":`申诉截止时间`,"account.freezeUntilAria":`账号冻结申诉截止时间`,"account.freezeAppealURL":`申诉链接`,"account.freezeAppealURLAria":`账号冻结申诉链接`,"account.premiumMonths":`会员时长(月)`,"account.premiumMonthsAria":`设置会员时长,单位月`,"account.setPremium":`设置会员`,"account.clearPremium":`取消会员`,"premium.title":`Stars 与 Premium`,"premium.eyebrow":`统一商业化`,"premium.starsChannel":`Stars 购买`,"premium.starsChannelHint":`原生自助充值与赠送他人的 Stars 共用同一账本结算。`,"premium.premiumChannel":`Premium 购买`,"premium.premiumChannelHint":`原生菜单与 @premiumbot 共用套餐、价格、支付表单和权益。`,"premium.sharedLedger":`统一账本`,"premium.sharedCatalog":`统一目录`,"premium.operations":`手动账户操作`,"premium.operationsHint":`在同一页面发放 Stars 或 Premium;所有写操作均需确认并记录审计信息。`,"premium.userID":`用户 ID`,"premium.userIDPlaceholder":`例如 1000000001`,"premium.starsAmount":`Stars 数量`,"premium.grantStars":`发放 Stars`,"premium.grantPremium":`发放 Premium`,"premium.catalog":`Premium 目录`,"premium.catalogHint":`管理 Stars 价格、有效期、可见性、名称和排序。已创建的支付表单保留原始快照;价格变化后旧表单会被拒绝。`,"premium.catalogSummary":`已启用 {enabled} · 共 {total}`,"premium.addPlan":`添加套餐`,"premium.newPlan":`新 Premium 套餐`,"premium.newPlanHint":`月数是稳定的目录键,创建后不能修改。`,"premium.defaultPlanLabel":`1 个月`,"premium.planTerm":`{months} 个月 · {days} 天`,"premium.planDetails":`套餐参数`,"premium.pricing":`定价`,"premium.fiatPrice":`法币价格`,"premium.fiatCurrency":`法币(ISO 4217)`,"premium.fiatAmount":`法币金额(最小单位)`,"premium.storeSettings":`应用商店购买`,"premium.storeProduct":`商店商品`,"premium.storeProductPlaceholder":`例如 telegram_premium_3m`,"premium.storeQuantity":`商品数量`,"premium.storeSettingsHint":`不使用原生商店商品时,请将商品留空并把数量设为 0。`,"premium.editPlan":`编辑`,"premium.months":`月数`,"premium.durationDays":`有效天数`,"premium.amountStars":`价格`,"premium.label":`名称`,"premium.sortOrder":`排序`,"premium.source":`管理来源`,"premium.sourceAdmin":`Web 管理员`,"premium.sourceConfig":`环境配置`,"premium.version":`版本`,"premium.save":`保存`,"premium.create":`创建套餐`,"account.starsAmount":`赠送 Stars 数量`,"account.starsAmountAria":`设置要赠送的 Stars 数量`,"account.grantStars":`赠送 Stars`,"account.setVerified":`设置认证`,"account.clearVerified":`取消认证`,"channel.pageTitle":`超级群与频道`,"channel.recentUpdated":`最近更新`,"channel.currentPage":`当前页实体`,"channel.megagroups":`超级群`,"channel.broadcasts":`频道`,"channel.verifiedCount":`已认证`,"channel.searchPlaceholder":`频道 ID / 用户名 / 标题`,"channel.channelID":`频道 ID`,"channel.kind":`类型`,"channel.title":`标题`,"channel.pts":`PTS`,"channel.detailProfile":`频道档案`,"channel.loadingDetail":`加载频道详情`,"channel.creator":`创建者 {id}`,"channel.governance":`治理状态`,"channel.governanceValue":`封禁 {banned} / 踢出 {kicked}`,"channel.flags":`频道标记`,"channel.rawRow":`频道原始行`,"channel.rawRowText":`数据库只读快照`,"channel.actionDock":`频道操作`,"channel.setVerified":`设置认证`,"channel.clearVerified":`取消认证`,"channel.kind.broadcast":`频道`,"channel.kind.forum":`超级群/论坛`,"channel.kind.megagroup":`超级群`,"channel.kind.generic":`频道/群`,"route.bots":`机器人`,"route.botsSubtitle":`控制台 / 机器人`,"layout.bots":`机器人`,"route.broadcasts":`系统广播`,"route.broadcastsSubtitle":`控制台 / 系统广播`,"layout.broadcasts":`系统广播`,"broadcast.pageTitle":`系统广播`,"broadcast.pageHint":`由官方账号 777000 发送的持久消息`,"broadcast.createTitle":`创建系统广播`,"broadcast.createHint":`预演只校验目标;确认后由后台有界投递,本次请求不会同步发送全部消息。`,"broadcast.create":`创建广播`,"broadcast.message":`消息正文`,"broadcast.messagePlaceholder":`显示在 777000 会话中的消息`,"broadcast.messageBytes":`{count} / 4096 字节`,"broadcast.target":`收件人`,"broadcast.targetAll":`全部账号`,"broadcast.targetSelected":`指定账号`,"broadcast.userIDs":`用户 ID`,"broadcast.userIDsHint":`用逗号或空白分隔;1-200 个不重复的有效账号 ID。`,"broadcast.currentPage":`当前页广播`,"broadcast.active":`进行中`,"broadcast.failed":`失败`,"broadcast.id":`广播 ID`,"broadcast.progress":`已投递 / 目标`,"broadcast.status":`状态`,"broadcast.createdBy":`创建者`,"broadcast.enumerating":`枚举收件人`,"broadcast.delivering":`投递中`,"broadcast.completed":`已完成`,"bots.pageTitle":`机器人`,"bots.queryResults":`查询结果`,"bots.recent":`最近创建的机器人`,"bots.currentPage":`当前页机器人`,"bots.banned":`已封禁`,"bots.active":`正常`,"bots.createTitle":`创建系统机器人`,"bots.createHint":`为指定用户创建机器人账号。确认后 token 只显示一次。`,"bots.ownerUserID":`所属用户 ID`,"bots.name":`显示名称`,"bots.namePlaceholder":`例如:服务机器人`,"bots.username":`用户名`,"bots.usernameHint":`用户名需 5-32 个字符,且以 bot 结尾。`,"bots.tokenResultHint":`确认后请保持结果窗口打开并复制一次性 token,再关闭任一窗口。`,"bots.create":`创建机器人`,"bots.searchPlaceholder":`机器人 ID / 用户名`,"bots.botID":`机器人 ID`,"bots.owner":`所属用户`,"bots.status":`状态`,"bots.detailTitle":`机器人 #{id}`,"bots.profile":`机器人档案`,"bots.loadingDetail":`加载机器人详情`,"bots.unnamed":`未命名机器人`,"bots.restriction":`限制状态`,"bots.actionDock":`机器人操作`,"bots.banUntil":`封禁至`,"bots.ban":`封禁机器人`,"bots.updateBan":`更新封禁`,"bots.unban":`解封机器人`,"bots.type":`类型`,"bots.system":`系统`,"bots.user":`用户`,"bots.delete":`删除机器人`,"bots.deleteHint":`永久删除该用户创建的机器人并使其 token 失效。此操作不可撤销。`,"bots.systemHint":`系统内置机器人不可删除。`,"flags.scam":`SCAM`,"flags.fake":`FAKE`,"flags.setScam":`标记为 SCAM`,"flags.clearScam":`移除 SCAM`,"flags.setFake":`标记为 FAKE`,"flags.clearFake":`移除 FAKE`,"attr.attributes":`属性`,"attr.settings":`设置`,"attr.username":`用户名`,"attr.setUsername":`设置用户名`,"attr.setSupport":`标记为客服`,"attr.clearSupport":`取消客服`,"attr.forProfile":`资料颜色`,"attr.hasColor":`启用颜色`,"attr.colorIndex":`颜色编号`,"attr.bgEmojiID":`背景 emoji ID`,"attr.setColor":`设置颜色`,"attr.emojiDocID":`Emoji 文档 ID`,"attr.emojiUntil":`有效期 (unix, 0 = 永久)`,"attr.setEmojiStatus":`设置 emoji 状态`,"attr.gigagroup":`广播群 (gigagroup)`,"attr.antispam":`激进反垃圾`,"attr.participantsHidden":`隐藏成员`,"attr.noforwards":`禁止转发`,"attr.joinToSend":`先加入才能发言`,"attr.joinRequest":`加入需审批`,"attr.slowmode":`慢速模式 (秒)`,"attr.applySettings":`应用设置`,"route.stickers":`贴纸`,"route.stickersSubtitle":`控制台 / 贴纸`,"layout.stickers":`贴纸`,"route.emoji":`Emoji`,"route.emojiSubtitle":`控制台 / Emoji`,"layout.emoji":`Emoji`,"route.gifCatalog":`GIF 素材库`,"route.gifCatalogSubtitle":`控制台 / GIF 素材库`,"layout.gifCatalog":`GIF 素材`,"emoji.pageTitle":`自定义 Emoji`,"emoji.queryResults":`查询结果`,"emoji.recent":`自定义 Emoji 目录`,"emoji.currentPage":`当前页 Emoji`,"emoji.searchPlaceholder":`文档 ID 或表情`,"emoji.copyID":`复制文档 ID`,"emoji.noSet":`无所属集合`,"emoji.hint":`这里的文档 ID 可直接填入账号、机器人和频道资料的 Emoji 状态字段。`,"emoji.packCatalog":`Emoji 包`,"emoji.documentCatalog":`全部文档`,"messages.privateTitle":`私聊消息`,"messages.privateEyebrow":`私聊消息盒`,"messages.groupTitle":`群聊消息`,"messages.groupEyebrow":`超级群 / 频道消息`,"messages.selectPrivatePeers":`请先搜索并选择所属用户和对端用户`,"messages.selectChannel":`请先搜索并选择超级群或频道`,"messages.ownerUser":`所属用户`,"messages.peerUser":`对端用户`,"messages.beforeDatePlaceholder":`before_date 游标`,"messages.beforeIDPlaceholder":`before_msg_id 游标`,"messages.limitPlaceholder":`条数 <= 100`,"messages.searchMessages":`查询消息`,"messages.nextPage":`下一页`,"messages.previousPage":`上一页`,"messages.currentPage":`当前页消息`,"messages.deleted":`已删除`,"messages.outgoing":`发出消息`,"messages.incoming":`收到`,"messages.ownerPeer":`所属 / 对端`,"messages.deleteSelected":`删除指定消息`,"messages.idsPlaceholder":`消息 ID,逗号分隔`,"messages.revoke":`同步撤回`,"messages.previewDelete":`预演删除`,"messages.clearHistory":`清空私聊历史`,"messages.maxIDPlaceholder":`max_id 截止消息`,"messages.maxBatchesPlaceholder":`max_batches 批次数`,"messages.justClear":`仅清本侧`,"messages.previewClearHistory":`预演清历史`,"messages.direction":`方向`,"messages.body":`正文`,"messages.privateDetailTitle":`消息 #{id}`,"messages.detailEyebrow":`消息详情`,"messages.backPrivate":`返回私聊消息`,"messages.backGroup":`返回群聊消息`,"messages.ownerPeerTitle":`所属 {owner} · 对端 {peer}`,"messages.senderSubtitle":`发送方 {sender} · {date}`,"messages.boxID":`消息盒 ID`,"messages.privateMessageID":`私聊消息 ID`,"messages.messageSender":`发送方`,"messages.messageBox":`消息盒`,"messages.dialogRow":`会话行`,"messages.privateRow":`私聊消息行`,"messages.channelMessageRow":`消息行`,"messages.channelRow":`频道行`,"messages.userUpdateEvents":`更新事件`,"messages.channelUpdateEvents":`频道更新事件`,"messages.eventJson":`事件 JSON`,"messages.dispatchOutbox":`分发队列`,"messages.messageBoxesSnapshot":`message_boxes 只读快照`,"messages.dialogSnapshot":`dialogs 只读快照`,"messages.privateSnapshot":`private_messages 只读快照`,"messages.channelMessagesSnapshot":`channel_messages 只读快照`,"messages.channelSnapshot":`channels 只读快照`,"messages.userEventsSource":`durable user_update_events`,"messages.channelEventsSource":`durable channel_update_events`,"messages.outboxSource":`在线/离线 dispatch_outbox`,"messages.attempts":`尝试`,"messages.deleteThis":`删除此消息`,"messages.groupDetailTitle":`群聊消息 #{id}`,"messages.channelGroupTitle":`频道/群 {id}`,"messages.mediaCount":`有媒体`,"messages.channelPosts":`频道帖子`,"messages.channelGroup":`频道 / 群`,"messages.pinned":`置顶`,"messages.channelPost":`频道帖子`,"gifts.pageTitle":`星星礼物目录`,"giveGift.action":`赠送`,"giveGift.eyebrow":`发放礼物 · 免费`,"giveGift.title":`赠送礼物`,"giveGift.recipientKind":`接收方类型`,"giveGift.recipientUser":`用户`,"giveGift.recipientChannel":`频道`,"giveGift.pickUser":`接收用户`,"giveGift.pickChannel":`接收频道`,"giveGift.recipientRequired":`请先选择接收方`,"giveGift.sender":`发送方账号 ID`,"giveGift.senderHint":`礼物始终由系统账号 777000(Telesrv)发送。`,"giveGift.message":`附加留言(可选)`,"giveGift.messagePlaceholder":`随礼物一起显示`,"giveGift.hideName":`对接收方隐藏发送方名称`,"giveGift.upgrade":`作为升级收藏品发放`,"giveGift.upgradeNote":`礼物将铸造为唯一收藏品。可在下方指定具体属性,或保持“随机”从已发布的属性池中抽取。编号自动分配。需要存在有剩余供应量的已发布收藏品升级。`,"giveGift.model":`模型`,"giveGift.pattern":`图案`,"giveGift.backdrop":`背景`,"giveGift.random":`随机`,"giveGift.confirm":`赠送礼物`,"giveGifts.pageTitle":`赠送礼物`,"giveGifts.eyebrow":`向任意用户或频道发放目录礼物`,"giveGifts.available":`可用礼物`,"giveGifts.sender":`默认发送方`,"giveGifts.searchPlaceholder":`按标题或礼物 ID 搜索`,"giveGifts.hint":`选择要发放的礼物。发放免费,默认由系统账号 777000(Telesrv)发送。`,"giveGifts.pickGift":`选择礼物`,"giveGifts.selectPrompt":`从列表中选择一个礼物开始。`,"gifts.eyebrow":`目录、不可变版本与动画资源`,"gifts.total":`目录条目`,"gifts.enabled":`已启用`,"gifts.received":`已领取礼物`,"gifts.formats":`支持格式`,"gifts.add":`添加礼物`,"gifts.searchPlaceholder":`搜索礼物 ID、标题或格式`,"gifts.listSummary":`显示 {shown} / {total} 项`,"gifts.idRevision":`ID / 版本`,"gifts.price":`售价 / 兑换`,"gifts.importTitle":`导入星星礼物`,"gifts.importEyebrow":`礼物目录操作`,"gifts.newRevision":`为礼物 #{id} 创建新版本`,"gifts.importHint":`支持 TGS 或纯 Lottie JSON;Lottie 会规范化并压缩成 TGS。`,"gifts.officialSource":`官方资源库`,"gifts.fileSource":`上传文件`,"gifts.officialHint":`从 data/official-gifts 的已校验快照中选择;完整 collectible 属性池会与礼物原子导入。`,"gifts.officialSearch":`搜索官方礼物 ID 或标题`,"gifts.officialSelect":`请选择官方礼物`,"gifts.officialRequired":`请先选择一个官方礼物`,"gifts.officialResults":`显示 {shown} / {total} 项`,"gifts.officialCategoryLabel":`官方礼物能力分类`,"gifts.officialCategory.all":`全部`,"gifts.officialCategory.upgrade":`可升级`,"gifts.officialCategory.craft":`可 Craft`,"gifts.officialCategory.basic":`不可升级`,"gifts.officialUnnamed":`未命名官方礼物 #{id}`,"gifts.officialAttributes":`{count} 个属性`,"gifts.canUpgrade":`可升级`,"gifts.cannotUpgrade":`不可升级`,"gifts.canCraft":`可 Craft`,"gifts.cannotCraft":`不可 Craft`,"gifts.officialEmpty":`当前分类和搜索条件下没有官方礼物。`,"gifts.includeCollectible":`完整导入 collectible 属性池(包含 crafted 模型)`,"gifts.animation":`动画文件`,"gifts.filePrompt":`拖放或选择 TGS / Lottie 文件`,"gifts.fileHint":`支持 TGS、JSON、Lottie,导入前会先进行校验`,"gifts.chooseFile":`选择文件`,"gifts.changeFile":`更换文件`,"gifts.title":`显示标题`,"gifts.titlePlaceholder":`例如:庆典星星`,"gifts.stars":`售价 Stars`,"gifts.convertStars":`可兑换 Stars`,"gifts.sortOrder":`排序值`,"gifts.lifecycle.label":`礼物模式`,"gifts.lifecycle.regular":`普通`,"gifts.lifecycle.auction":`拍卖`,"gifts.lifecycle.drop":`定时发售`,"gifts.lifecycle.hintRegular":`启用后即可购买。`,"gifts.lifecycle.hintAuction":`库存按计时轮次出售,出价最高者获得。Stars 价格即最低出价。`,"gifts.lifecycle.hintDrop":`礼物在倒计时结束前保持锁定,到达解锁时间后自动开放购买。`,"gifts.lifecycle.auctionFileOnly":`拍卖需要自带动画,因此只能在上传标签页中创建。快照礼物仍可设置延迟发布。`,"gifts.drop.schedule":`设置延迟发布`,"gifts.auction.slug":`拍卖标识`,"gifts.auction.slugPlaceholder":`summer-auction`,"gifts.auction.supply":`总量`,"gifts.auction.perRound":`每轮礼物数`,"gifts.auction.roundDuration":`每轮时长(秒)`,"gifts.auction.startAt":`开始时间(留空表示立即开始)`,"gifts.auction.plan":`{rounds} 轮 × 每轮 {perRound} 个,每轮 {minutes} 分钟`,"gifts.drop.unlockAt":`解锁时间`,"gifts.lifecycle.slugRequired":`拍卖需要填写标识。`,"gifts.lifecycle.supplyRequired":`总量必须大于 0。`,"gifts.lifecycle.perRoundRequired":`每轮礼物数必须大于 0。`,"gifts.lifecycle.perRoundTooLarge":`每轮礼物数不能超过总量。`,"gifts.lifecycle.roundDurationRequired":`每轮时长必须大于 0。`,"gifts.lifecycle.startPast":`拍卖开始时间已过。`,"gifts.lifecycle.unlockRequired":`请选择一个将来的解锁时间。`,"auctions.title":`拍卖与定时发售`,"auctions.eyebrow":`礼物生命周期运营`,"auctions.newAuction":`新建拍卖`,"auctions.newDrop":`新建定时发售`,"auctions.metricAuctions":`拍卖总数`,"auctions.metricLive":`进行中`,"auctions.metricDrops":`定时发售`,"auctions.metricPendingDrops":`等待解锁`,"auctions.metricCommitted":`出价占用星星`,"auctions.colGift":`礼物`,"auctions.colKind":`类型`,"auctions.colRounds":`轮次`,"auctions.colSupply":`剩余数量`,"auctions.colMinBid":`最低出价`,"auctions.colBids":`出价`,"auctions.colNext":`下一节点`,"auctions.kindAuction":`拍卖`,"auctions.kindDrop":`定时发售`,"auctions.untitled":`未命名礼物`,"auctions.unlimited":`无限量`,"auctions.roundOf":`第 {current} / {total} 轮`,"auctions.perRoundShort":`每轮 {minutes} 分钟`,"auctions.awarded":`已发出 {count}`,"auctions.bidCount":`{count} 个有效出价`,"auctions.topBid":`最高 {amount}`,"auctions.minBid":`最低出价(星星)`,"auctions.enableHint":`立即启用(礼物停用时,拍卖和定时发售对客户端不可见)`,"auctions.lazyNote":`引擎会在首次客户端请求或清扫任务时创建拍卖状态,因此新建拍卖在此之前显示“等待引擎”。`,"auctions.now":`已到时间`,"auctions.durationDH":`{days} 天 {hours} 小时`,"auctions.durationHM":`{hours} 小时 {minutes} 分`,"auctions.durationMS":`{minutes} 分 {seconds} 秒`,"auctions.durationS":`{seconds} 秒`,"auctions.status.pending":`等待开始`,"auctions.status.awaiting":`等待引擎`,"auctions.status.active":`进行中`,"auctions.status.completed":`已结束`,"auctions.status.cancelled":`已取消`,"auctions.status.scheduled":`已锁定`,"auctions.status.released":`已解锁`,"gifts.reason":`审计原因`,"gifts.reasonPlaceholder":`简要说明本次导入礼物的原因`,"gifts.enableAfterImport":`导入后启用`,"gifts.validate":`Dry-run 校验`,"gifts.confirmImport":`确认导入`,"gifts.stepDetails":`文件与信息`,"gifts.stepValidate":`Dry-run 校验`,"gifts.stepImport":`确认导入`,"gifts.fileRequired":`请先选择 TGS 或 Lottie 文件`,"gifts.source":`来源`,"gifts.replace":`创建新版本`,"gifts.disable":`停用`,"gifts.enable":`启用`,"gifts.empty":`尚未导入星星礼物。`,"gifts.emptyHint":`从上方导入第一个动画,开始搭建礼物目录。`,"gifts.validationReady":`校验已通过`,"gifts.validationHint":`确认规范化后的元数据无误,再执行正式导入。`,"gifts.confirmState":`确认执行礼物 #{id} 的状态变更吗?`,"collectibles.manage":`属性池`,"collectibles.title":`Collectibles 属性池 · 礼物 #{id}`,"collectibles.eyebrow":`唯一礼物属性管理`,"collectibles.activeRevision":`已发布版本 {revision}`,"collectibles.published":`已发布`,"collectibles.noPool":`尚未发布 Collectibles 属性池`,"collectibles.noPoolHint":`发布模型、图案与背景后,客户端即可升级为唯一礼物。`,"collectibles.publishNew":`发布新的不可变版本`,"collectibles.immutableHint":`Dry-run 会校验全部文件和属性池结构,通过后才切换为当前版本。`,"collectibles.upgradeStars":`升级价格 Stars`,"collectibles.supply":`唯一礼物总量`,"collectibles.slug":`公开 Slug 前缀`,"collectibles.models":`模型`,"collectibles.patterns":`图案`,"collectibles.backdrops":`背景`,"collectibles.model":`模型`,"collectibles.pattern":`图案`,"collectibles.backdrop":`背景`,"collectibles.rarity":`稀有度 ‰`,"collectibles.rarityHint":`每类至少保留两项。Permille 是普通升级的相对权重,增删时会自动重新分配为 1000。`,"collectibles.minimumAttributes":`模型、图案和背景每类都必须至少包含两个属性。`,"collectibles.duplicateBackdropID":`同一属性池中的背景 ID 必须唯一。`,"collectibles.colorHint":`颜色会按 24 位 RGB 数值保存。`,"collectibles.addAttribute":`添加`,"collectibles.remove":`删除属性`,"collectibles.fileRequired":`每个模型和图案都必须选择 TGS 或 Lottie 文件。`,"collectibles.backdropID":`背景 ID`,"collectibles.color.center":`中心色`,"collectibles.color.edge":`边缘色`,"collectibles.color.pattern":`图案色`,"collectibles.color.text":`文字色`,"collectibles.validationReady":`属性池校验通过`,"collectibles.validationHint":`确认规范化资源无误后,即可发布这个不可变版本。`,"collectibles.publish":`发布版本`,"moderation.casesEyebrow":`内容安全 / 案件`,"moderation.currentQueue":`当前队列`,"moderation.criticalCases":`关键案件`,"moderation.pendingOrFailed":`处置待完成 / 失败`,"moderation.statusFilter":`案件状态筛选`,"moderation.statusFilter.active":`活跃队列`,"moderation.statusFilter.all":`全部状态`,"moderation.assignee":`审核人`,"moderation.allAssignees":`留空为全部`,"moderation.case":`案件`,"moderation.target":`目标`,"moderation.severity":`等级`,"moderation.reportsAndReporters":`举报 / 举报人`,"moderation.latestReport":`最近举报`,"moderation.review":`审核`,"moderation.status.open":`待审核`,"moderation.status.in_review":`审核中`,"moderation.status.action_pending":`等待处置`,"moderation.status.action_failed":`处置失败`,"moderation.status.resolved":`已处置`,"moderation.status.dismissed":`已驳回`,"moderation.status.appeal_review":`申诉复核中`,"moderation.severity.low":`低`,"moderation.severity.medium":`中`,"moderation.severity.high":`高`,"moderation.severity.critical":`关键`,"moderation.targetType.user":`账号`,"moderation.targetType.chat":`群组`,"moderation.targetType.channel":`频道`,"moderation.caseDetailTitle":`审核案件 #{id}`,"moderation.caseDetailEyebrow":`内容安全 / 案件详情`,"moderation.backToQueue":`返回队列`,"moderation.loadingCase":`正在加载审核案件…`,"moderation.versionAndUpdated":`版本 {version} · 最近更新 {time}`,"moderation.reportCount":`举报数`,"moderation.reportCountValue":`{reports} 次({reporters} 位举报人)`,"moderation.firstAndLatestReport":`首个 / 最近举报`,"moderation.evidence":`举报证据`,"moderation.evidenceHint":`最多显示最近 100 条;快照在举报受理时冻结。`,"moderation.sourceAndReason":`来源 / 原因`,"moderation.reporter":`举报人`,"moderation.option":`选项`,"moderation.decisionAudit":`决定与处置审计`,"moderation.decisionAuditHint":`动作由租约 worker 幂等执行;失败保留错误与尝试次数。`,"moderation.appeals":`申诉`,"moderation.caseActions":`案件操作`,"moderation.renewClaim":`续领案件`,"moderation.claimCase":`领取案件`,"moderation.reviewReason":`审核理由`,"moderation.decisionPreset":`决定模板`,"moderation.preset.noViolation":`无违规(驳回举报)`,"moderation.preset.scam":`标记 SCAM`,"moderation.preset.fake":`标记 FAKE`,"moderation.preset.freeze":`冻结账号`,"moderation.preset.scamFreeze":`SCAM + 冻结`,"moderation.preset.fakeFreeze":`FAKE + 冻结`,"moderation.preset.deleteMessages":`删除证据覆盖的消息`,"moderation.preset.deleteAccount":`删除账号`,"moderation.evidenceMessageIDs":`证据消息 ID(逗号分隔)`,"moderation.privateOwnerUserID":`私聊 owner_user_id`,"moderation.revokeForBoth":`双方撤回`,"moderation.evidenceValidationHint":`服务端会再次校验每个消息 ID 必须存在于该案件的不可变举报证据中。`,"moderation.failedActionHint":`处置已部分执行,不能直接改为无违规;请选择新的处置动作重新执行并保留旧失败审计。`,"moderation.retryAction":`重新执行处置`,"moderation.submitDecision":`提交决定`,"moderation.appealReviewTitle":`申诉复核 #{id}`,"moderation.automaticRemedy":`通过后自动恢复`,"moderation.irreversibleAppealHint":`案件包含已成功的不可逆删除动作,不能标记为“申诉通过并已恢复”;请驳回或升级人工处理。`,"moderation.denyAppeal":`驳回申诉`,"moderation.grantAppeal":`通过申诉`,"moderation.reasonRequired":`必须填写审核理由。`,"moderation.appealReasonRequired":`必须填写申诉复核理由。`,"moderation.privateDeleteValidation":`私聊删除需要合法的证据消息 ID 和举报人 owner_user_id。`,"moderation.channelDeleteValidation":`频道删除需要至少一个合法的证据消息 ID。`,"moderation.confirmDecision":`确认提交“{decision}”决定?处置会通过 durable action 队列执行。`,"moderation.confirmGrantAppeal":`确认通过申诉?`,"moderation.confirmDenyAppeal":`确认驳回申诉?`,"moderation.remedy.clearFlags":`清除 SCAM / FAKE`,"moderation.remedy.unfreeze":`解除冻结`,"moderation.remedy.none":`无需恢复动作`,"moderation.source.account_peer":`账号 / Peer`,"moderation.source.profile_photo":`资料照片`,"moderation.source.messages_spam":`消息垃圾内容`,"moderation.source.messages":`消息`,"moderation.source.encrypted_spam":`加密聊天垃圾内容`,"moderation.source.reaction":`回应`,"moderation.source.channel_spam":`频道垃圾内容`,"moderation.source.story":`Story`,"moderation.source.ephemeral":`阅后即焚媒体`,"moderation.source.sponsored":`赞助消息`,"moderation.source.antispam_false_positive":`反垃圾误判`,"moderation.reason.spam":`垃圾内容`,"moderation.reason.violence":`暴力`,"moderation.reason.pornography":`色情内容`,"moderation.reason.child_abuse":`儿童虐待`,"moderation.reason.other":`其他`,"moderation.reason.copyright":`版权`,"moderation.reason.geo_irrelevant":`与地区无关`,"moderation.reason.fake":`虚假信息`,"moderation.reason.illegal_drugs":`非法药物`,"moderation.reason.personal_details":`个人信息`,"messages.msgIDsInvalid":`消息 ID 无效`,"auth.device":`设备`,"auth.platform":`平台`,"auth.ip":`IP`,"auth.lastActive":`最近活跃`,"auth.revokeCurrent":`撤销当前`,"auth.keepCurrent":`保留当前`,"auth.revokeAll":`撤销全部设备`,"picker.userPlaceholder":`搜索 user_id / phone / username`,"picker.channelPlaceholder":`搜索 channel_id / username / title`,"picker.verified":`认证`,"picker.regular":`普通`,"action.reasonRequired":`请填写操作原因`,"action.flow":`操作流程`,"action.close":`关闭`,"action.stepReason":`填写原因`,"action.stepDryRun":`预演检查`,"action.stepConfirm":`确认执行`,"action.reason":`操作原因`,"action.reasonPlaceholder":`说明本次操作原因`,"action.requestPreview":`请求预览`,"action.result":`操作结果`,"action.commandID":`命令 ID`,"action.status":`状态`,"action.dryRun":`预演`,"action.runAgain":`重新预演`,"action.runDry":`先预演`,"action.confirm":`确认执行`,"audit.id":`ID`,"audit.commandID":`命令 ID`,"audit.action":`动作`,"audit.actor":`操作者`,"audit.status":`状态`,"audit.dryRun":`预演`,"audit.reason":`原因`,"audit.time":`时间`,"common.loadMore":`加载更多`,"route.collectibleUsernames":`收藏用户名`,"route.collectibleUsernamesSubtitle":`控制台 / 收藏用户名`,"route.accountRatings":`账号评级`,"route.accountRatingsSubtitle":`控制台 / 账号评级`,"route.storage":`对象存储`,"route.storageSubtitle":`控制台 / 对象存储`,"layout.storage":`对象存储`,"storage.title":`对象存储`,"storage.eyebrow":`媒体 / 只读盘点`,"storage.physical":`物理字节`,"storage.logical":`逻辑引用字节`,"storage.dedupSaved":`去重节省`,"storage.objects":`唯一对象`,"storage.references":`Blob 引用`,"storage.documents":`文档`,"storage.photos":`照片`,"storage.backend":`后端`,"storage.retentionBlocked":`完整媒体引用图建立并校验前,物理删除功能保持关闭。`,"layout.collectibleUsernames":`NFT 用户名`,"route.collectiblePhones":`匿名号码`,"route.collectiblePhonesSubtitle":`控制台 / NFT +888 号码`,"layout.collectiblePhones":`NFT 号码`,"phones.pageTitle":`匿名 +888 号码`,"phones.eyebrow":`Fragment 号码 / 归属台账`,"phones.number":`号码`,"phones.class":`类别`,"phones.standard":`普通`,"phones.exclusive":`专属`,"phones.allClasses":`全部类别`,"phones.mintTitle":`发行匿名号码`,"phones.mintHint":`普通号码遵循电话隐私;专属号码始终显示在资料中。登录号码不会被修改。`,"phones.ownerOptional":`初始持有人(可选)`,"phones.mint":`发行号码`,"phones.search":`搜索 +888 号码`,"phones.manage":`管理`,"phones.manageHint":`转移、收回库存或销毁该资产。`,"phones.fiatAmount":`法币价格(USD)`,"phones.tonAmount":`TON 价格`,"phones.pricePreview":`Telegram 将显示:{crypto}({fiat})。请输入正常金额;面板会把 USD 转成美分,把 TON 转成 nanotons。`,"phones.priceInvalid":`请输入大于零的 USD 价格(最多 2 位小数)和 TON 价格(最多 9 位小数)。`,"phones.updatePrice":`保存价格`,"layout.accountRatings":`账号评级`,"usernames.pageTitle":`收藏用户名`,"usernames.eyebrow":`NFT 用户名 / 资产台账`,"usernames.metricLoaded":`已加载`,"usernames.metricVault":`库存中`,"usernames.metricOwned":`已归属`,"usernames.metricBurned":`已销毁`,"usernames.mintTitle":`铸造收藏用户名`,"usernames.mintHint":`同时写入购买记录;保持“库存”即不指定归属。`,"usernames.mint":`铸造用户名`,"usernames.mintNote":`用户名、币种与金额必填;预演会先校验可用性。`,"usernames.ownerKind":`归属类型`,"usernames.ownerVault":`库存(无归属)`,"usernames.ownerUser":`归属用户`,"usernames.ownerChannel":`归属频道`,"usernames.currency":`币种`,"usernames.amount":`金额({currency})`,"usernames.cryptoCurrency":`加密币种`,"usernames.cryptoNone":`无`,"usernames.cryptoAmount":`加密金额({currency})`,"usernames.amountHint":`金额按完整的 {currency} 输入,保存时会转换为 API 与 fragment.collectibleInfo 使用的最小单位,客户端才会显示你想要的价格。最多 {decimals} 位小数。客户端将显示:{preview}。`,"usernames.amountInvalid":`这不是有效的 {currency} 金额:只能是数字,最多 {decimals} 位小数。`,"usernames.inactive":`未启用`,"usernames.url":`市场链接`,"usernames.purchaseDate":`购买日期(UTC)`,"usernames.purchaseTime":`购买时间(UTC)`,"usernames.searchPlaceholder":`按用户名搜索`,"usernames.statusAll":`全部状态`,"usernames.statusVault":`库存`,"usernames.statusOwned":`已归属`,"usernames.statusBurned":`已销毁`,"usernames.price":`价格`,"usernames.transfers":`转移次数`,"usernames.registryActive":`资料中展示`,"usernames.registryHidden":`资料中隐藏`,"usernames.loadingDetail":`正在加载收藏用户名…`,"usernames.detailTitle":`收藏用户名 {username}`,"usernames.detailEyebrow":`NFT 用户名 / 资产详情`,"usernames.assetID":`资产 #{id}`,"usernames.transferCount":`转移 {count} 次`,"usernames.originalOwner":`最初归属`,"usernames.openOwnerAccount":`打开归属账号`,"usernames.openOwnerChannel":`打开归属频道`,"usernames.openMarketplace":`打开市场页面`,"usernames.transferTitle":`转移归属`,"usernames.transferHint":`选择接收方;转移会记入流转历史。`,"usernames.recipientKind":`接收方类型`,"usernames.recipientUser":`转给用户`,"usernames.recipientChannel":`转给频道`,"usernames.transferNote":`确认后当前持有者立即失去该用户名。`,"usernames.transfer":`转移`,"usernames.historyTitle":`流转历史`,"usernames.historyHint":`按时间顶序展示铸造、转移、收回与销毁事件。`,"usernames.eventKind":`事件`,"usernames.fromPeer":`来自`,"usernames.toPeer":`转至`,"usernames.actionDock":`资产操作`,"usernames.revoke":`收回至库存`,"usernames.revokeHint":`从持有者收回用户名并放回库存,之后可再次发放。`,"usernames.burn":`永久销毁`,"usernames.burnHint":`不可撤销:用户名将被销毁且永不可再发放。`,"usernames.burnedHint":`该用户名已销毁,无法再执行任何操作。`,"usernames.delete":`删除记录`,"usernames.deleteHint":`彻底删除该藏品及其持有历史,并释放用户名以便重新发放。适用于误发的用户名;销毁则会保留历史。`,"usernames.kind.mint":`铸造`,"usernames.kind.transfer":`转移`,"usernames.kind.revoke":`收回`,"usernames.kind.burn":`销毁`,"rating.pageTitle":`账号评级榜`,"rating.eyebrow":`评级 / 排行榜`,"rating.metricLoaded":`已加载`,"rating.metricTopLevel":`最高等级`,"rating.metricAvgLevel":`平均等级`,"rating.metricPending":`有待生效分值`,"rating.searchPlaceholder":`按用户名、姓名或用户 ID 搜索`,"rating.minLevel":`最低等级`,"rating.userID":`用户 ID`,"rating.level":`等级`,"rating.stars":`分值`,"rating.progress":`升级进度`,"rating.pending":`待生效`,"rating.computedAt":`计算时间`,"rating.levelValue":`{level} 级`,"rating.maxLevel":`已达最高等级`,"rating.progressHint":`距 {target} 还差 {remaining}`,"rating.loadingDetail":`正在加载账号评级…`,"rating.detailTitle":`{user} 的评级`,"rating.detailEyebrow":`评级 / 构成明细`,"rating.pendingBadge":`待生效 {amount}`,"rating.nextLevel":`下一级门槛`,"rating.toNextLevel":`升级所需分值`,"rating.breakdownTitle":`评级构成`,"rating.breakdownHint":`当前分值的来源:星星、活跃度、审核处罚与人工修正。`,"rating.breakdownMismatch":`各项合计为 {sum},而存储分值为 {total};请重新计算以消除偏差。`,"rating.breakdownPending":`各项已包含 {amount},但要到下列日期才计入分值。`,"rating.currentLevelStars":`当前等级门槛`,"rating.nextLevelStars":`下一等级门槛`,"rating.pendingTitle":`待生效分值`,"rating.pendingHint":`已获得但要到下列日期才计入评级的分值。`,"rating.pendingDate":`生效日期`,"rating.eventsTitle":`评级事件`,"rating.eventsHint":`每一次分值变动及其来源、操作者与原因。`,"rating.eventKind":`来源`,"rating.amount":`变动值`,"rating.actionDock":`评级操作`,"rating.openAccount":`打开账号`,"rating.recompute":`重新计算`,"rating.recomputeHint":`根据星星、活跃度、处罚与人工修正重新生成分值。`,"rating.adjustTitle":`人工修正`,"rating.adjustAmount":`数值(可为负)`,"rating.adjust":`应用修正`,"rating.adjustHint":`该数值会累加到人工修正项;填负数即为扣减。`,"rating.componentStars":`星星`,"rating.componentStarsHint":`购买与收到的星星`,"rating.componentActivity":`活跃度`,"rating.componentActivityHint":`消息、会话与长期活跃`,"rating.componentPenalty":`处罚`,"rating.componentPenaltyHint":`审核处置与限制`,"rating.componentManual":`人工修正`,"rating.componentManualHint":`管理员手动调整`,"rating.componentTotal":`总分`,"rating.kind.stars":`星星`,"rating.kind.activity":`活跃度`,"rating.kind.moderation":`审核`,"rating.kind.manual":`人工`,"rating.kind.recompute":`重新计算`,"route.verification":`官方认证`,"route.verificationSubtitle":`控制台 / 官方认证`,"layout.verification":`官方认证`,"permission.deniedTitle":`权限不足`,"permission.deniedEyebrow":`控制台 / 访问控制`,"permission.deniedBody":`当前会话没有 {permission} 权限,该板块保持关闭。`,"permission.deniedHeading":`板块不可用`,"permission.deniedHint":`请让运维在 TELESRV_ADMIN_UI_PERMISSIONS 中补上该权限,然后重新登录。`,"verification.pageTitle":`认证申请队列`,"verification.eyebrow":`认证 / 队列`,"verification.searchPlaceholder":`申请 ID、对象 ID、用户名或名称`,"verification.statusAll":`全部状态`,"verification.targetType":`对象类型`,"verification.targetTypeAll":`全部类型`,"verification.reviewer":`审核人`,"verification.reviewerPlaceholder":`全部审核人`,"verification.target":`认证对象`,"verification.applicant":`申请人`,"verification.category":`类别`,"verification.submittedAt":`提交时间`,"verification.alreadyVerified":`已有认证标记`,"verification.status.draft":`草稿`,"verification.status.submitted":`已提交`,"verification.status.in_review":`审核中`,"verification.status.approved":`已通过`,"verification.status.rejected":`已驳回`,"verification.status.cancelled":`已取消`,"verification.type.bot":`机器人`,"verification.type.channel":`频道`,"verification.type.supergroup":`超级群`,"verification.type.user":`用户`,"verification.loadingDetail":`正在加载申请…`,"verification.detailTitle":`申请 #{id}`,"verification.detailEyebrow":`认证 / 审核`,"verification.conflict":`该申请已被其他管理员修改,数据已重新加载;请确认状态后再次提交决定。`,"verification.controlsOk":`对象权限已确认`,"verification.controlsLost":`已失去对象权限`,"verification.controlsOkHint":`按当前实时记录核对(而非提交时的快照),申请人此刻仍然掌控该对象。`,"verification.controlsLostHint":`申请人已不再掌控该对象。此时通过,等于把认证标记发给并非持有人的一方,通常应当驳回。`,"verification.targetSection":`认证对象`,"verification.targetHint":`将要挂上认证标记的对象,展示的是当前实时状态。`,"verification.openTarget":`打开对象`,"verification.targetTitle":`名称`,"verification.targetID":`对象 ID`,"verification.applicantSection":`申请人`,"verification.applicantHint":`谁提交了申请,以及他是否仍持有该对象的权限。`,"verification.openApplicant":`打开账号`,"verification.applicantID":`用户 ID`,"verification.applicationSection":`申请内容`,"verification.applicationHint":`申请人填写的全部内容,一律按纯文本展示。`,"verification.correlationID":`关联 ID`,"verification.createdAt":`创建时间`,"verification.description":`说明`,"verification.officialWebsite":`官方网站`,"verification.socialLinks":`社交账号`,"verification.pressLinks":`媒体报道`,"verification.additionalNote":`申请人备注`,"verification.notProvided":`未填写`,"verification.linkSafetyHint":`只有 http:// 与 https:// 链接可点击并在新标签页打开,其余一律按文本显示。`,"verification.decisionSection":`决定`,"verification.decisionHint":`已记录的结论、审核人与理由。`,"verification.reviewedAt":`决定时间`,"verification.version":`版本(乐观锁)`,"verification.decisionReason":`决定理由`,"verification.noDecision":`尚无决定`,"verification.internalNote":`内部备注`,"verification.adminOnly":`仅管理员可见`,"verification.eventsSection":`历史记录`,"verification.eventsHint":`不可篡改的状态流转记录,含操作者与理由。`,"verification.eventKind":`事件`,"verification.transition":`状态变化`,"verification.eventNote":`内部备注`,"verification.kind.created":`创建`,"verification.kind.updated":`修改`,"verification.kind.submitted":`提交`,"verification.kind.claimed":`领取`,"verification.kind.approved":`通过`,"verification.kind.rejected":`驳回`,"verification.kind.cancelled":`取消`,"verification.kind.revoked":`撤销标记`,"verification.kind.notified":`已通知申请人`,"verification.actionDock":`审核操作`,"verification.noActions":`当前状态没有可执行的操作。`,"verification.claim":`领取审核`,"verification.claimHint":`把申请分配给自己并转入审核中,避免两名审核人同时处理同一条。`,"verification.internalNotePlaceholder":`给其他审核人的交接说明`,"verification.internalNoteHint":`可选。随决定一起保存,仅管理员可见,不会发送给申请人。`,"verification.alreadyVerifiedHint":`该对象已带有认证标记,通过操作只是补记这次决定。`,"verification.approve":`通过认证`,"verification.approveHint":`为该对象授予官方认证标记并结束申请。`,"verification.reject":`驳回`,"verification.rejectHint":`必须填写理由:这段文字会告知申请人,请写清究竟缺少什么。`,"verification.dangerZone":`高危操作`,"verification.revoke":`撤销认证`,"verification.revokeHint":`清除该对象的认证标记;已通过的申请仍作为历史保留。`,"verification.revokeNotVerified":`该对象当前没有认证标记,无需撤销。`,"route.botVerification":`第三方认证`,"route.botVerificationSubtitle":`控制台 / 第三方认证`,"layout.botVerification":`第三方标记`,"picker.system":`系统`,"picker.botPlaceholder":`机器人用户名或 ID`,"botverification.pageTitle":`第三方认证`,"botverification.eyebrow":`第三方认证 / 认证方、图标、标记`,"botverification.explainTitle":`这是认证公司的图标,不是官方认证标记`,"botverification.explainText":`第三方标记是认证机器人自己的图标,显示在账号、机器人或频道名称的“前面”,并在资料页附一行说明。它只表示“该认证方为此对象背书”,仅此而已。`,"botverification.explainIcon":`图标是一个自定义表情文档。客户端通过 messages.getCustomEmojiDocuments 拉取,所以指向不存在文档的 ID 会显示为完全没有标记——因此标记只能从下面的图标目录中授予,而不是手输一个数字。`,"botverification.explainOfficial":`官方认证标记是另一套机制,由平台在“官方认证”板块授予。两者分别存储、分别显示、分别撤销,任何一方都不代表另一方。`,"botverification.openOfficial":`官方认证`,"botverification.manageMissing":`本会话可以查看本板块并裁决申请,但不能修改认证方或图标目录——那需要 botverification.manage 权限。`,"botverification.tabRequests":`申请`,"botverification.tabVerifiers":`认证方`,"botverification.tabIcons":`图标目录`,"botverification.tabMarks":`已授予的标记`,"botverification.queueTitle":`申请队列`,"botverification.queueHint":`对象持有者向认证机器人提交的申请。计数覆盖整个队列,而不是下面这一页。`,"botverification.searchPlaceholder":`申请 ID、对象 ID、用户名或标题`,"botverification.statusAll":`全部状态`,"botverification.status.pending":`待处理`,"botverification.status.approved":`已通过`,"botverification.status.rejected":`已拒绝`,"botverification.status.revoked":`标记已撤销`,"botverification.peer.user":`账号`,"botverification.peer.channel":`频道`,"botverification.peerType":`对象类型`,"botverification.peerTypeAll":`全部类型`,"botverification.verifier":`认证方`,"botverification.verifierAll":`全部认证方`,"botverification.verifierID":`认证机器人 ID`,"botverification.applicant":`申请人`,"botverification.applicantID":`用户 ID`,"botverification.target":`对象`,"botverification.targetTitle":`标题`,"botverification.targetID":`对象 ID`,"botverification.reason":`申请理由`,"botverification.requestedDescription":`申请的说明文字`,"botverification.description":`说明`,"botverification.createdAt":`提交时间`,"botverification.company":`公司`,"botverification.companyPlaceholder":`Acme Verification Ltd`,"botverification.bot":`机器人`,"botverification.icon":`图标`,"botverification.iconDocument":`文档 ID`,"botverification.iconName":`名称`,"botverification.markCount":`标记数`,"botverification.grantedBy":`授予人`,"botverification.notProvided":`未设置`,"botverification.verifiersTitle":`认证机器人`,"botverification.verifiersHint":`获准发放自有标记的机器人。认证方身份按部署授予,所以这里的每一行都是运营人员手动打开的“标记发放机”。`,"botverification.grantTitle":`授予认证方身份`,"botverification.updateTitle":`更新认证方`,"botverification.grantHint":`机器人会拿到目录中的一个图标和一个用于背书的公司名。同一个接口也用于更新已有认证方,因此需要带上版本号。`,"botverification.grantBot":`机器人`,"botverification.grantIcon":`目录中的图标`,"botverification.grantIconPick":`选择图标`,"botverification.defaultDescription":`默认说明`,"botverification.defaultDescriptionPlaceholder":`由 Acme 认证`,"botverification.canModify":`允许认证方为每个对象单独改写说明`,"botverification.canModifyShort":`自定义说明`,"botverification.canModifyHint":`对应 botVerifierSettings.can_modify_custom_description:关闭时,该认证方发放的每个标记都使用上面的默认说明,无论申请人写了什么。`,"botverification.noActiveIcons":`目录中没有启用的图标,无法授予。请先在图标目录中添加。`,"botverification.grantNote":`记录存在且处于启用状态后,该机器人即可开始标记对象。`,"botverification.grant":`授予认证方身份`,"botverification.update":`更新认证方`,"botverification.editing":`正在更新 {bot} —— 版本 {version} 作为乐观锁一起提交,若该行已被他人改动则请求被拒绝,而不是被覆盖。`,"botverification.cancelEdit":`取消更新`,"botverification.edit":`编辑`,"botverification.enable":`启用`,"botverification.disable":`停用`,"botverification.enabled":`已启用`,"botverification.disabled":`已停用`,"botverification.disableHint":`停用是针对单个认证方的紧急开关:已发放的标记继续显示,但该机器人不能再标记新对象,其设置也不再投射到 botInfo。`,"botverification.revokeVerifier":`撤销身份`,"botverification.revokeVerifierHint":`撤销认证方身份会删除该行以及它发放过的所有标记——图标会同时从它的全部对象上消失。`,"botverification.iconsTitle":`图标目录`,"botverification.iconsHint":`认证方可用于标记的自定义表情文档。除此之外的任何东西都不能作为图标,所以错误的标记在这里被拦下,而不是事后修补。`,"botverification.addIconTitle":`添加或重命名图标`,"botverification.addIconHint":`文档 ID 必须对应本部署上真实存在的自定义表情文档;“表情”板块会列出它们及其 ID。添加一个已存在的 ID 会重命名它,而不是新建一条。`,"botverification.iconNamePlaceholder":`Acme 蓝标`,"botverification.iconOwner":`归属`,"botverification.iconOwnerShared":`共享`,"botverification.iconOwnerHint":`共享图标可以授予任何认证方;指定归属后则只保留给那一个机器人。`,"botverification.iconDocumentHint":`指向不存在文档的 ID 会产生“隐形标记”:数据库里对象已被标记,而客户端什么也画不出来。`,"botverification.addIconNote":`添加图标本身不授予任何东西,只是让该文档可被授予。`,"botverification.addIcon":`保存图标`,"botverification.iconActive":`启用`,"botverification.iconInactive":`已下架`,"botverification.usedBy":`使用中的认证方`,"botverification.activateIcon":`启用`,"botverification.deactivateIcon":`下架`,"botverification.deactivateIconHint":`下架后该图标不能再授予给新的认证方。已经带着它的标记不受影响:图标在授予时就复制到了标记上。`,"botverification.marksTitle":`已授予的标记`,"botverification.marksHint":`当前带有第三方标记的所有对象,无论由谁授予——运营裁决、认证机器人自己,或对象持有者通过 bots.setCustomVerification。`,"botverification.markSearchPlaceholder":`对象 ID、用户名或标题`,"botverification.revokeMark":`移除标记`,"botverification.revokeMarkHint":`移除标记会清除对象上的图标和说明。对应的申请仍保留历史记录。`,"botverification.loadingDetail":`正在加载申请…`,"botverification.detailTitle":`申请 #{id}`,"botverification.detailEyebrow":`第三方认证 / 审核`,"botverification.conflict":`另一位管理员已经改动过这份申请。数据已重新加载——请先确认状态再做决定。`,"botverification.markActive":`标记生效中`,"botverification.markInactive":`对象上没有标记`,"botverification.markActiveHint":`该对象已带有此认证方的标记;通过申请只会刷新说明并记录这次决定。`,"botverification.verifierSection":`认证方`,"botverification.verifierHint":`对象将要佩戴其图标的公司,按其当前记录显示。`,"botverification.openVerifier":`打开认证机器人`,"botverification.verifierMissing":`认证方记录已不存在:这份申请提交后其身份被撤销了。没有可授予的图标,因此这份申请只能被拒绝。`,"botverification.verifierDisabledHint":`该认证方已被停用,在运营人员重新启用之前不能标记任何新对象。`,"botverification.targetSection":`对象`,"botverification.targetHint":`图标将要附加到的账号、机器人或频道。`,"botverification.openTarget":`打开对象`,"botverification.applicantSection":`申请人`,"botverification.applicantHint":`向认证机器人提交这份申请的人。`,"botverification.openApplicant":`打开账号`,"botverification.requestSection":`申请内容`,"botverification.requestHint":`申请人填写的内容,按纯文本呈现。`,"botverification.correlationID":`关联 ID`,"botverification.markPreview":`标记将显示的说明`,"botverification.markPreviewHint":`与后端的解析规则一致:只有当该认证方被允许自定义说明时才使用申请人的文字,否则使用认证方的默认说明。`,"botverification.descriptionIgnoredHint":`该认证方不能为单个对象设置说明,因此申请的文字被忽略,改用默认说明。`,"botverification.decisionSection":`决定`,"botverification.decisionHint":`谁做了什么决定,以及用了什么措辞。`,"botverification.decidedBy":`裁决人`,"botverification.approvedAt":`通过时间`,"botverification.rejectedAt":`拒绝时间`,"botverification.version":`版本(乐观锁)`,"botverification.decisionReason":`决定理由`,"botverification.noDecision":`尚未裁决`,"botverification.internalNote":`内部备注`,"botverification.adminOnly":`仅管理员可见`,"botverification.internalNotePlaceholder":`留给其他管理员的交接说明`,"botverification.internalNoteHint":`可选。与决定一起保存,仅管理员可见——绝不会发给申请人。`,"botverification.actionDock":`裁决`,"botverification.noActions":`当前状态没有可执行的操作。`,"botverification.approve":`通过`,"botverification.approveHint":`把认证方的图标放到对象名称前面,把说明放进资料页,并通知申请人。`,"botverification.reject":`拒绝`,"botverification.rejectHint":`理由必填:申请人看到的就是这段文字,所以请写清到底缺了什么。`,"botverification.dangerZone":`危险操作`,"botverification.revokeRequest":`撤销标记`,"botverification.revokeRequestHint":`从对象上移除图标和说明,并把申请关闭为“已撤销”。对象若持有官方认证标记,不受影响。`,"botverification.revokeNoMark":`该对象当前没有标记——撤销只会关闭这份申请。`,"botverification.rosterDenied":`服务器拒绝了本会话读取认证方名单与图标目录(403),因此这两个列表为空——申请仍然可以审核。`,"common.account":`账号`,"common.all":`全部`,"common.archive":`归档`,"common.archived":`已归档`,"common.bot":`机器人`,"common.changeFile":`更换文件`,"common.choose":`选择`,"common.chooseFile":`选择文件`,"common.copied":`已复制`,"common.copy":`复制`,"common.delete":`删除`,"common.disable":`禁用`,"common.document":`文档`,"common.documents":`文档数`,"common.enable":`启用`,"common.logo":`图标`,"common.next":`下一页`,"common.official":`官方`,"common.order":`顺序`,"common.pageOf":`第 {current} 页,共 {total} 页`,"common.perPage":`每页`,"common.previous":`上一页`,"common.preview":`预览`,"common.remove":`移除`,"common.save":`保存`,"common.shortName":`短名称`,"common.showing":`显示 {visible} / {total}`,"common.showingRange":`显示 {start}-{end},共 {total}`,"common.sortOrder":`排序值`,"common.source":`来源`,"common.title":`标题`,"common.unarchive":`取消归档`,"common.upload":`上传`,"common.view":`查看`,"language.label":`语言`,"dashboard.loadError":`加载概览失败`,"dashboard.needsAttention":`需要处理`,"dashboard.pendingReports":`待处理举报`,"dashboard.verificationRequests":`认证申请`,"dashboard.peopleChats":`用户与聊天`,"dashboard.users":`用户`,"dashboard.onlineNow":`当前在线`,"dashboard.lastFiveMinutes":`最近 5 分钟`,"dashboard.bots":`机器人`,"dashboard.channels":`频道`,"dashboard.supergroups":`超级群组`,"dashboard.content":`内容`,"dashboard.stickerPacks":`贴纸包`,"dashboard.emojiPacks":`Emoji 包`,"dashboard.gifs":`GIF`,"dashboard.savedByUsers":`用户已保存`,"dashboard.mediaStorageUsed":`媒体存储用量`,"dashboard.serverHealth":`服务器状态`,"dashboard.waitingForSample":`等待首次采样…`,"dashboard.cpuLoad":`CPU 负载`,"dashboard.ramUsed":`内存用量`,"dashboard.diskFree":`可用磁盘`,"dashboard.ofTotal":`共 {total}`,"dashboard.singleBackend":`{backend} 后端`,"dashboard.multipleBackends":`{count} 个后端`,"dashboard.noMediaObjects":`没有媒体对象`,"attr.firstName":`名字`,"attr.lastName":`姓氏`,"attr.setProfileName":`设置资料名称`,"attr.phoneNumber":`电话号码`,"attr.setPhone":`设置电话号码`,"attr.loginEmail":`登录邮箱`,"attr.loginEmailPlaceholder":`留空将清除邮箱`,"attr.setLoginEmail":`设置登录邮箱`,"avatar.change":`更换头像`,"avatar.new":`新头像`,"avatar.chooseImage":`选择 JPEG、PNG 或 WebP 图片`,"avatar.chooseFirst":`请先选择图片文件。`,"avatar.reasonPlaceholder":`简要说明更换头像的原因`,"avatar.upload":`上传头像`,"bots.copyToken":`复制机器人令牌`,"bots.copyTokenHint":`令牌会直接写入剪贴板,不会显示在屏幕上。`,"bots.noTokenReturned":`未返回令牌。`,"bots.tokenCopied":`令牌已复制到剪贴板。`,"bots.copyAgain":`再次复制`,"gifCatalog.title":`GIF 素材库`,"gifCatalog.eyebrow":`内置 @gif 内联机器人提供的受控媒体目录`,"gifCatalog.add":`添加 GIF`,"gifCatalog.entries":`目录条目`,"gifCatalog.adminUpload":`管理员上传`,"gifCatalog.newMaterial":`新建 @gif 素材`,"gifCatalog.chooseMedia":`选择 GIF 或 MP4`,"gifCatalog.required":`标题、操作原因和 GIF/MP4 文件均为必填项。`,"stickers.emojiEyebrow":`自定义 Emoji 包——系统包不可手动编辑`,"stickers.stickerEyebrow":`贴纸包——系统包不可手动编辑`,"stickers.createEmojiPack":`创建 Emoji 包`,"stickers.createStickerPack":`创建贴纸包`,"stickers.totalSets":`集合总数`,"stickers.searchPlaceholder":`搜索集合 ID、短名称或标题`,"stickers.newSet":`新集合`,"stickers.firstEmoji":`首个 Emoji`,"stickers.firstSticker":`首个贴纸`,"stickers.chooseMedia":`选择 TGS、Lottie JSON 或 WebP 文件`,"stickers.createReasonPlaceholder":`简要说明创建此包的原因`,"stickers.emojiRequired":`标题、短名称、Emoji 和首个 Emoji 文件均为必填项。`,"stickers.stickerRequired":`标题、短名称、Emoji 和首个贴纸文件均为必填项。`,"stickers.setContents":`集合内容`,"stickers.emptySet":`此集合没有文档。`,"stickers.chooseEmojiFirst":`请先选择 Emoji 文件。`,"stickers.chooseStickerFirst":`请先选择贴纸文件。`,"stickers.emojiValueRequired":`必须填写 Emoji。`,"stickers.addEmoji":`添加 Emoji`,"stickers.packFull":`此包已有 {count} 项,当前上限为 {max};请创建新的 Emoji 包后继续添加。`,"stickers.addSticker":`添加贴纸`,"action.oneTimeSecret":`这是一次性密钥——请立即复制,之后将不再显示。`},ru:{"app.adminConsole":`Панель администратора`,"app.localAccess":`Локальный доступ`,"app.title":`telesrv admin`,"common.actions":`Действия`,"common.admins":`Администраторы`,"common.backToList":`Назад к списку`,"common.channel":`Канал`,"common.channelOrGroup":`Канал / Группа`,"common.clear":`Очистить`,"common.close":`Закрыть`,"common.count":`Количество`,"common.deleted":`Удалено`,"common.detail":`Детали`,"common.device":`Устройство`,"common.disabled":`Отключено`,"common.enabled":`Включено`,"common.fromPeer":`От пира`,"common.group":`Группа`,"common.id":`ID`,"common.limit":`Лимит`,"common.loading":`Загрузка…`,"common.member":`Участник`,"common.members":`Участники`,"common.messageId":`ID сообщения`,"common.name":`Имя`,"common.no":`Нет`,"common.noResults":`Нет результатов`,"common.none":`Нет`,"common.normal":`Обычный`,"common.operations":`Операции`,"common.owner":`Владелец`,"common.platform":`Платформа`,"common.refresh":`Обновить`,"common.search":`Поиск`,"common.sender":`Отправитель`,"common.status":`Статус`,"common.survived":`Уцелело`,"common.time":`Время`,"common.type":`Тип`,"common.updatedAt":`Обновлено`,"common.username":`Имя пользователя`,"common.valid":`Действителен`,"common.verified":`Подтверждён`,"common.views":`Просмотры`,"common.yes":`Да`,"route.accounts":`Аккаунты`,"route.accountsSubtitle":`Консоль / Аккаунты`,"route.channels":`Супергруппы и каналы`,"route.channelsSubtitle":`Консоль / Каналы`,"route.moderation":`Жалобы и модерация`,"route.moderationSubtitle":`Консоль / Модерация`,"route.dashboard":`Панель управления`,"route.dashboardSubtitle":`Консоль / Обзор`,"route.messages":`Аудит сообщений`,"route.messagesSubtitle":`Консоль / Сообщения`,"route.gifts":`Звёздные подарки`,"route.giftsSubtitle":`Консоль / Звёздные подарки`,"route.auctions":`Аукционы и дропы`,"route.auctionsSubtitle":`Консоль / Аукционы и дропы`,"route.premium":`Звёзды и Premium`,"route.premiumSubtitle":`Консоль / Монетизация`,"route.giveGifts":`Выдача подарков`,"route.giveGiftsSubtitle":`Консоль / Выдача подарков`,"layout.navigation":`Навигация`,"layout.primaryNav":`Основное меню`,"layout.dashboard":`Обзор`,"layout.accounts":`Аккаунты`,"layout.channels":`Супергруппы / Каналы`,"layout.moderation":`Жалобы / Модерация`,"layout.messages":`Сообщения`,"layout.gifts":`Звёздные подарки`,"layout.auctions":`Аукционы и дропы`,"layout.premium":`Звёзды и Premium`,"layout.giveGifts":`Выдача подарков`,"layout.privateMessages":`Личные`,"layout.groupMessages":`Группы`,"layout.runtime":`Среда выполнения`,"layout.adminBackend":`Админ-бэкенд`,"layout.ready":`Готов`,"layout.pgRead":`Чтение из PG`,"layout.readOnly":`Только чтение`,"layout.writeOps":`Операции записи`,"layout.dryRun":`Тестовый запуск`,"layout.actor":`Вы вошли как: {actor}`,"layout.logout":`Выйти`,"language.en":`EN`,"language.zh":`中文`,"language.ru":`RU`,"theme.switchToDark":`Тёмная тема`,"theme.switchToLight":`Светлая тема`,"login.heading":`Панель администратора`,"login.body":`Введите учётные данные для входа в консоль.`,"login.secret":`Пароль или токен администратора`,"login.submit":`Войти`,"login.submitting":`Вход…`,"dashboard.eyebrow":`Состояние системы`,"dashboard.title":`Обзор консоли`,"dashboard.readPath":`Путь чтения`,"dashboard.readPathValue":`PG только для чтения`,"dashboard.writePath":`Путь записи`,"dashboard.executionPolicy":`Политика выполнения`,"dashboard.dryRunFirst":`Сначала тестовый запуск`,"dashboard.accountsText":`Статус аккаунтов, Premium, подтверждение, сессии.`,"dashboard.channelsText":`Публичные каналы и группы, число участников, статус подтверждения.`,"dashboard.messagesText":`Ящики сообщений, обновления, состояние исходящих.`,"dashboard.strip.dryRun":`Все опасные действия начинаются с тестового запуска`,"dashboard.strip.token":`Браузер никогда не сохраняет внутренние токены`,"dashboard.strip.pagination":`Списки используют курсорную пагинацию`,"dashboard.strip.snapshot":`Детальные страницы сохраняют моментальные снимки исходного состояния`,"account.pageTitle":`Аккаунты`,"account.queryResults":`Результаты поиска`,"account.recentActive":`Недавно активные аккаунты`,"account.currentPage":`Аккаунты на странице`,"account.onlineDevices":`Активные сессии устройств`,"account.premium":`Premium`,"account.frozen":`Заморожен`,"account.searchPlaceholder":`ID пользователя / телефон / имя пользователя`,"account.userID":`ID пользователя`,"account.phone":`Телефон`,"account.lastActive":`Последняя активность`,"account.notVerified":`Не подтверждён`,"account.notPremium":`Без Premium`,"account.premiumUntil":`Premium истекает`,"account.starsBalance":`Баланс Звёзд`,"account.startingGrantApplied":`стартовый бонус начислен`,"account.startingGrantPending":`ожидает стартового бонуса`,"account.activeSessions":`Авторизованные устройства`,"account.accountFlags":`Флаги аккаунта`,"account.restriction":`Ограничение`,"account.restricted":`Ограничен`,"account.createdAt":`Создан`,"account.detailTitle":`Аккаунт #{id}`,"account.profile":`Профиль аккаунта`,"account.loadingDetail":`Загрузка данных аккаунта`,"account.waitingData":`Ожидание данных`,"account.noUsername":`Нет имени пользователя`,"account.noPhone":`Нет телефона`,"account.accountFrozen":`Аккаунт заморожен`,"account.accountActive":`Аккаунт активен`,"account.authorizationsTitle":`Авторизованные устройства`,"account.authorizationsCount":`Авторизаций: {count}`,"account.recentAdminOps":`Последние действия администратора`,"account.recent30Audit":`Последние 30 записей аудита`,"account.actionDock":`Действия с аккаунтом`,"account.freezeAccount":`Заморозить аккаунт`,"account.updateFreeze":`Обновить параметры заморозки`,"account.unfreezeAccount":`Разморозить аккаунт`,"account.freezeSince":`Заморожен с`,"account.freezeUntil":`Срок подачи апелляции`,"account.freezeUntilAria":`Срок подачи апелляции на заморозку`,"account.freezeAppealURL":`URL для апелляции`,"account.freezeAppealURLAria":`URL для апелляции на заморозку`,"account.premiumMonths":`Срок действия Premium (в месяцах)`,"account.premiumMonthsAria":`Указать срок действия Premium в месяцах`,"account.setPremium":`Выдать Premium`,"account.clearPremium":`Снять Premium`,"premium.title":`Звёзды и Premium`,"premium.eyebrow":`Единая монетизация`,"premium.starsChannel":`Покупка Звёзд`,"premium.starsChannelHint":`Нативное пополнение себе и покупка для других пользователей проводятся через единый журнал Звёзд.`,"premium.premiumChannel":`Покупка Premium`,"premium.premiumChannelHint":`Нативное меню и @premiumbot используют общие тарифы, цены, формы оплаты и права Premium.`,"premium.sharedLedger":`Единый журнал`,"premium.sharedCatalog":`Единый каталог`,"premium.operations":`Ручные операции с аккаунтом`,"premium.operationsHint":`Начисляйте Звёзды или Premium в одной вкладке. Каждая запись требует подтверждения и попадает в аудит.`,"premium.userID":`ID пользователя`,"premium.userIDPlaceholder":`например, 1000000001`,"premium.starsAmount":`Количество Звёзд`,"premium.grantStars":`Начислить Звёзды`,"premium.grantPremium":`Выдать Premium`,"premium.catalog":`Каталог Premium`,"premium.catalogHint":`Управляйте ценами в Stars, длительностью, доступностью, названиями и порядком тарифов. Уже созданные формы оплаты сохраняют исходный снимок и отклоняются после изменения цены.`,"premium.catalogSummary":`Включено: {enabled} · Всего: {total}`,"premium.addPlan":`Добавить тариф`,"premium.newPlan":`Новый тариф Premium`,"premium.newPlanHint":`Количество месяцев является постоянным ключом тарифа и не меняется после создания.`,"premium.defaultPlanLabel":`1 месяц`,"premium.planTerm":`{months} мес. · {days} дн.`,"premium.planDetails":`Параметры тарифа`,"premium.pricing":`Стоимость`,"premium.fiatPrice":`Фиатная цена`,"premium.fiatCurrency":`Валюта (ISO 4217)`,"premium.fiatAmount":`Сумма (мин. единицы)`,"premium.storeSettings":`Магазины приложений`,"premium.storeProduct":`Продукт магазина`,"premium.storeProductPlaceholder":`например, telegram_premium_3m`,"premium.storeQuantity":`Количество продукта`,"premium.storeSettingsHint":`Если нативный продукт магазина не используется, оставьте его пустым, а количество — равным 0.`,"premium.editPlan":`Настроить`,"premium.months":`Месяцы`,"premium.durationDays":`Длительность, дней`,"premium.amountStars":`Цена`,"premium.label":`Название`,"premium.sortOrder":`Порядок`,"premium.source":`Источник`,"premium.sourceAdmin":`Веб-админка`,"premium.sourceConfig":`Переменные среды`,"premium.version":`Версия`,"premium.save":`Сохранить`,"premium.create":`Создать тариф`,"account.starsAmount":`Количество Звёзд`,"account.starsAmountAria":`Указать количество начисляемых Звёзд`,"account.grantStars":`Начислить Звёзды`,"account.setVerified":`Подтвердить аккаунт`,"account.clearVerified":`Снять подтверждение`,"channel.pageTitle":`Супергруппы и каналы`,"channel.recentUpdated":`Недавно обновлённые`,"channel.currentPage":`Объекты на странице`,"channel.megagroups":`Супергруппы`,"channel.broadcasts":`Каналы`,"channel.verifiedCount":`Подтверждённые`,"channel.searchPlaceholder":`ID канала / имя пользователя / название`,"channel.channelID":`ID канала`,"channel.kind":`Тип`,"channel.title":`Название`,"channel.pts":`PTS`,"channel.detailProfile":`Профиль канала`,"channel.loadingDetail":`Загрузка данных канала`,"channel.creator":`Создатель: {id}`,"channel.governance":`Модерация`,"channel.governanceValue":`Заблокировано {banned} / Исключено {kicked}`,"channel.flags":`Флаги канала`,"channel.rawRow":`Исходная строка БД`,"channel.rawRowText":`Снимок базы данных только для чтения`,"channel.actionDock":`Действия с каналом`,"channel.setVerified":`Подтвердить канал`,"channel.clearVerified":`Снять подтверждение`,"channel.kind.broadcast":`Канал`,"channel.kind.forum":`Супергруппа / Форум`,"channel.kind.megagroup":`Супергруппа`,"channel.kind.generic":`Канал / Группа`,"route.bots":`Боты`,"route.botsSubtitle":`Консоль / Боты`,"layout.bots":`Боты`,"route.broadcasts":`Рассылки`,"route.broadcastsSubtitle":`Консоль / Рассылки`,"layout.broadcasts":`Рассылки`,"broadcast.pageTitle":`Системные рассылки`,"broadcast.pageHint":`Надёжные сообщения от официального аккаунта 777000`,"broadcast.createTitle":`Создать системную рассылку`,"broadcast.createHint":`Проверка валидирует получателей; после подтверждения сообщения доставляются фоновыми ограниченными пакетами.`,"broadcast.create":`Создать рассылку`,"broadcast.message":`Сообщение`,"broadcast.messagePlaceholder":`Сообщение в диалоге с 777000`,"broadcast.messageBytes":`{count} / 4096 байт`,"broadcast.target":`Получатели`,"broadcast.targetAll":`Все аккаунты`,"broadcast.targetSelected":`Выбранные аккаунты`,"broadcast.userIDs":`ID пользователей`,"broadcast.userIDsHint":`Через запятую или пробел; 1–200 уникальных активных аккаунтов.`,"broadcast.currentPage":`Рассылок на странице`,"broadcast.active":`Активные`,"broadcast.failed":`Ошибки`,"broadcast.id":`ID рассылки`,"broadcast.progress":`Доставлено / цель`,"broadcast.status":`Статус`,"broadcast.createdBy":`Автор`,"broadcast.enumerating":`Сбор получателей`,"broadcast.delivering":`Доставка`,"broadcast.completed":`Завершено`,"bots.pageTitle":`Боты`,"bots.queryResults":`Результаты поиска`,"bots.recent":`Недавно созданные боты`,"bots.currentPage":`Боты на странице`,"bots.banned":`Забанен`,"bots.active":`Активен`,"bots.createTitle":`Создать системного бота`,"bots.createHint":`Создаёт бота, принадлежащего указанному пользователю. Токен показывается один раз после подтверждения.`,"bots.ownerUserID":`ID владельца`,"bots.name":`Отображаемое имя`,"bots.namePlaceholder":`например, Service Bot`,"bots.username":`Имя пользователя`,"bots.usernameHint":`Имя пользователя: 5–32 символа, обязательно оканчивается на «bot».`,"bots.tokenResultHint":`После подтверждения не закрывайте результат и скопируйте одноразовый токен до закрытия любого окна.`,"bots.create":`Создать бота`,"bots.searchPlaceholder":`ID бота / имя пользователя`,"bots.botID":`ID бота`,"bots.owner":`Владелец`,"bots.status":`Статус`,"bots.detailTitle":`Бот #{id}`,"bots.profile":`Профиль бота`,"bots.loadingDetail":`Загрузка данных бота`,"bots.unnamed":`Без имени`,"bots.restriction":`Ограничение`,"bots.actionDock":`Действия с ботом`,"bots.banUntil":`Забанить до`,"bots.ban":`Забанить бота`,"bots.updateBan":`Обновить бан`,"bots.unban":`Разбанить бота`,"bots.type":`Тип`,"bots.system":`Системный`,"bots.user":`Пользовательский`,"bots.delete":`Удалить бота`,"bots.deleteHint":`Безвозвратно удаляет созданного пользователем бота и аннулирует его токен. Действие необратимо.`,"bots.systemHint":`Системные боты встроены и не могут быть удалены.`,"flags.scam":`SCAM`,"flags.fake":`FAKE`,"flags.setScam":`Пометить как SCAM`,"flags.clearScam":`Снять метку SCAM`,"flags.setFake":`Пометить как FAKE`,"flags.clearFake":`Снять метку FAKE`,"attr.attributes":`Атрибуты`,"attr.settings":`Настройки`,"attr.username":`Имя пользователя`,"attr.setUsername":`Задать имя пользователя`,"attr.setSupport":`Пометить как support`,"attr.clearSupport":`Снять support`,"attr.forProfile":`Цвет профиля`,"attr.hasColor":`Включить цвет`,"attr.colorIndex":`Индекс цвета`,"attr.bgEmojiID":`ID фонового эмодзи`,"attr.setColor":`Задать цвет`,"attr.emojiDocID":`ID документа эмодзи`,"attr.emojiUntil":`До (unix, 0 = бессрочно)`,"attr.setEmojiStatus":`Задать emoji-статус`,"attr.gigagroup":`Гигагруппа`,"attr.antispam":`Агрессивный антиспам`,"attr.participantsHidden":`Скрыть участников`,"attr.noforwards":`Запретить пересылку`,"attr.joinToSend":`Вступление для отправки`,"attr.joinRequest":`Вступление по заявке`,"attr.slowmode":`Медленный режим (сек)`,"attr.applySettings":`Применить настройки`,"route.stickers":`Стикеры`,"route.stickersSubtitle":`Консоль / Стикеры`,"layout.stickers":`Стикеры`,"route.emoji":`Emoji`,"route.emojiSubtitle":`Консоль / Emoji`,"layout.emoji":`Emoji`,"route.gifCatalog":`Каталог GIF`,"route.gifCatalogSubtitle":`Консоль / Каталог GIF`,"layout.gifCatalog":`GIF`,"emoji.pageTitle":`Кастом-эмодзи`,"emoji.queryResults":`Результаты поиска`,"emoji.recent":`Каталог кастом-эмодзи`,"emoji.currentPage":`Эмодзи на странице`,"emoji.searchPlaceholder":`ID документа или эмодзи`,"emoji.copyID":`Скопировать ID документа`,"emoji.noSet":`Без набора`,"emoji.hint":`ID документов отсюда можно вставлять в поле Emoji-статуса в профилях аккаунтов, ботов и каналов.`,"emoji.packCatalog":`Наборы эмодзи`,"emoji.documentCatalog":`Все документы`,"messages.privateTitle":`Личные сообщения`,"messages.privateEyebrow":`Личные ящики сообщений`,"messages.groupTitle":`Групповые сообщения`,"messages.groupEyebrow":`Сообщения супергрупп и каналов`,"messages.selectPrivatePeers":`Сначала найдите и выберите владельца и собеседника`,"messages.selectChannel":`Сначала найдите и выберите супергруппу или канал`,"messages.ownerUser":`Пользователь-владелец`,"messages.peerUser":`Собеседник`,"messages.beforeDatePlaceholder":`курсор before_date`,"messages.beforeIDPlaceholder":`курсор before_msg_id`,"messages.limitPlaceholder":`лимит <= 100`,"messages.searchMessages":`Поиск сообщений`,"messages.nextPage":`Следующая страница`,"messages.previousPage":`Предыдущая страница`,"messages.currentPage":`Сообщения на странице`,"messages.deleted":`Удалено`,"messages.outgoing":`Исходящее`,"messages.incoming":`Входящее`,"messages.ownerPeer":`Владелец / Собеседник`,"messages.deleteSelected":`Удалить выбранные сообщения`,"messages.idsPlaceholder":`ID сообщений через запятую`,"messages.revoke":`Удалить для обеих сторон`,"messages.previewDelete":`Тестовое удаление`,"messages.clearHistory":`Очистить историю личной переписки`,"messages.maxIDPlaceholder":`граница max_id`,"messages.maxBatchesPlaceholder":`max_batches`,"messages.justClear":`Очистить только у себя`,"messages.previewClearHistory":`Тестовая очистка истории`,"messages.direction":`Направление`,"messages.body":`Текст сообщения`,"messages.privateDetailTitle":`Сообщение #{id}`,"messages.detailEyebrow":`Детали сообщения`,"messages.backPrivate":`Назад к личным сообщениям`,"messages.backGroup":`Назад к групповым сообщениям`,"messages.ownerPeerTitle":`Владелец {owner} · Собеседник {peer}`,"messages.senderSubtitle":`Отправитель {sender} · {date}`,"messages.boxID":`ID ящика сообщений`,"messages.privateMessageID":`ID личного сообщения`,"messages.messageSender":`Отправитель сообщения`,"messages.messageBox":`Ящик сообщений`,"messages.dialogRow":`Строка диалога`,"messages.privateRow":`Строка личного сообщения`,"messages.channelMessageRow":`Строка сообщения канала`,"messages.channelRow":`Строка канала`,"messages.userUpdateEvents":`События обновления пользователей`,"messages.channelUpdateEvents":`События обновления каналов`,"messages.eventJson":`JSON события`,"messages.dispatchOutbox":`Очередь отправки (Outbox)`,"messages.messageBoxesSnapshot":`Снимок message_boxes только для чтения`,"messages.dialogSnapshot":`Снимок dialogs только для чтения`,"messages.privateSnapshot":`Снимок private_messages только для чтения`,"messages.channelMessagesSnapshot":`Снимок channel_messages только для чтения`,"messages.channelSnapshot":`Снимок channels только для чтения`,"messages.userEventsSource":`постоянные user_update_events`,"messages.channelEventsSource":`постоянные channel_update_events`,"messages.outboxSource":`онлайн/офлайн dispatch_outbox`,"messages.attempts":`Попытки`,"messages.deleteThis":`Удалить это сообщение`,"messages.groupDetailTitle":`Групповое сообщение #{id}`,"messages.channelGroupTitle":`Канал / Группа {id}`,"messages.mediaCount":`С медиафайлами`,"messages.channelPosts":`Посты канала`,"messages.channelGroup":`Канал / Группа`,"messages.pinned":`Закреплено`,"messages.channelPost":`Пост в канале`,"gifts.pageTitle":`Каталог звёздных подарков`,"giveGift.action":`Выдать`,"giveGift.eyebrow":`Выдача подарка · без списания`,"giveGift.title":`Выдать подарок`,"giveGift.recipientKind":`Тип получателя`,"giveGift.recipientUser":`Пользователь`,"giveGift.recipientChannel":`Канал`,"giveGift.pickUser":`Получатель (пользователь)`,"giveGift.pickChannel":`Получатель (канал)`,"giveGift.recipientRequired":`Сначала выберите получателя`,"giveGift.sender":`ID аккаунта-отправителя`,"giveGift.senderHint":`Подарки всегда отправляются от системного аккаунта 777000 (Telesrv).`,"giveGift.message":`Сообщение к подарку (необязательно)`,"giveGift.messagePlaceholder":`Показывается вместе с подарком`,"giveGift.hideName":`Скрыть имя отправителя от получателя`,"giveGift.upgrade":`Выдать как улучшенный коллекционный`,"giveGift.upgradeNote":`Подарок будет отчеканен как уникальный коллекционный. Ниже можно выбрать конкретные атрибуты или оставить «Случайно» для выбора из опубликованного пула. Номер присваивается автоматически. Требуется опубликованное коллекционное улучшение с остатком тиража.`,"giveGift.model":`Модель`,"giveGift.pattern":`Узор`,"giveGift.backdrop":`Фон`,"giveGift.random":`Случайно`,"giveGift.confirm":`Выдать подарок`,"giveGifts.pageTitle":`Выдача подарков`,"giveGifts.eyebrow":`Выдача каталожных подарков любому пользователю или каналу`,"giveGifts.available":`Доступно подарков`,"giveGifts.sender":`Отправитель по умолчанию`,"giveGifts.searchPlaceholder":`Поиск по названию или ID подарка`,"giveGifts.hint":`Выберите подарок для выдачи. Выдача бесплатна и по умолчанию отправляется от системного аккаунта 777000 (Telesrv).`,"giveGifts.pickGift":`Выберите подарок`,"giveGifts.selectPrompt":`Выберите подарок из списка, чтобы начать.`,"gifts.eyebrow":`Каталог, неизменяемые версии и файлы анимаций`,"gifts.total":`Подарков в каталоге`,"gifts.enabled":`Включено`,"gifts.received":`Полученные подарки`,"gifts.formats":`Поддерживаемые форматы`,"gifts.add":`Добавить подарок`,"gifts.searchPlaceholder":`Поиск по ID подарка, названию или формату`,"gifts.listSummary":`Показано {shown} из {total}`,"gifts.idRevision":`ID / Версия`,"gifts.price":`Цена / Конвертация`,"gifts.importTitle":`Импорт звёздного подарка`,"gifts.importEyebrow":`Управление каталогом подарков`,"gifts.newRevision":`Создать версию для подарка #{id}`,"gifts.importHint":`Загрузите файл TGS или обычный Lottie JSON. Lottie нормализуется и сжимается в формат TGS.`,"gifts.officialSource":`Официальный снимок`,"gifts.fileSource":`Загрузить файл`,"gifts.officialHint":`Выберите проверенный подарок из data/official-gifts. Полные пулы коллекционных предметов импортируются атомарно.`,"gifts.officialSearch":`Поиск по ID или названию официального подарка`,"gifts.officialSelect":`Выберите официальный подарок`,"gifts.officialRequired":`Сначала выберите официальный подарок`,"gifts.officialResults":`Показано {shown} из {total}`,"gifts.officialCategoryLabel":`Категория возможностей официального подарка`,"gifts.officialCategory.all":`Все`,"gifts.officialCategory.upgrade":`Можно улучшить`,"gifts.officialCategory.craft":`Можно создать`,"gifts.officialCategory.basic":`Нельзя улучшить`,"gifts.officialUnnamed":`Официальный подарок без названия #{id}`,"gifts.officialAttributes":`Атрибутов: {count}`,"gifts.canUpgrade":`Можно улучшить`,"gifts.cannotUpgrade":`Нельзя улучшить`,"gifts.canCraft":`Можно создать`,"gifts.cannotCraft":`Нельзя создать`,"gifts.officialEmpty":`Нет подарков, соответствующих категории и поиску.`,"gifts.includeCollectible":`Импортировать полный пул коллекционных предметов, включая созданные модели`,"gifts.animation":`Файл анимации`,"gifts.filePrompt":`Перетащите или выберите файл TGS / Lottie`,"gifts.fileHint":`TGS, JSON или Lottie · файл проверяется перед импортом`,"gifts.chooseFile":`Выбрать файл`,"gifts.changeFile":`Изменить файл`,"gifts.title":`Отображаемое название`,"gifts.titlePlaceholder":`например, Праздничная звезда`,"gifts.stars":`Цена в Звёздах`,"gifts.convertStars":`Звёзд при конвертации`,"gifts.sortOrder":`Порядок сортировки`,"gifts.lifecycle.label":`Режим подарка`,"gifts.lifecycle.regular":`Обычный`,"gifts.lifecycle.auction":`Аукцион`,"gifts.lifecycle.drop":`Отложенный дроп`,"gifts.lifecycle.hintRegular":`Доступен для покупки сразу после включения подарка.`,"gifts.lifecycle.hintAuction":`Тираж распродаётся раундами по таймеру, побеждают наибольшие ставки. Цена в Звёздах — это минимальная ставка.`,"gifts.lifecycle.hintDrop":`Подарок остаётся заблокированным с обратным отсчётом и автоматически становится доступным в указанное время.`,"gifts.lifecycle.auctionFileOnly":`Аукциону нужна своя анимация, поэтому он создаётся на вкладке загрузки. Подарок из снимка всё равно можно отложить.`,"gifts.drop.schedule":`Отложить выпуск`,"gifts.auction.slug":`Слаг аукциона`,"gifts.auction.slugPlaceholder":`summer-auction`,"gifts.auction.supply":`Всего подарков`,"gifts.auction.perRound":`Подарков за раунд`,"gifts.auction.roundDuration":`Длительность раунда (сек)`,"gifts.auction.startAt":`Время старта (пусто — начать сразу)`,"gifts.auction.plan":`Раундов: {rounds} × {perRound} подарк(ов), по {minutes} мин`,"gifts.drop.unlockAt":`Время разблокировки`,"gifts.lifecycle.slugRequired":`Для аукциона нужен слаг.`,"gifts.lifecycle.supplyRequired":`Всего подарков должно быть больше 0.`,"gifts.lifecycle.perRoundRequired":`Подарков за раунд должно быть больше 0.`,"gifts.lifecycle.perRoundTooLarge":`Подарков за раунд не может быть больше общего тиража.`,"gifts.lifecycle.roundDurationRequired":`Длительность раунда должна быть больше 0.`,"gifts.lifecycle.startPast":`Время старта аукциона уже прошло.`,"gifts.lifecycle.unlockRequired":`Выберите время разблокировки в будущем.`,"auctions.title":`Аукционы и отложенные дропы`,"auctions.eyebrow":`Управление жизненным циклом подарков`,"auctions.newAuction":`Новый аукцион`,"auctions.newDrop":`Новый отложенный дроп`,"auctions.metricAuctions":`Аукционы`,"auctions.metricLive":`Идут сейчас`,"auctions.metricDrops":`Отложенные дропы`,"auctions.metricPendingDrops":`Ждут разблокировки`,"auctions.metricCommitted":`Звёзд в активных ставках`,"auctions.colGift":`Подарок`,"auctions.colKind":`Тип`,"auctions.colRounds":`Раунд`,"auctions.colSupply":`Остаток`,"auctions.colMinBid":`Мин. ставка`,"auctions.colBids":`Ставки`,"auctions.colNext":`Следующий срок`,"auctions.kindAuction":`Аукцион`,"auctions.kindDrop":`Отложенный дроп`,"auctions.untitled":`Без названия`,"auctions.unlimited":`Без лимита`,"auctions.roundOf":`{current} из {total}`,"auctions.perRoundShort":`{minutes} мин на раунд`,"auctions.awarded":`выдано {count}`,"auctions.bidCount":`{count} активных`,"auctions.topBid":`макс. {amount}`,"auctions.minBid":`Минимальная ставка в звёздах`,"auctions.enableHint":`Включить сразу (пока подарок выключен, аукцион и дроп не видны клиентам)`,"auctions.lazyNote":`Движок создаёт состояние аукциона при первом запросе клиента или проходе планировщика, поэтому новый аукцион до этого момента показывает «ожидает движок».`,"auctions.now":`уже пора`,"auctions.durationDH":`{days} д {hours} ч`,"auctions.durationHM":`{hours} ч {minutes} мин`,"auctions.durationMS":`{minutes} мин {seconds} с`,"auctions.durationS":`{seconds} с`,"auctions.status.pending":`Ожидает старта`,"auctions.status.awaiting":`Ожидает движок`,"auctions.status.active":`Идёт`,"auctions.status.completed":`Завершён`,"auctions.status.cancelled":`Отменён`,"auctions.status.scheduled":`Заблокирован`,"auctions.status.released":`Разблокирован`,"gifts.reason":`Причина для аудита`,"gifts.reasonPlaceholder":`Кратко опишите причину импорта этого подарка`,"gifts.enableAfterImport":`Включить после импорта`,"gifts.validate":`Тестовая проверка`,"gifts.confirmImport":`Подтвердить импорт`,"gifts.stepDetails":`Файл и описание`,"gifts.stepValidate":`Тестовая проверка`,"gifts.stepImport":`Подтверждение импорта`,"gifts.fileRequired":`Сначала выберите файл TGS или Lottie`,"gifts.source":`Источник`,"gifts.replace":`Новая версия`,"gifts.disable":`Отключить`,"gifts.enable":`Включить`,"gifts.empty":`Звёздные подарки ещё не импортированы.`,"gifts.emptyHint":`Импортируйте первую анимацию, чтобы начать наполнение каталога.`,"gifts.validationReady":`Проверка пройдена`,"gifts.validationHint":`Проверьте нормализованные метаданные и подтвердите импорт.`,"gifts.confirmState":`Применить проверенные изменения состояния к подарку #{id}?`,"collectibles.manage":`Пул атрибутов`,"collectibles.title":`Пул коллекционных предметов · Подарок #{id}`,"collectibles.eyebrow":`Уникальные атрибуты подарка`,"collectibles.activeRevision":`Опубликованная версия {revision}`,"collectibles.published":`Опубликовано`,"collectibles.noPool":`Нет опубликованного пула коллекционных предметов`,"collectibles.noPoolHint":`Опубликуйте модели, узоры и фоны для активации улучшений.`,"collectibles.publishNew":`Опубликовать новую неизменяемую версию`,"collectibles.immutableHint":`Тестовый запуск проверяет каждый файл и структуру набора атрибутов перед активацией версии.`,"collectibles.upgradeStars":`Цена улучшения в Звёздах`,"collectibles.supply":`Уникальный тираж`,"collectibles.slug":`Публичный префикс ссылки (slug)`,"collectibles.models":`Модели`,"collectibles.patterns":`Узоры`,"collectibles.backdrops":`Фоны`,"collectibles.model":`Модель`,"collectibles.pattern":`Узор`,"collectibles.backdrop":`Фон`,"collectibles.rarity":`Редкость ‰`,"collectibles.rarityHint":`В каждой категории должно быть не менее двух атрибутов. Значения в промилле задают относительные веса обычного улучшения; при добавлении или удалении они перераспределяются до суммы 1000.`,"collectibles.minimumAttributes":`Модели, узоры и фоны должны содержать не менее двух атрибутов в каждой категории.`,"collectibles.duplicateBackdropID":`ID фонов в одном наборе должны быть уникальными.`,"collectibles.colorHint":`Цвета сохраняются как 24-битные RGB-значения.`,"collectibles.addAttribute":`Добавить`,"collectibles.remove":`Удалить атрибут`,"collectibles.fileRequired":`Для каждой модели и узора требуется файл TGS или Lottie.`,"collectibles.backdropID":`ID фона`,"collectibles.color.center":`Центр`,"collectibles.color.edge":`Край`,"collectibles.color.pattern":`Узор`,"collectibles.color.text":`Текст`,"collectibles.validationReady":`Пул атрибутов корректен`,"collectibles.validationHint":`Проверьте нормализованные ресурсы и опубликуйте эту неизменяемую версию.`,"collectibles.publish":`Опубликовать версию`,"moderation.casesEyebrow":`Модерация / Обращения`,"moderation.currentQueue":`Текущая очередь`,"moderation.criticalCases":`Критические обращения`,"moderation.pendingOrFailed":`Ожидающие / неудачные действия`,"moderation.statusFilter":`Фильтр по статусу обращения`,"moderation.statusFilter.active":`Активная очередь`,"moderation.statusFilter.all":`Все статусы`,"moderation.assignee":`Модератор`,"moderation.allAssignees":`Оставьте пустым для всех`,"moderation.case":`Обращение`,"moderation.target":`Объект`,"moderation.severity":`Важность`,"moderation.reportsAndReporters":`Жалобы / Авторы`,"moderation.latestReport":`Последняя жалоба`,"moderation.review":`Проверить`,"moderation.status.open":`Открыто`,"moderation.status.in_review":`На рассмотрении`,"moderation.status.action_pending":`Ожидает действия`,"moderation.status.action_failed":`Ошибка действия`,"moderation.status.resolved":`Решено`,"moderation.status.dismissed":`Отклонено`,"moderation.status.appeal_review":`Рассмотрение апелляции`,"moderation.severity.low":`Низкая`,"moderation.severity.medium":`Средняя`,"moderation.severity.high":`Высокая`,"moderation.severity.critical":`Критическая`,"moderation.targetType.user":`Аккаунт`,"moderation.targetType.chat":`Группа`,"moderation.targetType.channel":`Канал`,"moderation.caseDetailTitle":`Проверка обращения #{id}`,"moderation.caseDetailEyebrow":`Модерация / Детали обращения`,"moderation.backToQueue":`Назад к очереди`,"moderation.loadingCase":`Загрузка обращения…`,"moderation.versionAndUpdated":`Версия {version} · Обновлено {time}`,"moderation.reportCount":`Жалобы`,"moderation.reportCountValue":`Жалоб: {reports}; авторов: {reporters}`,"moderation.firstAndLatestReport":`Первая / последняя жалоба`,"moderation.evidence":`Материалы жалобы`,"moderation.evidenceHint":`Показываются последние 100 жалоб; снимки фиксируются при приёме жалобы.`,"moderation.sourceAndReason":`Источник / Причина`,"moderation.reporter":`Автор жалобы`,"moderation.option":`Вариант`,"moderation.decisionAudit":`Аудит решений и действий`,"moderation.decisionAuditHint":`Действия выполняются идемпотентно арендующим worker-процессом; при сбое сохраняются ошибка и число попыток.`,"moderation.appeals":`Апелляции`,"moderation.caseActions":`Действия с обращением`,"moderation.renewClaim":`Продлить назначение`,"moderation.claimCase":`Взять на проверку`,"moderation.reviewReason":`Причина решения`,"moderation.decisionPreset":`Шаблон решения`,"moderation.preset.noViolation":`Нет нарушения (отклонить жалобу)`,"moderation.preset.scam":`Пометить как SCAM`,"moderation.preset.fake":`Пометить как FAKE`,"moderation.preset.freeze":`Заморозить аккаунт`,"moderation.preset.scamFreeze":`SCAM + заморозка`,"moderation.preset.fakeFreeze":`FAKE + заморозка`,"moderation.preset.deleteMessages":`Удалить сообщения из материалов`,"moderation.preset.deleteAccount":`Удалить аккаунт`,"moderation.evidenceMessageIDs":`ID сообщений из материалов (через запятую)`,"moderation.privateOwnerUserID":`owner_user_id личного чата`,"moderation.revokeForBoth":`Удалить у обеих сторон`,"moderation.evidenceValidationHint":`Сервер повторно проверит, что каждый ID сообщения присутствует в неизменяемых материалах этого обращения.`,"moderation.failedActionHint":`Действие выполнено частично, поэтому решение нельзя сразу сменить на отсутствие нарушения. Выберите новое действие для повтора; аудит предыдущего сбоя сохранится.`,"moderation.retryAction":`Повторить действие`,"moderation.submitDecision":`Отправить решение`,"moderation.appealReviewTitle":`Рассмотрение апелляции #{id}`,"moderation.automaticRemedy":`Автовосстановление после одобрения`,"moderation.irreversibleAppealHint":`Обращение содержит завершённое необратимое удаление. Его нельзя отметить как одобренное и восстановленное; отклоните или передайте на ручную обработку.`,"moderation.denyAppeal":`Отклонить апелляцию`,"moderation.grantAppeal":`Одобрить апелляцию`,"moderation.reasonRequired":`Укажите причину решения.`,"moderation.appealReasonRequired":`Укажите причину рассмотрения апелляции.`,"moderation.privateDeleteValidation":`Для удаления личных сообщений нужны корректные ID сообщений из материалов и owner_user_id автора жалобы.`,"moderation.channelDeleteValidation":`Для удаления сообщений канала нужен хотя бы один корректный ID сообщения из материалов.`,"moderation.confirmDecision":`Отправить решение «{decision}»? Действие будет выполнено через устойчивую очередь.`,"moderation.confirmGrantAppeal":`Одобрить эту апелляцию?`,"moderation.confirmDenyAppeal":`Отклонить эту апелляцию?`,"moderation.remedy.clearFlags":`Снять SCAM / FAKE`,"moderation.remedy.unfreeze":`Разморозить аккаунт`,"moderation.remedy.none":`Восстановление не требуется`,"moderation.source.account_peer":`Аккаунт / пир`,"moderation.source.profile_photo":`Фото профиля`,"moderation.source.messages_spam":`Спам в сообщениях`,"moderation.source.messages":`Сообщения`,"moderation.source.encrypted_spam":`Спам в секретном чате`,"moderation.source.reaction":`Реакция`,"moderation.source.channel_spam":`Спам в канале`,"moderation.source.story":`История`,"moderation.source.ephemeral":`Исчезающее медиа`,"moderation.source.sponsored":`Рекламное сообщение`,"moderation.source.antispam_false_positive":`Ложное срабатывание антиспама`,"moderation.reason.spam":`Спам`,"moderation.reason.violence":`Насилие`,"moderation.reason.pornography":`Порнография`,"moderation.reason.child_abuse":`Жестокое обращение с детьми`,"moderation.reason.other":`Другое`,"moderation.reason.copyright":`Авторские права`,"moderation.reason.geo_irrelevant":`Не относится к региону`,"moderation.reason.fake":`Подделка`,"moderation.reason.illegal_drugs":`Незаконные наркотики`,"moderation.reason.personal_details":`Персональные данные`,"messages.msgIDsInvalid":`Некорректные ID сообщений`,"auth.device":`Устройство`,"auth.platform":`Платформа`,"auth.ip":`IP-адрес`,"auth.lastActive":`Последняя активность`,"auth.revokeCurrent":`Отозвать текущую`,"auth.keepCurrent":`Оставить текущую`,"auth.revokeAll":`Отозвать все устройства`,"picker.userPlaceholder":`Поиск по user_id / телефону / имени пользователя`,"picker.channelPlaceholder":`Поиск по channel_id / имени пользователя / названию`,"picker.verified":`Подтверждённые`,"picker.regular":`Обычные`,"action.reasonRequired":`Пожалуйста, укажите причину операции`,"action.flow":`Процесс выполнения`,"action.close":`Закрыть`,"action.stepReason":`Укажите причину`,"action.stepDryRun":`Тестовый запуск`,"action.stepConfirm":`Подтверждение выполнения`,"action.reason":`Причина операции`,"action.reasonPlaceholder":`Опишите, почему выполняется эта операция`,"action.requestPreview":`Запросить предпросмотр`,"action.result":`Результат действия`,"action.commandID":`ID команды`,"action.status":`Статус`,"action.dryRun":`Тестовый запуск`,"action.runAgain":`Повторить тестовый запуск`,"action.runDry":`Сначала выполните тестовый запуск`,"action.confirm":`Подтвердить выполнение`,"audit.id":`ID`,"audit.commandID":`ID команды`,"audit.action":`Действие`,"audit.actor":`Исполнитель`,"audit.status":`Статус`,"audit.dryRun":`Тестовый запуск`,"audit.reason":`Причина`,"audit.time":`Время`,"common.loadMore":`Показать ещё`,"route.collectibleUsernames":`Коллекционные юзернеймы`,"route.collectibleUsernamesSubtitle":`Консоль / Коллекционные юзернеймы`,"route.accountRatings":`Рейтинг аккаунтов`,"route.accountRatingsSubtitle":`Консоль / Рейтинг аккаунтов`,"route.storage":`Хранилище`,"route.storageSubtitle":`Консоль / Хранилище`,"layout.storage":`Хранилище`,"storage.title":`Хранилище`,"storage.eyebrow":`Медиа / Инвентаризация только для чтения`,"storage.physical":`Физические байты`,"storage.logical":`Логические байты ссылок`,"storage.dedupSaved":`Сэкономлено дедупликацией`,"storage.objects":`Уникальные объекты`,"storage.references":`Ссылки на blob`,"storage.documents":`Документы`,"storage.photos":`Фотографии`,"storage.backend":`Бэкенд`,"storage.retentionBlocked":`Физическое удаление отключено до реализации и сверки полного графа ссылок на медиа.`,"layout.collectibleUsernames":`NFT-юзернеймы`,"route.collectiblePhones":`Анонимные номера`,"route.collectiblePhonesSubtitle":`Консоль / NFT-номера +888`,"layout.collectiblePhones":`NFT-номера`,"phones.pageTitle":`Анонимные номера +888`,"phones.eyebrow":`Номера Fragment / Реестр владения`,"phones.number":`Номер`,"phones.class":`Класс`,"phones.standard":`Обычный`,"phones.exclusive":`Эксклюзивный`,"phones.allClasses":`Все классы`,"phones.mintTitle":`Выпустить анонимный номер`,"phones.mintHint":`Обычный номер подчиняется приватности телефона; эксклюзивный всегда виден в профиле. Номер для входа не изменяется.`,"phones.ownerOptional":`Первый владелец (необязательно)`,"phones.mint":`Выпустить номер`,"phones.search":`Поиск номера +888`,"phones.manage":`Управление`,"phones.manageHint":`Передать, вернуть в хранилище или сжечь актив.`,"phones.fiatAmount":`Цена в валюте (USD)`,"phones.tonAmount":`Цена в TON`,"phones.pricePreview":`Telegram покажет: {crypto} ({fiat}). Вводите обычные суммы — панель сама переведёт USD в центы, а TON в нанотоны.`,"phones.priceInvalid":`Укажите положительную цену в USD (до 2 знаков) и TON (до 9 знаков после запятой).`,"phones.updatePrice":`Сохранить цену`,"layout.accountRatings":`Рейтинг аккаунтов`,"usernames.pageTitle":`Коллекционные юзернеймы`,"usernames.eyebrow":`NFT-юзернеймы / Реестр`,"usernames.metricLoaded":`Загружено строк`,"usernames.metricVault":`В хранилище`,"usernames.metricOwned":`У владельцев`,"usernames.metricBurned":`Сожжено`,"usernames.mintTitle":`Выпустить юзернейм`,"usernames.mintHint":`Создаёт актив вместе с записью о покупке. Оставьте «хранилище», чтобы выпустить юзернейм без владельца.`,"usernames.mint":`Выпустить юзернейм`,"usernames.mintNote":`Юзернейм, валюта и сумма обязательны — тестовый запуск сначала проверит, свободен ли юзернейм.`,"usernames.ownerKind":`Тип владельца`,"usernames.ownerVault":`Хранилище (без владельца)`,"usernames.ownerUser":`Владелец-пользователь`,"usernames.ownerChannel":`Владелец-канал`,"usernames.currency":`Валюта`,"usernames.amount":`Сумма ({currency})`,"usernames.cryptoCurrency":`Криптовалюта`,"usernames.cryptoNone":`Нет`,"usernames.cryptoAmount":`Сумма в крипте ({currency})`,"usernames.amountHint":`Сумма вводится в целых {currency}, а хранится в наименьших единицах, которые принимают API и fragment.collectibleInfo, — тогда клиент покажет именно ту цену, которую вы задали. До {decimals} знаков после запятой. Клиент покажет: {preview}.`,"usernames.amountInvalid":`Это не похоже на сумму в {currency}: только цифры и не больше {decimals} знаков после запятой.`,"usernames.inactive":`выключен`,"usernames.url":`Ссылка на маркетплейс`,"usernames.purchaseDate":`Дата покупки (UTC)`,"usernames.purchaseTime":`Время покупки (UTC)`,"usernames.searchPlaceholder":`Поиск по юзернейму`,"usernames.statusAll":`Все статусы`,"usernames.statusVault":`В хранилище`,"usernames.statusOwned":`У владельца`,"usernames.statusBurned":`Сожжён`,"usernames.price":`Цена`,"usernames.transfers":`Передачи`,"usernames.registryActive":`Активен в профиле`,"usernames.registryHidden":`Скрыт в профиле`,"usernames.loadingDetail":`Загружаем коллекционный юзернейм…`,"usernames.detailTitle":`Юзернейм {username}`,"usernames.detailEyebrow":`NFT-юзернеймы / Актив`,"usernames.assetID":`Актив №{id}`,"usernames.transferCount":`Передач: {count}`,"usernames.originalOwner":`Первый владелец`,"usernames.openOwnerAccount":`Открыть аккаунт владельца`,"usernames.openOwnerChannel":`Открыть канал владельца`,"usernames.openMarketplace":`Открыть страницу на маркетплейсе`,"usernames.transferTitle":`Передать юзернейм`,"usernames.transferHint":`Выберите получателя — передача попадёт в историю владения.`,"usernames.recipientKind":`Тип получателя`,"usernames.recipientUser":`Пользователю`,"usernames.recipientChannel":`Каналу`,"usernames.transferNote":`После подтверждения текущий владелец сразу теряет юзернейм.`,"usernames.transfer":`Передать`,"usernames.historyTitle":`История владения`,"usernames.historyHint":`Выпуск, передачи, отзывы и сжигание — в хронологическом порядке.`,"usernames.eventKind":`Событие`,"usernames.fromPeer":`От`,"usernames.toPeer":`Кому`,"usernames.actionDock":`Операции с активом`,"usernames.revoke":`Отозвать в хранилище`,"usernames.revokeHint":`Забирает юзернейм у владельца и возвращает в хранилище — позже его можно выдать снова.`,"usernames.burn":`Сжечь безвозвратно`,"usernames.burnHint":`Необратимо: юзернейм уничтожается и больше никогда не будет выдан.`,"usernames.burnedHint":`Юзернейм сожжён — операции с ним больше недоступны.`,"usernames.delete":`Удалить запись`,"usernames.deleteHint":`Стирает актив вместе с историей владения и полностью освобождает юзернейм для нового выпуска. Это для случая «выпустил не то имя»; сжигание, наоборот, историю сохраняет.`,"usernames.kind.mint":`Выпуск`,"usernames.kind.transfer":`Передача`,"usernames.kind.revoke":`Отзыв`,"usernames.kind.burn":`Сжигание`,"rating.pageTitle":`Рейтинг аккаунтов`,"rating.eyebrow":`Рейтинг / Лидерборд`,"rating.metricLoaded":`Загружено строк`,"rating.metricTopLevel":`Максимальный уровень`,"rating.metricAvgLevel":`Средний уровень`,"rating.metricPending":`С отложенными баллами`,"rating.searchPlaceholder":`Поиск по юзернейму, имени или ID`,"rating.minLevel":`Мин. уровень`,"rating.userID":`ID пользователя`,"rating.level":`Уровень`,"rating.stars":`Баллы`,"rating.progress":`Прогресс до следующего уровня`,"rating.pending":`Отложено`,"rating.computedAt":`Пересчитан`,"rating.levelValue":`Уровень {level}`,"rating.maxLevel":`Максимальный уровень`,"rating.progressHint":`До {target} осталось {remaining}`,"rating.loadingDetail":`Загружаем рейтинг аккаунта…`,"rating.detailTitle":`Рейтинг {user}`,"rating.detailEyebrow":`Рейтинг / Разбор по компонентам`,"rating.pendingBadge":`Отложено {amount}`,"rating.nextLevel":`Порог следующего уровня`,"rating.toNextLevel":`Баллов до следующего уровня`,"rating.breakdownTitle":`Из чего сложился рейтинг`,"rating.breakdownHint":`Вклад каждого источника: звёзды, активность, штрафы модерации и ручные корректировки.`,"rating.breakdownMismatch":`Сумма компонентов — {sum}, а сохранённый рейтинг — {total}. Запустите пересчёт, чтобы устранить расхождение.`,"rating.breakdownPending":`Компоненты уже учитывают {amount}, которые войдут в рейтинг только в указанную дату.`,"rating.currentLevelStars":`Порог текущего уровня`,"rating.nextLevelStars":`Порог следующего уровня`,"rating.pendingTitle":`Отложенные баллы`,"rating.pendingHint":`Баллы уже начислены, но войдут в рейтинг только в указанную дату.`,"rating.pendingDate":`Дата применения`,"rating.eventsTitle":`События рейтинга`,"rating.eventsHint":`Каждое изменение рейтинга с источником, исполнителем и причиной.`,"rating.eventKind":`Источник`,"rating.amount":`Изменение`,"rating.actionDock":`Операции с рейтингом`,"rating.openAccount":`Открыть аккаунт`,"rating.recompute":`Пересчитать`,"rating.recomputeHint":`Собирает рейтинг заново из звёзд, активности, штрафов и ручных корректировок.`,"rating.adjustTitle":`Ручная корректировка`,"rating.adjustAmount":`Значение (можно отрицательное)`,"rating.adjust":`Скорректировать`,"rating.adjustHint":`Значение прибавляется к ручной составляющей; отрицательное число уменьшает рейтинг.`,"rating.componentStars":`Звёзды`,"rating.componentStarsHint":`Купленные и полученные звёзды`,"rating.componentActivity":`Активность`,"rating.componentActivityHint":`Сообщения, сессии и долгосрочная вовлечённость`,"rating.componentPenalty":`Штрафы`,"rating.componentPenaltyHint":`Решения модерации и ограничения`,"rating.componentManual":`Ручные корректировки`,"rating.componentManualHint":`Правки, внесённые администраторами`,"rating.componentTotal":`Итоговый рейтинг`,"rating.kind.stars":`Звёзды`,"rating.kind.activity":`Активность`,"rating.kind.moderation":`Модерация`,"rating.kind.manual":`Вручную`,"rating.kind.recompute":`Пересчёт`,"route.verification":`Официальная верификация`,"route.verificationSubtitle":`Консоль / Верификация`,"layout.verification":`Верификация`,"permission.deniedTitle":`Недостаточно прав`,"permission.deniedEyebrow":`Консоль / Доступ`,"permission.deniedBody":`Этой сессии не выдано право {permission}, поэтому раздел закрыт.`,"permission.deniedHeading":`Раздел недоступен`,"permission.deniedHint":`Попросите добавить право в TELESRV_ADMIN_UI_PERMISSIONS и войдите заново.`,"verification.pageTitle":`Очередь заявок на верификацию`,"verification.eyebrow":`Верификация / Очередь`,"verification.searchPlaceholder":`ID заявки, ID цели, юзернейм или название`,"verification.statusAll":`Все статусы`,"verification.targetType":`Тип цели`,"verification.targetTypeAll":`Все типы`,"verification.reviewer":`Ревьюер`,"verification.reviewerPlaceholder":`Любой ревьюер`,"verification.target":`Цель`,"verification.applicant":`Заявитель`,"verification.category":`Категория`,"verification.submittedAt":`Подана`,"verification.alreadyVerified":`Бейдж уже стоит`,"verification.status.draft":`Черновик`,"verification.status.submitted":`Подана`,"verification.status.in_review":`На рассмотрении`,"verification.status.approved":`Одобрена`,"verification.status.rejected":`Отклонена`,"verification.status.cancelled":`Отменена`,"verification.type.bot":`Бот`,"verification.type.channel":`Канал`,"verification.type.supergroup":`Супергруппа`,"verification.type.user":`Пользователь`,"verification.loadingDetail":`Загружаем заявку…`,"verification.detailTitle":`Заявка №{id}`,"verification.detailEyebrow":`Верификация / Разбор заявки`,"verification.conflict":`Заявку уже изменил другой администратор. Данные перезагружены — проверьте статус и примите решение заново.`,"verification.controlsOk":`Права на цель подтверждены`,"verification.controlsLost":`Прав на цель больше нет`,"verification.controlsOkHint":`Заявитель управляет целью прямо сейчас — проверено по актуальным записям, а не по снимку на момент подачи.`,"verification.controlsLostHint":`Заявитель больше не управляет целью. Одобрить — значит выдать бейдж тому, кто уже не владеет пиром; обычно это причина отказать.`,"verification.targetSection":`Цель`,"verification.targetHint":`Пир, которому достанется бейдж, — в том виде, в каком он существует сейчас.`,"verification.openTarget":`Открыть цель`,"verification.targetTitle":`Название`,"verification.targetID":`ID пира`,"verification.applicantSection":`Заявитель`,"verification.applicantHint":`Кто подал заявку и сохранились ли у него права на цель.`,"verification.openApplicant":`Открыть аккаунт`,"verification.applicantID":`ID пользователя`,"verification.applicationSection":`Заявка`,"verification.applicationHint":`Всё, что заявитель прислал сам; выводится как обычный текст.`,"verification.correlationID":`ID корреляции`,"verification.createdAt":`Создана`,"verification.description":`Описание`,"verification.officialWebsite":`Официальный сайт`,"verification.socialLinks":`Соцсети`,"verification.pressLinks":`Публикации в СМИ`,"verification.additionalNote":`Комментарий заявителя`,"verification.notProvided":`Не указано`,"verification.linkSafetyHint":`Кликабельны только ссылки на http:// и https://, и открываются они в новой вкладке; всё остальное показано текстом.`,"verification.decisionSection":`Решение`,"verification.decisionHint":`Что решили, кто решил и с какой формулировкой.`,"verification.reviewedAt":`Решение принято`,"verification.version":`Версия (оптимистичная блокировка)`,"verification.decisionReason":`Причина решения`,"verification.noDecision":`Решения пока нет`,"verification.internalNote":`Внутренняя заметка`,"verification.adminOnly":`видно только администраторам`,"verification.eventsSection":`История`,"verification.eventsHint":`Неизменяемый след всех переходов статуса — с автором и причиной.`,"verification.eventKind":`Событие`,"verification.transition":`Было → стало`,"verification.eventNote":`Внутренняя заметка`,"verification.kind.created":`Создана`,"verification.kind.updated":`Изменена`,"verification.kind.submitted":`Подана`,"verification.kind.claimed":`Взята в работу`,"verification.kind.approved":`Одобрена`,"verification.kind.rejected":`Отклонена`,"verification.kind.cancelled":`Отменена`,"verification.kind.revoked":`Бейдж снят`,"verification.kind.notified":`Заявитель уведомлён`,"verification.actionDock":`Действия по заявке`,"verification.noActions":`В этом статусе действий нет.`,"verification.claim":`Взять в работу`,"verification.claimHint":`Закрепляет заявку за вами и переводит её в статус «на рассмотрении», чтобы двое не разбирали одно и то же.`,"verification.internalNotePlaceholder":`Заметка для других ревьюеров`,"verification.internalNoteHint":`Необязательно. Сохраняется вместе с решением и видно только администраторам — заявителю не уходит.`,"verification.alreadyVerifiedHint":`Бейдж на цели уже стоит: одобрение лишь зафиксирует решение.`,"verification.approve":`Одобрить`,"verification.approveHint":`Выдаёт цели официальный бейдж и закрывает заявку.`,"verification.reject":`Отклонить`,"verification.rejectHint":`Причина обязательна: именно эту формулировку увидит заявитель, поэтому напишите, чего не хватило.`,"verification.dangerZone":`Опасная зона`,"verification.revoke":`Снять верификацию`,"verification.revokeHint":`Убирает бейдж с цели. Одобренная заявка остаётся в истории.`,"verification.revokeNotVerified":`Бейджа на цели сейчас нет — снимать нечего.`,"route.botVerification":`Сторонняя верификация`,"route.botVerificationSubtitle":`Консоль / Сторонняя верификация`,"layout.botVerification":`Сторонние метки`,"picker.system":`Системный`,"picker.botPlaceholder":`Юзернейм бота или ID`,"botverification.pageTitle":`Сторонняя верификация`,"botverification.eyebrow":`Сторонняя верификация / Верификаторы, иконки, метки`,"botverification.explainTitle":`Это иконка компании-верификатора, а не официальная галочка`,"botverification.explainText":`Сторонняя метка — это собственная иконка бота-верификатора, которая рисуется ПЕРЕД именем аккаунта, бота или канала, плюс одна строка описания в профиле. Она значит только одно: «этот верификатор поручился за этот аккаунт».`,"botverification.explainIcon":`Иконка — это документ кастомного эмодзи. Клиент забирает его через messages.getCustomEmojiDocuments, поэтому ID, за которым нет реального документа, выглядит как полное отсутствие метки. Именно поэтому метки выдаются из каталога ниже, а не из набранного руками числа.`,"botverification.explainOfficial":`Официальная галочка — другой механизм, её выдаёт платформа в разделе «Официальная верификация». Они хранятся, показываются и снимаются по отдельности, и одна не подразумевает другую.`,"botverification.openOfficial":`Официальная верификация`,"botverification.manageMissing":`Эта сессия может читать раздел и решать заявки, но не может менять верификаторов и каталог иконок — для этого нужно право botverification.manage.`,"botverification.tabRequests":`Заявки`,"botverification.tabVerifiers":`Верификаторы`,"botverification.tabIcons":`Каталог иконок`,"botverification.tabMarks":`Выданные метки`,"botverification.queueTitle":`Очередь заявок`,"botverification.queueHint":`Заявки, поданные боту-верификатору владельцем аккаунта или канала. Счётчики считают всю очередь, а не страницу ниже.`,"botverification.searchPlaceholder":`ID заявки, ID цели, юзернейм или название`,"botverification.statusAll":`Все статусы`,"botverification.status.pending":`Ожидает решения`,"botverification.status.approved":`Одобрена`,"botverification.status.rejected":`Отклонена`,"botverification.status.revoked":`Метка снята`,"botverification.peer.user":`Аккаунт`,"botverification.peer.channel":`Канал`,"botverification.peerType":`Тип цели`,"botverification.peerTypeAll":`Все типы`,"botverification.verifier":`Верификатор`,"botverification.verifierAll":`Все верификаторы`,"botverification.verifierID":`ID бота-верификатора`,"botverification.applicant":`Заявитель`,"botverification.applicantID":`ID пользователя`,"botverification.target":`Цель`,"botverification.targetTitle":`Название`,"botverification.targetID":`ID цели`,"botverification.reason":`Обоснование`,"botverification.requestedDescription":`Запрошенное описание`,"botverification.description":`Описание`,"botverification.createdAt":`Подана`,"botverification.company":`Компания`,"botverification.companyPlaceholder":`ООО «Ромашка Верификация»`,"botverification.bot":`Бот`,"botverification.icon":`Иконка`,"botverification.iconDocument":`ID документа`,"botverification.iconName":`Название`,"botverification.markCount":`Метки`,"botverification.grantedBy":`Кто выдал`,"botverification.notProvided":`Не задано`,"botverification.verifiersTitle":`Боты-верификаторы`,"botverification.verifiersHint":`Боты, которым разрешено выдавать свою метку. Статус верификатора выдаётся вручную на уровне сервера, так что каждая строка здесь — это печатный станок бейджей, включённый оператором.`,"botverification.grantTitle":`Выдать статус верификатора`,"botverification.updateTitle":`Обновить верификатора`,"botverification.grantHint":`Бот получает иконку из каталога и название компании, от имени которой он поручается. Тот же вызов обновляет существующего верификатора — поэтому в нём есть version.`,"botverification.grantBot":`Бот`,"botverification.grantIcon":`Иконка из каталога`,"botverification.grantIconPick":`Выберите иконку`,"botverification.defaultDescription":`Описание по умолчанию`,"botverification.defaultDescriptionPlaceholder":`Проверено компанией «Ромашка»`,"botverification.canModify":`Верификатор может задавать своё описание для каждой цели`,"botverification.canModifyShort":`Своё описание`,"botverification.canModifyHint":`Это botVerifierSettings.can_modify_custom_description: если выключено, любая метка этого верификатора несёт описание по умолчанию — что бы ни просил заявитель.`,"botverification.noActiveIcons":`В каталоге нет активных иконок, выдавать нечего. Сначала добавьте иконку в каталог.`,"botverification.grantNote":`Бот сможет ставить метки сразу, как только строка появится и будет включена.`,"botverification.grant":`Выдать статус`,"botverification.update":`Обновить верификатора`,"botverification.editing":`Обновляем {bot} — версия {version} уходит как оптимистичная блокировка: если строку успел изменить кто-то другой, запрос отклонят, а не перезапишут.`,"botverification.cancelEdit":`Отменить обновление`,"botverification.edit":`Изменить`,"botverification.enable":`Включить`,"botverification.disable":`Отключить`,"botverification.enabled":`Включён`,"botverification.disabled":`отключён`,"botverification.disableHint":`Отключение — это рубильник для одного верификатора: уже выданные метки продолжают показываться, но новых бот поставить не может, и его настройки перестают уезжать в botInfo.`,"botverification.revokeVerifier":`Отозвать статус`,"botverification.revokeVerifierHint":`Отзыв статуса удаляет строку и все метки, которые этот верификатор выдал: иконка исчезнет сразу у всех его целей.`,"botverification.iconsTitle":`Каталог иконок`,"botverification.iconsHint":`Документы кастомных эмодзи, которыми верификатор может помечать цели. Ничего другого иконкой быть не может, поэтому неверный бейдж отсекается здесь, а не исправляется потом.`,"botverification.addIconTitle":`Добавить иконку или переименовать`,"botverification.addIconHint":`ID документа должен указывать на реальный документ кастомного эмодзи на этом сервере; раздел «Эмодзи» показывает их вместе с ID. Повторное добавление того же ID переименует запись, а не создаст вторую.`,"botverification.iconNamePlaceholder":`Синяя галочка «Ромашки»`,"botverification.iconOwner":`Владелец`,"botverification.iconOwnerShared":`Общая`,"botverification.iconOwnerHint":`Общую иконку можно выдать любому верификатору; если указать владельца, она закрепится только за этим ботом.`,"botverification.iconDocumentHint":`ID, за которым нет документа, даёт невидимый бейдж: в базе цель помечена, а клиент не рисует ничего.`,"botverification.addIconNote":`Добавление иконки само по себе ничего не выдаёт — оно лишь делает документ доступным для выдачи.`,"botverification.addIcon":`Сохранить иконку`,"botverification.iconActive":`Активна`,"botverification.iconInactive":`Выведена`,"botverification.usedBy":`Используют верификаторов`,"botverification.activateIcon":`Активировать`,"botverification.deactivateIcon":`Вывести`,"botverification.deactivateIconHint":`Выведенную иконку больше нельзя выдать новым верификаторам. У уже выданных меток она остаётся: иконка копируется в метку в момент выдачи.`,"botverification.marksTitle":`Выданные метки`,"botverification.marksHint":`Все цели, которые сейчас несут стороннюю метку, кем бы она ни была выдана: решением оператора, самим ботом-верификатором или владельцем цели через bots.setCustomVerification.`,"botverification.markSearchPlaceholder":`ID цели, юзернейм или название`,"botverification.revokeMark":`Снять метку`,"botverification.revokeMarkHint":`Снятие метки убирает с цели иконку и описание. Заявка, из которой метка появилась, остаётся в истории.`,"botverification.loadingDetail":`Загружаем заявку…`,"botverification.detailTitle":`Заявка #{id}`,"botverification.detailEyebrow":`Сторонняя верификация / Разбор`,"botverification.conflict":`Заявку уже изменил другой админ. Данные перезагружены — посмотрите статус перед новым решением.`,"botverification.markActive":`Метка стоит`,"botverification.markInactive":`Метки на цели нет`,"botverification.markActiveHint":`На этой цели метка этого верификатора уже стоит; одобрение обновит описание и зафиксирует решение.`,"botverification.verifierSection":`Верификатор`,"botverification.verifierHint":`Компания, чью иконку получит цель, — в том виде, в каком её строка выглядит сейчас.`,"botverification.openVerifier":`Открыть бота-верификатора`,"botverification.verifierMissing":`Строки верификатора больше нет: его статус отозвали уже после подачи заявки. Выдавать нечего, поэтому заявку остаётся только отклонить.`,"botverification.verifierDisabledHint":`Верификатор отключён. Пока оператор не включит его снова, новые метки он ставить не может.`,"botverification.targetSection":`Цель`,"botverification.targetHint":`Аккаунт, бот или канал, к которому будет прикреплена иконка.`,"botverification.openTarget":`Открыть цель`,"botverification.applicantSection":`Заявитель`,"botverification.applicantHint":`Кто подал заявку боту-верификатору.`,"botverification.openApplicant":`Открыть аккаунт`,"botverification.requestSection":`Заявка`,"botverification.requestHint":`Что написал заявитель — как обычный текст.`,"botverification.correlationID":`ID корреляции`,"botverification.markPreview":`Описание, которое получит метка`,"botverification.markPreviewHint":`Считается так же, как на бэкенде: формулировка заявителя берётся только если верификатору разрешено своё описание, иначе применяется описание верификатора по умолчанию.`,"botverification.descriptionIgnoredHint":`Этому верификатору нельзя задавать описание для отдельной цели, поэтому запрошенный текст игнорируется и применяется описание по умолчанию.`,"botverification.decisionSection":`Решение`,"botverification.decisionHint":`Кто и что решил и какими словами.`,"botverification.decidedBy":`Решение принял`,"botverification.approvedAt":`Одобрена`,"botverification.rejectedAt":`Отклонена`,"botverification.version":`Версия (оптимистичная блокировка)`,"botverification.decisionReason":`Причина решения`,"botverification.noDecision":`Решения пока нет`,"botverification.internalNote":`Внутренняя заметка`,"botverification.adminOnly":`только для админов`,"botverification.internalNotePlaceholder":`Заметка для других админов`,"botverification.internalNoteHint":`Необязательно. Хранится вместе с решением и видна только админам — заявителю не отправляется.`,"botverification.actionDock":`Решение по заявке`,"botverification.noActions":`В этом статусе действий нет.`,"botverification.approve":`Одобрить`,"botverification.approveHint":`Ставит иконку верификатора перед именем цели, описание — в профиль, и уведомляет заявителя.`,"botverification.reject":`Отклонить`,"botverification.rejectHint":`Причина обязательна: именно эту формулировку увидит заявитель, поэтому напишите, чего именно не хватило.`,"botverification.dangerZone":`Опасная зона`,"botverification.revokeRequest":`Снять метку`,"botverification.revokeRequestHint":`Убирает с цели иконку и описание и закрывает заявку как «метка снята». Официальная галочка, если она есть, не затрагивается.`,"botverification.revokeNoMark":`Метки на цели сейчас нет — снятие только закроет заявку.`,"botverification.rosterDenied":`Сервер не отдал этой сессии список верификаторов и каталог иконок (403), поэтому оба списка здесь пустые — заявки разбирать всё равно можно.`,"common.account":`Аккаунт`,"common.all":`Все`,"common.archive":`Архивировать`,"common.archived":`В архиве`,"common.bot":`Бот`,"common.changeFile":`Заменить файл`,"common.choose":`Выбрать`,"common.chooseFile":`Выбрать файл`,"common.copied":`Скопировано`,"common.copy":`Копировать`,"common.delete":`Удалить`,"common.disable":`Отключить`,"common.document":`Документ`,"common.documents":`Документы`,"common.enable":`Включить`,"common.logo":`Логотип`,"common.next":`Далее`,"common.official":`Официальный`,"common.order":`Порядок`,"common.pageOf":`Страница {current} из {total}`,"common.perPage":`На странице`,"common.previous":`Назад`,"common.preview":`Предпросмотр`,"common.remove":`Убрать`,"common.save":`Сохранить`,"common.shortName":`Короткое имя`,"common.showing":`Показано {visible} из {total}`,"common.showingRange":`Показано {start}–{end} из {total}`,"common.sortOrder":`Порядок сортировки`,"common.source":`Источник`,"common.title":`Название`,"common.unarchive":`Вернуть из архива`,"common.upload":`Загрузить`,"common.view":`Открыть`,"language.label":`Язык`,"dashboard.loadError":`Не удалось загрузить сводку`,"dashboard.needsAttention":`Требует внимания`,"dashboard.pendingReports":`Ожидающие жалобы`,"dashboard.verificationRequests":`Заявки на верификацию`,"dashboard.peopleChats":`Пользователи и чаты`,"dashboard.users":`Пользователи`,"dashboard.onlineNow":`Сейчас онлайн`,"dashboard.lastFiveMinutes":`за последние 5 минут`,"dashboard.bots":`Боты`,"dashboard.channels":`Каналы`,"dashboard.supergroups":`Супергруппы`,"dashboard.content":`Контент`,"dashboard.stickerPacks":`Наборы стикеров`,"dashboard.emojiPacks":`Наборы эмодзи`,"dashboard.gifs":`GIF`,"dashboard.savedByUsers":`сохранены пользователями`,"dashboard.mediaStorageUsed":`Занято медиахранилищем`,"dashboard.serverHealth":`Состояние сервера`,"dashboard.waitingForSample":`ожидание первого замера…`,"dashboard.cpuLoad":`Загрузка CPU`,"dashboard.ramUsed":`Использовано RAM`,"dashboard.diskFree":`Свободно на диске`,"dashboard.ofTotal":`из {total}`,"dashboard.singleBackend":`Хранилище: {backend}`,"dashboard.multipleBackends":`Хранилищ: {count}`,"dashboard.noMediaObjects":`медиаобъектов нет`,"attr.firstName":`Имя`,"attr.lastName":`Фамилия`,"attr.setProfileName":`Задать имя профиля`,"attr.phoneNumber":`Номер телефона`,"attr.setPhone":`Задать номер`,"attr.loginEmail":`Почта для входа`,"attr.loginEmailPlaceholder":`Пустое значение удалит почту`,"attr.setLoginEmail":`Задать почту для входа`,"avatar.change":`Изменить аватар`,"avatar.new":`Новый аватар`,"avatar.chooseImage":`Выберите изображение JPEG, PNG или WebP`,"avatar.chooseFirst":`Сначала выберите файл изображения.`,"avatar.reasonPlaceholder":`Кратко укажите причину изменения аватара`,"avatar.upload":`Загрузить аватар`,"bots.copyToken":`Скопировать токен бота`,"bots.copyTokenHint":`Токен будет записан прямо в буфер обмена и не появится на экране.`,"bots.noTokenReturned":`Сервер не вернул токен.`,"bots.tokenCopied":`Токен скопирован в буфер обмена.`,"bots.copyAgain":`Скопировать ещё раз`,"gifCatalog.title":`Каталог GIF`,"gifCatalog.eyebrow":`Управляемый каталог медиа для встроенного inline-бота @gif`,"gifCatalog.add":`Добавить GIF`,"gifCatalog.entries":`Записи каталога`,"gifCatalog.adminUpload":`Загружено администратором`,"gifCatalog.newMaterial":`Новый материал для @gif`,"gifCatalog.chooseMedia":`Выберите GIF или MP4`,"gifCatalog.required":`Необходимо указать название, причину и выбрать файл GIF/MP4.`,"stickers.emojiEyebrow":`Наборы кастом-эмодзи — системные наборы защищены от ручного редактирования`,"stickers.stickerEyebrow":`Наборы стикеров — системные наборы защищены от ручного редактирования`,"stickers.createEmojiPack":`Создать набор эмодзи`,"stickers.createStickerPack":`Создать набор стикеров`,"stickers.totalSets":`Всего наборов`,"stickers.searchPlaceholder":`Поиск по ID, короткому имени или названию набора`,"stickers.newSet":`Новый набор`,"stickers.firstEmoji":`Первый эмодзи`,"stickers.firstSticker":`Первый стикер`,"stickers.chooseMedia":`Выберите файл TGS, Lottie JSON или WebP`,"stickers.createReasonPlaceholder":`Кратко укажите причину создания набора`,"stickers.emojiRequired":`Укажите название, короткое имя, эмодзи и выберите первый файл эмодзи.`,"stickers.stickerRequired":`Укажите название, короткое имя, эмодзи и выберите первый файл стикера.`,"stickers.setContents":`Содержимое набора`,"stickers.emptySet":`В этом наборе пока нет документов.`,"stickers.chooseEmojiFirst":`Сначала выберите файл эмодзи.`,"stickers.chooseStickerFirst":`Сначала выберите файл стикера.`,"stickers.emojiValueRequired":`Укажите эмодзи.`,"stickers.addEmoji":`Добавить эмодзи`,"stickers.packFull":`В этом наборе уже {count} элементов. Текущий лимит — {max}; создайте новый набор, чтобы добавить ещё.`,"stickers.addSticker":`Добавить стикер`,"action.oneTimeSecret":`Одноразовый секрет — скопируйте его сейчас, повторно он не отобразится.`}},jt=(0,g.createContext)(null);function Mt({children:e}){let[t,n]=(0,g.useState)(()=>Ft());(0,g.useEffect)(()=>{try{localStorage.setItem(kt,t)}catch{}let e=t===`zh`?`zh-CN`:t===`ru`?`ru`:`en`;document.documentElement.lang=e,document.documentElement.dir=`ltr`,document.documentElement.setAttribute(`translate`,`no`),document.body.classList.add(`notranslate`),document.title=Pt(t,`app.title`)},[t]);let r=(0,g.useMemo)(()=>({lang:t,setLang:n,t:(e,n)=>Pt(t,e,n)}),[t]);return(0,H.jsx)(jt.Provider,{value:r,children:e})}function U(){let e=(0,g.useContext)(jt);if(!e)throw Error(`useI18n must be used inside I18nProvider`);return e}function Nt(){let{lang:e,setLang:t,t:n}=U();return(0,H.jsx)(`div`,{className:`language-switch`,role:`group`,"aria-label":n(`language.label`),children:[`en`,`zh`,`ru`].map(r=>(0,H.jsx)(`button`,{className:e===r?`active`:``,type:`button`,"aria-pressed":e===r,onClick:()=>t(r),children:n(`language.${r}`)},r))})}function Pt(e,t,n){let r=At[e][t]??At.en[t]??t;return n?r.replace(/\{(\w+)\}/g,(e,t)=>String(n[t]??``)):r}function Ft(){try{let e=It(new URLSearchParams(window.location.search).get(`lang`));if(e)return e}catch{}try{let e=It(localStorage.getItem(kt));if(e)return e}catch{}let e=navigator.languages?.length?navigator.languages:[navigator.language];for(let t of e){let e=It(t);if(e)return e}return`en`}function It(e){if(!e)return null;let t=e.trim().toLowerCase().replace(`_`,`-`);return t===`zh`||t.startsWith(`zh-`)?`zh`:t===`en`||t.startsWith(`en-`)?`en`:t===`ru`||t.startsWith(`ru-`)?`ru`:null}function Lt(e){let t=e.trim();return!t||t.startsWith(`+`)?t:/^\d+$/.test(t)?`+${t}`:t}function W(e){let t=e.trim();return t?t.startsWith(`@`)?t:`@${t}`:``}function Rt(e){return`${e.FirstName||``} ${e.LastName||``}`.trim()||`-`}function zt(e,t){let n=t??(e=>({"channel.kind.broadcast":`Channel`,"channel.kind.forum":`Supergroup / Forum`,"channel.kind.megagroup":`Supergroup`,"channel.kind.generic":`Channel / Group`})[e]??e);return e.Broadcast&&!e.Megagroup?n(`channel.kind.broadcast`):e.Megagroup&&e.Forum?n(`channel.kind.forum`):e.Megagroup?n(`channel.kind.megagroup`):n(`channel.kind.generic`)}function G(e){if(!e||e.startsWith(`0001-`))return``;let t=new Date(e);return Number.isNaN(t.getTime())?``:t.toLocaleString()}function Bt(e){if(!e||e<=0)return``;let t=new Date(e*1e3);return Number.isNaN(t.getTime())?``:t.toLocaleString()}function Vt(e){let t=(e??``).trim();if(!/^https?:\/\//i.test(t))return``;try{let e=new URL(t);return e.protocol!==`http:`&&e.protocol!==`https:`?``:e.href}catch{return``}}function Ht(e){if(!e.trim())return 0;let t=Number.parseInt(e,10);return Number.isFinite(t)?t:0}function Ut(e){let t=(e??``).trim();if(!t)return 0;let n=Number(t);return Number.isFinite(n)?n:0}function Wt(e){let t=(e??``).trim();if(!t)return`0`;let n=Number(t);return Number.isFinite(n)?n.toLocaleString():t}function Gt(e){let t=(e??``).trim();if(!/^\d+$/.test(t))return`0 B`;let n;try{n=BigInt(t)}catch{return`${t} B`}let r=[`B`,`KiB`,`MiB`,`GiB`,`TiB`,`PiB`],i=1n,a=0;for(;n>=i*1024n&&a0?a.slice(a.length-r):``;r>2&&(s=s.replace(/0+$/,``));let c=i?`-`:``;return s?`${c}${Yt(o)}.${s}`:`${c}${Yt(o)}`}function Yt(e){return e.replace(/\B(?=(\d{3})+(?!\d))/g,` `)}function Xt(e,t){let n=(t??``).trim().toUpperCase(),r=Jt(e,n);return n?`${r} ${n}`:r}function Zt(e,t){let n=(e??``).trim().replace(/\s+/g,``).replace(`,`,`.`);if(!n)return`0`;if(!/^\d*(\.\d*)?$/.test(n)||n===`.`)return null;let r=qt(t),[i,a=``]=n.split(`.`);if(a.length>r)return null;let o=`${i||`0`}${a.padEnd(r,`0`)}`.replace(/^0+(?=\d)/,``);return o===``?`0`:o}function Qt(e){let t=(e??``).trim();if(!t)return`0`;let n=Number(t);return Number.isFinite(n)?n>0?`+${n.toLocaleString()}`:n.toLocaleString():t}function $t(e,t=`msg ids invalid`){let n=e.split(/[\s,]+/).map(e=>e.trim()).filter(Boolean).map(e=>Number.parseInt(e,10));if(n.length===0||n.some(e=>!Number.isFinite(e)||e<=0))throw Error(t);return n}function en(e){if(!e.trim())return 0;let t=new Date(e).getTime();return Number.isFinite(t)?Math.floor(t/1e3):0}function tn(e){let t=new Date(Date.now()+e*1e3),n=e=>String(e).padStart(2,`0`);return`${t.getFullYear()}-${n(t.getMonth()+1)}-${n(t.getDate())}T${n(t.getHours())}:${n(t.getMinutes())}`}function nn({title:e,eyebrow:t,children:n,actions:r}){return(0,H.jsxs)(`div`,{className:`page-frame`,children:[(0,H.jsxs)(`div`,{className:`page-title-row`,children:[(0,H.jsxs)(`div`,{children:[t&&(0,H.jsx)(`div`,{className:`eyebrow`,children:t}),(0,H.jsx)(`h2`,{children:e})]}),r&&(0,H.jsx)(`div`,{className:`page-actions`,children:r})]}),n]})}function rn({children:e}){return(0,H.jsx)(`div`,{className:`query-panel`,children:e})}function an({main:e,side:t}){return(0,H.jsxs)(`div`,{className:`split-layout`,children:[(0,H.jsx)(`div`,{className:`split-main`,children:e}),(0,H.jsx)(`aside`,{className:`split-side`,children:t})]})}function K({title:e,text:t,action:n}){return(0,H.jsxs)(`div`,{className:`section-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`h2`,{children:e}),t&&(0,H.jsx)(`p`,{children:t})]}),n&&(0,H.jsx)(`div`,{className:`section-action`,children:n})]})}function q({children:e}){return(0,H.jsxs)(`div`,{className:`alert`,children:[(0,H.jsx)(I,{size:16}),` `,(0,H.jsx)(`span`,{children:e})]})}function J({children:e,tone:t=`neutral`}){return(0,H.jsx)(`span`,{className:`badge ${t}`,children:e})}function Y({label:e,value:t,tone:n=`neutral`,mono:r=!1}){return(0,H.jsxs)(`div`,{className:`metric ${n}`,children:[(0,H.jsx)(`span`,{children:e}),(0,H.jsx)(`strong`,{className:r?`mono`:``,children:t})]})}function X({label:e,value:t,mono:n=!1}){return(0,H.jsxs)(`div`,{className:`summary-item`,children:[(0,H.jsx)(`span`,{children:e}),(0,H.jsx)(`strong`,{className:n?`mono`:``,children:t})]})}function on({rows:e}){let{t}=U();return(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:t(`audit.id`)}),(0,H.jsx)(`th`,{children:t(`audit.commandID`)}),(0,H.jsx)(`th`,{children:t(`audit.action`)}),(0,H.jsx)(`th`,{children:t(`audit.actor`)}),(0,H.jsx)(`th`,{children:t(`audit.status`)}),(0,H.jsx)(`th`,{children:t(`audit.dryRun`)}),(0,H.jsx)(`th`,{children:t(`audit.reason`)}),(0,H.jsx)(`th`,{children:t(`audit.time`)})]})}),(0,H.jsxs)(`tbody`,{children:[e.map(e=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{children:e.ID}),(0,H.jsx)(`td`,{className:`mono`,children:e.CommandID}),(0,H.jsx)(`td`,{children:e.Action}),(0,H.jsx)(`td`,{children:e.Actor}),(0,H.jsx)(`td`,{children:e.Status}),(0,H.jsx)(`td`,{children:e.DryRun?t(`common.yes`):t(`common.no`)}),(0,H.jsx)(`td`,{className:`truncate`,children:e.Reason}),(0,H.jsx)(`td`,{children:G(e.CreatedAt)})]},e.ID)),e.length===0&&(0,H.jsx)(sn,{colSpan:8})]})]})})}function sn({colSpan:e}){let{t}=U();return(0,H.jsx)(`tr`,{children:(0,H.jsx)(`td`,{colSpan:e,className:`empty-cell`,children:t(`common.noResults`)})})}function cn({label:e}){return(0,H.jsx)(`section`,{className:`surface`,children:(0,H.jsx)(`div`,{className:`loading-line`,children:e})})}function ln({value:e}){return(0,H.jsx)(`pre`,{className:`json-block`,children:e||`{}`})}function un({username:e,collectibles:t}){let{t:n}=U(),r=W(e??``),i=t??[];return i.length===0?(0,H.jsx)(H.Fragment,{children:r||`-`}):(0,H.jsxs)(H.Fragment,{children:[r,(0,H.jsx)(`ul`,{className:`username-branch`,children:i.map(e=>(0,H.jsxs)(`li`,{className:e.Active?``:`inactive`,children:[(0,H.jsx)(`span`,{children:W(e.Username)}),!e.Active&&(0,H.jsx)(`em`,{children:n(`usernames.inactive`)})]},e.Username))})]})}var dn=`premium.manage`,fn=`bots.token.read`,pn=`verification.review`,mn=`botverification.review`,hn=`botverification.manage`,gn=(0,g.createContext)([]);function _n({permissions:e,children:t}){return(0,H.jsx)(gn.Provider,{value:e,children:t})}function vn(){let e=(0,g.useContext)(gn);return(0,g.useMemo)(()=>({permissions:e,can:t=>e.includes(`*`)||e.includes(t)}),[e])}function yn(e){return vn().can(e)}function bn({permission:e,children:t}){let{can:n}=vn();return n(e)?(0,H.jsx)(H.Fragment,{children:t}):(0,H.jsx)(xn,{permission:e})}function xn({permission:e}){let{t}=U();return(0,H.jsxs)(nn,{title:t(`permission.deniedTitle`),eyebrow:t(`permission.deniedEyebrow`),children:[(0,H.jsx)(q,{children:t(`permission.deniedBody`,{permission:e})}),(0,H.jsx)(`section`,{className:`section-block`,children:(0,H.jsx)(`div`,{className:`entity-head`,children:(0,H.jsxs)(`div`,{children:[(0,H.jsxs)(`div`,{className:`entity-title`,children:[(0,H.jsx)(ut,{size:16}),` `,t(`permission.deniedHeading`)]}),(0,H.jsx)(`div`,{className:`entity-subtitle`,children:t(`permission.deniedHint`)})]})})})]})}function Sn(){return{href:`${window.location.pathname}${window.location.search}`,path:window.location.pathname,search:new URLSearchParams(window.location.search)}}function Cn(e,t){return e.startsWith(`/bot-verification`)?t(`route.botVerification`):e.startsWith(`/verification`)?t(`route.verification`):e.startsWith(`/collectible-usernames`)?t(`route.collectibleUsernames`):e.startsWith(`/collectible-phones`)?t(`route.collectiblePhones`):e.startsWith(`/account-ratings`)?t(`route.accountRatings`):e.startsWith(`/storage`)?t(`route.storage`):e.startsWith(`/accounts`)?t(`route.accounts`):e.startsWith(`/channels`)?t(`route.channels`):e.startsWith(`/bots`)?t(`route.bots`):e.startsWith(`/broadcasts`)?t(`route.broadcasts`):e.startsWith(`/monetization`)||e.startsWith(`/premium`)?t(`route.premium`):e.startsWith(`/moderation`)?t(`route.moderation`):e.startsWith(`/stickers`)?t(`route.stickers`):e.startsWith(`/emoji`)?t(`route.emoji`):e.startsWith(`/gif-catalog`)?t(`route.gifCatalog`):e.startsWith(`/messages`)?t(`route.messages`):e.startsWith(`/give-gifts`)?t(`route.giveGifts`):e.startsWith(`/gifts`)?t(`route.gifts`):e.startsWith(`/auctions`)?t(`route.auctions`):t(`route.dashboard`)}function wn(e,t){return e.startsWith(`/bot-verification`)?t(`route.botVerificationSubtitle`):e.startsWith(`/verification`)?t(`route.verificationSubtitle`):e.startsWith(`/collectible-usernames`)?t(`route.collectibleUsernamesSubtitle`):e.startsWith(`/collectible-phones`)?t(`route.collectiblePhonesSubtitle`):e.startsWith(`/account-ratings`)?t(`route.accountRatingsSubtitle`):e.startsWith(`/storage`)?t(`route.storageSubtitle`):e.startsWith(`/accounts`)?t(`route.accountsSubtitle`):e.startsWith(`/channels`)?t(`route.channelsSubtitle`):e.startsWith(`/bots`)?t(`route.botsSubtitle`):e.startsWith(`/broadcasts`)?t(`route.broadcastsSubtitle`):e.startsWith(`/monetization`)||e.startsWith(`/premium`)?t(`route.premiumSubtitle`):e.startsWith(`/moderation`)?t(`route.moderationSubtitle`):e.startsWith(`/stickers`)?t(`route.stickersSubtitle`):e.startsWith(`/emoji`)?t(`route.emojiSubtitle`):e.startsWith(`/gif-catalog`)?t(`route.gifCatalogSubtitle`):e.startsWith(`/messages`)?t(`route.messagesSubtitle`):e.startsWith(`/give-gifts`)?t(`route.giveGiftsSubtitle`):e.startsWith(`/gifts`)?t(`route.giftsSubtitle`):e.startsWith(`/auctions`)?t(`route.auctionsSubtitle`):t(`route.dashboardSubtitle`)}var Tn=`telesrv.admin.theme`,En=(0,g.createContext)(null);function Dn(e){document.documentElement.setAttribute(`data-theme`,e),document.documentElement.style.colorScheme=e}function On({children:e}){let[t,n]=(0,g.useState)(()=>jn());(0,g.useEffect)(()=>{Dn(t);try{localStorage.setItem(Tn,t)}catch{}},[t]),(0,g.useEffect)(()=>{if(!window.matchMedia)return;let e=window.matchMedia(`(prefers-color-scheme: dark)`),t=e=>{let t=null;try{t=localStorage.getItem(Tn)}catch{t=null}t!==`light`&&t!==`dark`&&n(e.matches?`dark`:`light`)};return e.addEventListener(`change`,t),()=>e.removeEventListener(`change`,t)},[]);let r=(0,g.useCallback)(e=>n(e),[]),i=(0,g.useCallback)(()=>n(e=>e===`dark`?`light`:`dark`),[]),a=(0,g.useMemo)(()=>({theme:t,setTheme:r,toggleTheme:i}),[t,r,i]);return(0,H.jsx)(En.Provider,{value:a,children:e})}function kn(){let e=(0,g.useContext)(En);if(!e)throw Error(`useTheme must be used inside ThemeProvider`);return e}function An(){let{theme:e,toggleTheme:t}=kn(),{t:n}=U(),r=n(e===`light`?`theme.switchToDark`:`theme.switchToLight`);return(0,H.jsx)(`button`,{className:`theme-toggle`,type:`button`,onClick:t,"aria-label":r,title:r,children:e===`dark`?(0,H.jsx)(_t,{size:16}):(0,H.jsx)(Ke,{size:16})})}function jn(){try{let e=localStorage.getItem(Tn);if(e===`light`||e===`dark`)return e}catch{}try{if(window.matchMedia&&window.matchMedia(`(prefers-color-scheme: dark)`).matches)return`dark`}catch{}return`light`}function Mn({href:e,navigate:t,className:n,children:r}){return(0,H.jsx)(`a`,{className:n,href:e,onClick:n=>{n.preventDefault(),t(e)},children:r})}function Nn(){let{t:e}=U();return(0,H.jsxs)(`div`,{className:`boot-screen`,children:[(0,H.jsxs)(`div`,{className:`brand compact brand-elevated`,children:[(0,H.jsx)(`span`,{className:`brand-mark`,children:`T`}),(0,H.jsxs)(`span`,{children:[(0,H.jsx)(`strong`,{children:`telesrv`}),(0,H.jsx)(`small`,{children:e(`app.adminConsole`)})]})]}),(0,H.jsx)(`div`,{className:`loader-bar`})]})}function Pn({actor:e,route:t,navigate:n,onLogout:r,children:i}){let{t:a}=U(),o=yn(pn),s=yn(mn),c=yn(dn),l=t.path.startsWith(`/messages`),[u,d]=(0,g.useState)(l);(0,g.useEffect)(()=>{l&&d(!0)},[l]);async function f(){await k.logout().catch(()=>void 0),r()}return(0,H.jsxs)(`div`,{className:`shell`,children:[(0,H.jsxs)(`aside`,{className:`sidebar`,children:[(0,H.jsxs)(Mn,{className:`brand`,href:`/`,navigate:n,children:[(0,H.jsx)(`span`,{className:`brand-mark`,children:`T`}),(0,H.jsxs)(`span`,{children:[(0,H.jsx)(`strong`,{children:`telesrv`}),(0,H.jsx)(`small`,{children:a(`app.adminConsole`)})]})]}),(0,H.jsx)(`div`,{className:`sidebar-label`,children:a(`layout.navigation`)}),(0,H.jsxs)(`nav`,{className:`nav-list`,"aria-label":a(`layout.primaryNav`),children:[(0,H.jsx)(Fn,{icon:(0,H.jsx)(Be,{size:16}),href:`/`,route:t,navigate:n,children:a(`layout.dashboard`)}),(0,H.jsx)(Fn,{icon:(0,H.jsx)(Tt,{size:16}),href:`/accounts`,route:t,navigate:n,children:a(`layout.accounts`)}),(0,H.jsx)(Fn,{icon:(0,H.jsx)(lt,{size:16}),href:`/channels`,route:t,navigate:n,children:a(`layout.channels`)}),(0,H.jsx)(Fn,{icon:(0,H.jsx)(de,{size:16}),href:`/bots`,route:t,navigate:n,children:a(`layout.bots`)}),(0,H.jsx)(Fn,{icon:(0,H.jsx)(V,{size:16}),href:`/broadcasts`,route:t,navigate:n,children:a(`layout.broadcasts`)}),c&&(0,H.jsx)(Fn,{icon:(0,H.jsx)(ue,{size:16}),href:`/monetization`,route:t,navigate:n,activeWhen:e=>e.startsWith(`/monetization`)||e.startsWith(`/premium`),children:a(`layout.premium`)}),(0,H.jsx)(Fn,{icon:(0,H.jsx)(ct,{size:16}),href:`/moderation`,route:t,navigate:n,children:a(`layout.moderation`)}),o&&(0,H.jsx)(Fn,{icon:(0,H.jsx)(F,{size:16}),href:`/verification`,route:t,navigate:n,children:a(`layout.verification`)}),s&&(0,H.jsx)(Fn,{icon:(0,H.jsx)(mt,{size:16}),href:`/bot-verification`,route:t,navigate:n,children:a(`layout.botVerification`)}),(0,H.jsx)(Fn,{icon:(0,H.jsx)(le,{size:16}),href:`/collectible-usernames`,route:t,navigate:n,children:a(`layout.collectibleUsernames`)}),(0,H.jsx)(Fn,{icon:(0,H.jsx)(Xe,{size:16}),href:`/collectible-phones`,route:t,navigate:n,children:a(`layout.collectiblePhones`)}),(0,H.jsx)(Fn,{icon:(0,H.jsx)(bt,{size:16}),href:`/account-ratings`,route:t,navigate:n,children:a(`layout.accountRatings`)}),(0,H.jsx)(Fn,{icon:(0,H.jsx)(Se,{size:16}),href:`/storage`,route:t,navigate:n,children:a(`layout.storage`)}),(0,H.jsx)(Fn,{icon:(0,H.jsx)(Ne,{size:16}),href:`/gifts`,route:t,navigate:n,children:a(`layout.gifts`)}),(0,H.jsx)(Fn,{icon:(0,H.jsx)(at,{size:16}),href:`/give-gifts`,route:t,navigate:n,children:a(`layout.giveGifts`)}),(0,H.jsx)(Fn,{icon:(0,H.jsx)(je,{size:16}),href:`/auctions`,route:t,navigate:n,children:a(`layout.auctions`)}),(0,H.jsx)(Fn,{icon:(0,H.jsx)(gt,{size:16}),href:`/stickers`,route:t,navigate:n,children:a(`layout.stickers`)}),(0,H.jsx)(Fn,{icon:(0,H.jsx)(pt,{size:16}),href:`/emoji`,route:t,navigate:n,children:a(`layout.emoji`)}),(0,H.jsx)(Fn,{icon:(0,H.jsx)(Oe,{size:16}),href:`/gif-catalog`,route:t,navigate:n,children:a(`layout.gifCatalog`)}),(0,H.jsxs)(`div`,{className:`nav-section ${l?`active`:``} ${u?`open`:``}`,children:[(0,H.jsxs)(`button`,{className:`nav-section-toggle`,type:`button`,"aria-expanded":u,onClick:()=>d(e=>!e),children:[(0,H.jsx)(Ge,{size:16}),(0,H.jsx)(`span`,{children:a(`layout.messages`)}),(0,H.jsx)(ge,{className:`nav-section-chevron`,size:15})]}),u&&(0,H.jsxs)(`div`,{className:`nav-children`,children:[(0,H.jsx)(Fn,{href:`/messages/private`,route:t,navigate:n,activeWhen:e=>e===`/messages`||e===`/messages/detail`||e.startsWith(`/messages/private`),children:a(`layout.privateMessages`)}),(0,H.jsx)(Fn,{href:`/messages/groups`,route:t,navigate:n,activeWhen:e=>e.startsWith(`/messages/groups`),children:a(`layout.groupMessages`)})]})]})]}),(0,H.jsxs)(`div`,{className:`sidebar-status`,children:[(0,H.jsx)(`div`,{className:`sidebar-label`,children:a(`layout.runtime`)}),(0,H.jsxs)(`div`,{className:`runtime-row`,children:[(0,H.jsx)(ot,{size:14}),(0,H.jsx)(`span`,{children:a(`layout.adminBackend`)}),(0,H.jsx)(`strong`,{children:a(`layout.ready`)})]}),(0,H.jsxs)(`div`,{className:`runtime-row`,children:[(0,H.jsx)(Se,{size:14}),(0,H.jsx)(`span`,{children:a(`layout.pgRead`)}),(0,H.jsx)(`strong`,{children:a(`layout.readOnly`)})]}),(0,H.jsxs)(`div`,{className:`runtime-row`,children:[(0,H.jsx)(dt,{size:14}),(0,H.jsx)(`span`,{children:a(`layout.writeOps`)}),(0,H.jsx)(`strong`,{children:a(`layout.dryRun`)})]})]})]}),(0,H.jsxs)(`div`,{className:`workspace`,children:[(0,H.jsxs)(`header`,{className:`topbar`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`eyebrow`,children:wn(t.path,a)}),(0,H.jsx)(`h1`,{children:Cn(t.path,a)})]}),(0,H.jsxs)(`div`,{className:`topbar-actions`,children:[(0,H.jsx)(An,{}),(0,H.jsx)(Nt,{}),(0,H.jsx)(`span`,{className:`actor-pill`,children:a(`layout.actor`,{actor:e})}),(0,H.jsxs)(`button`,{className:`btn ghost icon-text`,type:`button`,onClick:f,title:a(`layout.logout`),children:[(0,H.jsx)(He,{size:16}),` `,a(`layout.logout`)]})]})]}),(0,H.jsx)(`main`,{className:`content`,children:i})]})]})}function Fn({href:e,route:t,navigate:n,icon:r,children:i,activeWhen:a}){return(0,H.jsxs)(Mn,{className:`nav-item ${(a?a(t.path):e===`/`?t.path===`/`:t.path.startsWith(e))?`active`:``}`,href:e,navigate:n,children:[r??(0,H.jsx)(`span`,{"aria-hidden":`true`,className:`nav-dot`}),(0,H.jsx)(`span`,{children:i})]})}function In({onLogin:e}){let{t}=U(),[n,r]=(0,g.useState)(``),[i,a]=(0,g.useState)(``),[o,s]=(0,g.useState)(!1);async function c(t){t.preventDefault(),s(!0),a(``);try{let t=await k.login(n);e({actor:t.actor,permissions:t.permissions??[]})}catch(e){a(O(e))}finally{s(!1)}}return(0,H.jsx)(`main`,{className:`login-page`,children:(0,H.jsxs)(`section`,{className:`login-panel`,children:[(0,H.jsxs)(`div`,{className:`login-head`,children:[(0,H.jsxs)(`div`,{className:`brand brand-elevated`,children:[(0,H.jsx)(`span`,{className:`brand-mark`,children:`T`}),(0,H.jsxs)(`span`,{children:[(0,H.jsx)(`strong`,{children:`telesrv`}),(0,H.jsx)(`small`,{children:t(`app.adminConsole`)})]})]}),(0,H.jsxs)(`div`,{className:`login-head-actions`,children:[(0,H.jsx)(An,{}),(0,H.jsx)(Nt,{}),(0,H.jsx)(`span`,{className:`login-chip`,children:t(`app.localAccess`)})]})]}),(0,H.jsxs)(`div`,{className:`login-copy`,children:[(0,H.jsx)(`h1`,{children:t(`login.heading`)}),(0,H.jsx)(`p`,{children:t(`login.body`)})]}),i&&(0,H.jsx)(q,{children:i}),(0,H.jsxs)(`form`,{className:`form-stack`,onSubmit:c,children:[(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:t(`login.secret`)}),(0,H.jsx)(`input`,{autoFocus:!0,type:`password`,value:n,autoComplete:`current-password`,onChange:e=>r(e.target.value)})]}),(0,H.jsx)(`button`,{className:`btn primary full`,type:`submit`,disabled:o,children:t(o?`login.submitting`:`login.submit`)})]})]})})}var Ln=m();function Z({label:e,path:t,payload:n,icon:r,compact:i=!1,tone:a=`danger`,disabled:o=!1,onDone:s,onError:c,secretField:l}){let{t:u}=U(),[d,f]=(0,g.useState)(!1),[p,m]=(0,g.useState)(``),[h,_]=(0,g.useState)(null),[v,y]=(0,g.useState)(``),[b,x]=(0,g.useState)(!1),[S,C]=(0,g.useState)(!1);function w(){m(``),_(null),y(``),C(!1)}async function T(e){if(!p.trim()){y(u(`action.reasonRequired`));return}x(!0),y(``);try{let r={...n(),reason:p,confirm:e};_(await k.action(t,r)),e&&s?.()}catch(e){y(c?.(e)||O(e))}finally{x(!1)}}let E=h?.dry_run&&!h.error,D=`btn ${a===`danger`?`danger`:a===`warn`?`warn`:``} ${i?`compact-btn`:``}`,A=(0,g.useMemo)(()=>{try{return n()}catch(e){return{payload_error:O(e)}}},[d,n]),j=l&&h?.details&&typeof h.details[l]==`string`?h.details[l]:``,M=j&&h?.details?Object.fromEntries(Object.entries(h.details).filter(([e])=>e!==l)):h?.details;async function N(){await navigator.clipboard.writeText(j),C(!0)}return(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`button`,{className:D,type:`button`,disabled:o,onClick:()=>{w(),f(!0)},children:[r,e]}),d&&(0,Ln.createPortal)((0,H.jsx)(`div`,{className:`modal-backdrop`,role:`presentation`,children:(0,H.jsxs)(`section`,{className:`modal command-modal`,role:`dialog`,"aria-modal":`true`,"aria-label":e,children:[(0,H.jsxs)(`div`,{className:`modal-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`eyebrow`,children:u(`action.flow`)}),(0,H.jsx)(`h2`,{children:e})]}),(0,H.jsx)(`button`,{className:`icon-btn`,type:`button`,onClick:()=>f(!1),"aria-label":u(`action.close`),children:(0,H.jsx)(Dt,{size:15})})]}),(0,H.jsxs)(`div`,{className:`command-body`,children:[(0,H.jsxs)(`div`,{className:`command-steps`,children:[(0,H.jsxs)(`div`,{className:`command-step ${p.trim()?`done`:`active`}`,children:[(0,H.jsx)(`span`,{children:`1`}),(0,H.jsx)(`strong`,{children:u(`action.stepReason`)})]}),(0,H.jsxs)(`div`,{className:`command-step ${h?.dry_run?`done`:p.trim()?`active`:``}`,children:[(0,H.jsx)(`span`,{children:`2`}),(0,H.jsx)(`strong`,{children:u(`action.stepDryRun`)})]}),(0,H.jsxs)(`div`,{className:`command-step ${h&&!h.dry_run&&!h.error?`done`:E?`active`:``}`,children:[(0,H.jsx)(`span`,{children:`3`}),(0,H.jsx)(`strong`,{children:u(`action.stepConfirm`)})]})]}),(0,H.jsxs)(`label`,{className:`form-field`,children:[(0,H.jsx)(`span`,{children:u(`action.reason`)}),(0,H.jsx)(`textarea`,{value:p,onChange:e=>m(e.target.value),rows:3,placeholder:u(`action.reasonPlaceholder`)})]}),(0,H.jsxs)(`div`,{className:`command-preview`,children:[(0,H.jsxs)(`div`,{className:`preview-head`,children:[(0,H.jsx)(Ee,{size:14}),` `,u(`action.requestPreview`)]}),(0,H.jsx)(ln,{value:JSON.stringify(A,null,2)})]}),v&&(0,H.jsx)(q,{children:v}),h&&(0,H.jsxs)(`div`,{className:`result-box`,children:[(0,H.jsxs)(`div`,{className:`result-title`,children:[h.error?(0,H.jsx)(I,{size:16}):(0,H.jsx)(L,{size:16}),(0,H.jsx)(`strong`,{children:h.message||h.error||u(`action.result`)})]}),(0,H.jsxs)(`div`,{className:`result-line`,children:[(0,H.jsx)(`span`,{children:u(`action.commandID`)}),(0,H.jsx)(`strong`,{children:h.command_id})]}),(0,H.jsxs)(`div`,{className:`result-line`,children:[(0,H.jsx)(`span`,{children:u(`action.status`)}),(0,H.jsx)(`strong`,{children:h.status})]}),(0,H.jsxs)(`div`,{className:`result-line`,children:[(0,H.jsx)(`span`,{children:u(`action.dryRun`)}),(0,H.jsx)(`strong`,{children:h.dry_run?u(`common.yes`):u(`common.no`)})]}),(0,H.jsx)(`div`,{className:`result-message`,children:h.message||h.error}),j&&(0,H.jsxs)(`div`,{className:`secret-reveal`,children:[(0,H.jsx)(`div`,{className:`secret-reveal-label`,children:u(`action.oneTimeSecret`)}),(0,H.jsxs)(`div`,{className:`secret-reveal-row`,children:[(0,H.jsx)(`code`,{className:`secret-reveal-value`,children:`•`.repeat(Math.min(j.length,40))}),(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>void N(),children:[S?(0,H.jsx)(he,{size:15}):(0,H.jsx)(be,{size:15}),u(S?`common.copied`:`common.copy`)]})]})]}),M&&Object.keys(M).length>0&&(0,H.jsx)(ln,{value:JSON.stringify(M,null,2)})]})]}),(0,H.jsxs)(`div`,{className:`modal-actions`,children:[(0,H.jsx)(`button`,{className:`btn`,type:`button`,onClick:()=>f(!1),children:u(`common.close`)}),(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>T(!1),disabled:b,children:[b?(0,H.jsx)(te,{size:15,className:`spin`}):(0,H.jsx)(Ze,{size:15}),u(h?`action.runAgain`:`action.runDry`)]}),(0,H.jsxs)(`button`,{className:`btn danger icon-text`,type:`button`,onClick:()=>T(!0),disabled:b||!E,children:[(0,H.jsx)(L,{size:15}),u(`action.confirm`)]})]})]})}),document.body)]})}var Rn=[[`#FF885E`,`#FF516A`],[`#FFCD6A`,`#FFA85C`],[`#82B1FF`,`#665FFF`],[`#A0DE7E`,`#54CB68`],[`#53EDD6`,`#28C9B7`],[`#72D5FD`,`#2A9EF1`],[`#E0A2F3`,`#D669ED`]];function zn(e){let t=e.trim().split(/\s+/).filter(Boolean);return t.length===0?`T`:((Array.from(t[0])[0]??`T`)+(t.length>1?Array.from(t[t.length-1])[0]??``:``)).toUpperCase()}function Bn({id:e,kind:t=`user`,label:n,size:r=34,refreshKey:i}){let[a,o]=(0,g.useState)(!1);if((0,g.useEffect)(()=>o(!1),[e,t,i]),a){let[t,i]=Rn[Math.abs(e)%Rn.length];return(0,H.jsx)(`div`,{className:`avatar-fallback`,style:{width:r,height:r,background:`linear-gradient(135deg, ${t}, ${i})`,fontSize:Math.round(r*.42)},children:zn(n)})}return(0,H.jsx)(`img`,{className:`avatar-photo-img`,src:`${t===`channel`?`/api/channels/${e}/avatar`:`/api/accounts/${e}/avatar`}${i===void 0?``:`?v=${encodeURIComponent(String(i))}`}`,alt:``,loading:`lazy`,style:{width:r,height:r},onError:()=>o(!0)})}function Vn({kind:e,id:t,onClose:n,onDone:r}){let{t:i}=U(),[a,o]=(0,g.useState)(null),[s,c]=(0,g.useState)(``),[l,u]=(0,g.useState)(``),[d,f]=(0,g.useState)(!1),[p,m]=(0,g.useState)(``);(0,g.useEffect)(()=>{if(!a){c(``);return}let e=URL.createObjectURL(a);return c(e),()=>URL.revokeObjectURL(e)},[a]);async function h(){if(!a||!l.trim()){m(i(a?`action.reasonRequired`:`avatar.chooseFirst`));return}f(!0),m(``);try{let i=new FormData,o=e===`channel`?`channel_id`:`user_id`;i.set(`metadata`,JSON.stringify({command_id:``,reason:l.trim(),confirm:!0,[o]:t})),i.set(`file`,a,a.name);let s=e===`channel`?await k.setChannelAvatar(i):await k.setAccountAvatar(i);if(s.error){m(s.error);return}r(),n()}catch(e){m(O(e))}finally{f(!1)}}return(0,Ln.createPortal)((0,H.jsx)(`div`,{className:`modal-backdrop`,role:`presentation`,children:(0,H.jsxs)(`section`,{className:`modal command-modal`,role:`dialog`,"aria-modal":`true`,"aria-label":i(`avatar.change`),children:[(0,H.jsxs)(`div`,{className:`modal-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`eyebrow`,children:i(e===`channel`?`common.channel`:`common.account`)}),(0,H.jsx)(`h2`,{children:i(`avatar.change`)})]}),(0,H.jsx)(`button`,{className:`icon-btn`,type:`button`,onClick:n,disabled:d,"aria-label":i(`common.close`),children:(0,H.jsx)(Dt,{size:15})})]}),(0,H.jsxs)(`div`,{className:`command-body`,children:[(0,H.jsxs)(`label`,{className:`gift-file-picker ${a?`has-file`:``}`,children:[(0,H.jsx)(`input`,{type:`file`,accept:`image/png,image/jpeg,image/webp`,onChange:e=>o(e.target.files?.[0]??null)}),s?(0,H.jsx)(`img`,{className:`gift-file-icon`,src:s,alt:``,style:{objectFit:`cover`}}):(0,H.jsx)(Re,{size:22}),(0,H.jsxs)(`span`,{className:`gift-file-copy`,children:[(0,H.jsx)(`span`,{className:`gift-field-label`,children:i(`avatar.new`)}),(0,H.jsx)(`strong`,{children:a?a.name:i(`avatar.chooseImage`)})]}),(0,H.jsx)(`span`,{className:`gift-file-action`,children:i(a?`common.changeFile`:`common.chooseFile`)})]}),(0,H.jsxs)(`label`,{className:`gift-reason-field`,children:[(0,H.jsx)(`span`,{children:i(`action.reason`)}),(0,H.jsx)(`input`,{value:l,placeholder:i(`avatar.reasonPlaceholder`),onChange:e=>u(e.target.value)})]}),p&&(0,H.jsx)(q,{children:p})]}),(0,H.jsxs)(`div`,{className:`modal-actions`,children:[(0,H.jsx)(`button`,{className:`btn`,type:`button`,onClick:n,disabled:d,children:i(`common.close`)}),(0,H.jsxs)(`button`,{className:`btn primary icon-text`,type:`button`,onClick:()=>void h(),disabled:d,children:[d?(0,H.jsx)(te,{className:`spin`,size:15}):(0,H.jsx)(St,{size:15}),` `,i(`avatar.upload`)]})]})]})}),document.body)}function Hn({rows:e,userID:t,onDone:n}){let{t:r}=U();return(0,H.jsxs)(`div`,{className:`authorization-block`,children:[(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table authorization-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:r(`auth.device`)}),(0,H.jsx)(`th`,{children:r(`auth.platform`)}),(0,H.jsx)(`th`,{children:r(`auth.ip`)}),(0,H.jsx)(`th`,{children:r(`auth.lastActive`)}),(0,H.jsx)(`th`,{className:`device-actions-head`,children:r(`common.actions`)})]})}),(0,H.jsxs)(`tbody`,{children:[e.map(e=>(0,H.jsxs)(`tr`,{children:[(0,H.jsxs)(`td`,{className:`device-text`,children:[e.DeviceModel,` `,e.SystemVersion]}),(0,H.jsxs)(`td`,{className:`device-text`,children:[e.Platform,` `,e.AppVersion]}),(0,H.jsx)(`td`,{children:e.IP}),(0,H.jsx)(`td`,{children:G(e.ActiveAt)}),(0,H.jsx)(`td`,{className:`device-actions-cell`,children:(0,H.jsxs)(`div`,{className:`device-actions`,children:[(0,H.jsx)(Z,{label:r(`auth.revokeCurrent`),icon:(0,H.jsx)(He,{size:13}),compact:!0,path:`/api/actions/revoke-sessions`,payload:()=>({user_id:t,hash:e.Hash}),onDone:n}),(0,H.jsx)(Z,{label:r(`auth.keepCurrent`),icon:(0,H.jsx)(lt,{size:13}),compact:!0,path:`/api/actions/revoke-sessions`,payload:()=>({user_id:t,keep_hash:e.Hash}),onDone:n})]})})]},e.Hash)),e.length===0&&(0,H.jsx)(sn,{colSpan:5})]})]})}),(0,H.jsx)(`div`,{className:`danger-zone`,children:(0,H.jsx)(Z,{label:r(`auth.revokeAll`),icon:(0,H.jsx)(pe,{size:15}),path:`/api/actions/revoke-sessions`,payload:()=>({user_id:t,revoke_all:!0}),onDone:n})})]})}function Un({scam:e,fake:t}){let{t:n}=U();return!e&&!t?null:(0,H.jsxs)(H.Fragment,{children:[e&&(0,H.jsx)(J,{tone:`danger`,children:n(`flags.scam`)}),t&&(0,H.jsx)(J,{tone:`danger`,children:n(`flags.fake`)})]})}function Wn({idKey:e,id:t,path:n,scam:r,fake:i,onDone:a}){let{t:o}=U();return(0,H.jsxs)(`div`,{className:`action-stack`,children:[(0,H.jsx)(Z,{label:o(r?`flags.clearScam`:`flags.setScam`),icon:(0,H.jsx)(ct,{size:15}),tone:`danger`,path:n,payload:()=>({[e]:t,scam:!r,fake:r?i:!1}),onDone:a}),(0,H.jsx)(Z,{label:o(i?`flags.clearFake`:`flags.setFake`),icon:(0,H.jsx)(ne,{size:15}),tone:`danger`,path:n,payload:()=>({[e]:t,fake:!i,scam:i?r:!1}),onDone:a})]})}function Gn({id:e,support:t,onDone:n}){let{t:r}=U();return(0,H.jsx)(Z,{label:r(t?`attr.clearSupport`:`attr.setSupport`),icon:(0,H.jsx)(Ve,{size:15}),tone:`neutral`,path:`/api/actions/set-support`,payload:()=>({user_id:e,support:!t}),onDone:n})}function Kn({idKey:e,id:t,path:n,current:r,onDone:i}){let{t:a}=U(),[o,s]=(0,g.useState)(r.replace(/^@/,``));return(0,H.jsxs)(`div`,{className:`attr-block`,children:[(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:a(`attr.username`)}),(0,H.jsx)(`input`,{value:o,onChange:e=>s(e.target.value),placeholder:`username`})]}),(0,H.jsx)(Z,{label:a(`attr.setUsername`),icon:(0,H.jsx)(le,{size:15}),tone:`neutral`,path:n,payload:()=>({[e]:t,username:o.trim().replace(/^@/,``)}),onDone:i})]})}function qn({id:e,firstName:t,lastName:n,onDone:r}){let{t:i}=U(),[a,o]=(0,g.useState)(t),[s,c]=(0,g.useState)(n);return(0,g.useEffect)(()=>{o(t),c(n)},[t,n]),(0,H.jsxs)(`div`,{className:`attr-block`,children:[(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:i(`attr.firstName`)}),(0,H.jsx)(`input`,{value:a,onChange:e=>o(e.target.value),maxLength:64})]}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:i(`attr.lastName`)}),(0,H.jsx)(`input`,{value:s,onChange:e=>c(e.target.value),maxLength:64})]}),(0,H.jsx)(Z,{label:i(`attr.setProfileName`),icon:(0,H.jsx)(Ct,{size:15}),tone:`neutral`,path:`/api/actions/set-account-profile`,payload:()=>({user_id:e,first_name:a.trim(),last_name:s.trim()}),onDone:r})]})}function Jn({id:e,current:t,onDone:n}){let{t:r}=U(),[i,a]=(0,g.useState)(t);return(0,g.useEffect)(()=>a(t),[t]),(0,H.jsxs)(`div`,{className:`attr-block`,children:[(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:r(`attr.phoneNumber`)}),(0,H.jsx)(`input`,{value:i,onChange:e=>a(e.target.value),inputMode:`tel`})]}),(0,H.jsx)(Z,{label:r(`attr.setPhone`),icon:(0,H.jsx)(Xe,{size:15}),tone:`warn`,path:`/api/actions/set-account-phone`,payload:()=>({user_id:e,phone:i.trim()}),onDone:n})]})}function Yn({id:e,current:t,onDone:n}){let{t:r}=U(),[i,a]=(0,g.useState)(t);return(0,g.useEffect)(()=>a(t),[t]),(0,H.jsxs)(`div`,{className:`attr-block`,children:[(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:r(`attr.loginEmail`)}),(0,H.jsx)(`input`,{value:i,onChange:e=>a(e.target.value),type:`email`,placeholder:r(`attr.loginEmailPlaceholder`)})]}),(0,H.jsx)(Z,{label:r(`attr.setLoginEmail`),icon:(0,H.jsx)(Ue,{size:15}),tone:`neutral`,path:`/api/actions/set-account-login-email`,payload:()=>({user_id:e,email:i.trim()}),onDone:n})]})}function Xn({idKey:e,id:t,path:n,onDone:r}){let{t:i}=U(),[a,o]=(0,g.useState)(!1),[s,c]=(0,g.useState)(!0),[l,u]=(0,g.useState)(`0`),[d,f]=(0,g.useState)(``);return(0,H.jsxs)(`div`,{className:`attr-block`,children:[(0,H.jsxs)(`label`,{className:`checkline`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:a,onChange:e=>o(e.target.checked)}),` `,i(`attr.forProfile`)]}),(0,H.jsxs)(`label`,{className:`checkline`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:s,onChange:e=>c(e.target.checked)}),` `,i(`attr.hasColor`)]}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:i(`attr.colorIndex`)}),(0,H.jsx)(`input`,{type:`number`,min:`0`,max:`20`,value:l,onChange:e=>u(e.target.value)})]}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:i(`attr.bgEmojiID`)}),(0,H.jsx)(`input`,{value:d,onChange:e=>f(e.target.value),placeholder:`0`})]}),(0,H.jsx)(Z,{label:i(`attr.setColor`),icon:(0,H.jsx)(Je,{size:15}),tone:`neutral`,path:n,payload:()=>({[e]:t,for_profile:a,has_color:s,color:Ht(l),background_emoji_id:d.trim()||`0`}),onDone:r})]})}function Zn({idKey:e,id:t,path:n,onDone:r}){let{t:i}=U(),[a,o]=(0,g.useState)(``),[s,c]=(0,g.useState)(`0`);return(0,H.jsxs)(`div`,{className:`attr-block`,children:[(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:i(`attr.emojiDocID`)}),(0,H.jsx)(`input`,{value:a,onChange:e=>o(e.target.value),placeholder:`0 = clear`})]}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:i(`attr.emojiUntil`)}),(0,H.jsx)(`input`,{type:`number`,min:`0`,value:s,onChange:e=>c(e.target.value)})]}),(0,H.jsx)(Z,{label:i(`attr.setEmojiStatus`),icon:(0,H.jsx)(pt,{size:15}),tone:`neutral`,path:n,payload:()=>({[e]:t,document_id:a.trim()||`0`,until:Ht(s)}),onDone:r})]})}function Qn({channel:e,onDone:t}){let{t:n}=U(),[r,i]=(0,g.useState)(e.Gigagroup),[a,o]=(0,g.useState)(e.AntiSpam),[s,c]=(0,g.useState)(e.ParticipantsHidden),[l,u]=(0,g.useState)(e.NoForwards),[d,f]=(0,g.useState)(e.JoinToSend),[p,m]=(0,g.useState)(e.JoinRequest),[h,_]=(0,g.useState)(String(e.SlowmodeSeconds));(0,g.useEffect)(()=>{i(e.Gigagroup),o(e.AntiSpam),c(e.ParticipantsHidden),u(e.NoForwards),f(e.JoinToSend),m(e.JoinRequest),_(String(e.SlowmodeSeconds))},[e]);function v(){let t={channel_id:e.ID};return r!==e.Gigagroup&&(t.gigagroup=r),a!==e.AntiSpam&&(t.antispam=a),s!==e.ParticipantsHidden&&(t.participants_hidden=s),l!==e.NoForwards&&(t.noforwards=l),d!==e.JoinToSend&&(t.join_to_send=d),p!==e.JoinRequest&&(t.join_request=p),Ht(h)!==e.SlowmodeSeconds&&(t.slowmode_seconds=Ht(h)),t}return(0,H.jsxs)(`div`,{className:`attr-block`,children:[(0,H.jsxs)(`label`,{className:`checkline`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:r,onChange:e=>i(e.target.checked)}),` `,n(`attr.gigagroup`)]}),(0,H.jsxs)(`label`,{className:`checkline`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:a,onChange:e=>o(e.target.checked)}),` `,n(`attr.antispam`)]}),(0,H.jsxs)(`label`,{className:`checkline`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:s,onChange:e=>c(e.target.checked)}),` `,n(`attr.participantsHidden`)]}),(0,H.jsxs)(`label`,{className:`checkline`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:l,onChange:e=>u(e.target.checked)}),` `,n(`attr.noforwards`)]}),(0,H.jsxs)(`label`,{className:`checkline`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:d,onChange:e=>f(e.target.checked)}),` `,n(`attr.joinToSend`)]}),(0,H.jsxs)(`label`,{className:`checkline`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:p,onChange:e=>m(e.target.checked)}),` `,n(`attr.joinRequest`)]}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:n(`attr.slowmode`)}),(0,H.jsx)(`input`,{type:`number`,min:`0`,max:`86400`,value:h,onChange:e=>_(e.target.value)})]}),(0,H.jsx)(Z,{label:n(`attr.applySettings`),icon:(0,H.jsx)(st,{size:15}),tone:`warn`,path:`/api/actions/set-channel-settings`,payload:v,onDone:t})]})}function $n({id:e,navigate:t}){let{t:n}=U(),[r,i]=(0,g.useState)(null),[a,o]=(0,g.useState)(``),[s,c]=(0,g.useState)(!1),[l,u]=(0,g.useState)(`1`),[d,f]=(0,g.useState)(`1000`),[p,m]=(0,g.useState)(()=>er(new Date(Date.now()+7*864e5))),[h,_]=(0,g.useState)(``),[v,y]=(0,g.useState)(!1),[b,x]=(0,g.useState)(0);async function S(){c(!0),o(``);try{let t=await k.account(e);i(t),t.Restriction.Frozen&&(t.Restriction.Until&&m(er(new Date(t.Restriction.Until))),_(t.Restriction.AppealURL||``))}catch(e){o(O(e))}finally{c(!1)}}if((0,g.useEffect)(()=>{S()},[e]),a)return(0,H.jsx)(q,{children:a});if(!r)return(0,H.jsx)(cn,{label:n(s?`account.loadingDetail`:`account.waitingData`)});let C=r.Account;return(0,H.jsxs)(nn,{title:n(`account.detailTitle`,{id:C.ID}),eyebrow:n(`account.profile`),actions:(0,H.jsxs)(`button`,{className:`btn icon-text`,onClick:()=>t(`/accounts`),children:[(0,H.jsx)(ce,{size:15}),` `,n(`common.backToList`)]}),children:[(0,H.jsx)(an,{main:(0,H.jsxs)(`div`,{className:`stacked-sections`,children:[(0,H.jsxs)(`section`,{className:`entity-head`,children:[(0,H.jsxs)(`div`,{className:`entity-identity`,children:[(0,H.jsx)(Bn,{id:C.ID,label:Rt(C),size:64,refreshKey:b}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`entity-title`,children:Rt(C)}),(0,H.jsxs)(`div`,{className:`entity-subtitle`,children:[W(C.Username)||n(`account.noUsername`),` · `,Lt(C.Phone)||n(`account.noPhone`)]}),C.Collectibles?.length>0&&(0,H.jsx)(`div`,{className:`entity-subtitle`,children:(0,H.jsx)(un,{username:``,collectibles:C.Collectibles})})]})]}),(0,H.jsxs)(`div`,{className:`entity-badges`,children:[C.PremiumUntil>0?(0,H.jsx)(J,{tone:`good`,children:n(`account.premium`)}):(0,H.jsx)(J,{children:n(`account.notPremium`)}),r.Verified?(0,H.jsx)(J,{tone:`good`,children:n(`common.verified`)}):(0,H.jsx)(J,{children:n(`account.notVerified`)}),(0,H.jsx)(Un,{scam:r.Scam,fake:r.Fake}),C.Frozen?(0,H.jsx)(J,{tone:`danger`,children:n(`account.accountFrozen`)}):(0,H.jsx)(J,{children:n(`account.accountActive`)})]})]}),(0,H.jsxs)(`div`,{className:`summary-grid`,children:[(0,H.jsx)(X,{label:n(`account.userID`),value:String(C.ID),mono:!0}),(0,H.jsx)(X,{label:n(`account.lastActive`),value:Bt(r.LastSeenAt)||`-`}),(0,H.jsx)(X,{label:n(`account.premiumUntil`),value:C.PremiumUntil>0?Bt(C.PremiumUntil):n(`common.none`)}),(0,H.jsx)(X,{label:n(`account.starsBalance`),value:`${r.StarsBalance} / ${r.StarsGranted?n(`account.startingGrantApplied`):n(`account.startingGrantPending`)}`}),(0,H.jsx)(X,{label:n(`common.updatedAt`),value:G(C.UpdatedAt)||`-`}),(0,H.jsx)(X,{label:n(`account.activeSessions`),value:String(r.Authorizations.length)}),(0,H.jsx)(X,{label:n(`account.accountFlags`),value:`support=${r.Support} bot=${r.Bot}`}),(0,H.jsx)(X,{label:n(`attr.loginEmail`),value:r.LoginEmail||n(`common.none`)}),(0,H.jsx)(X,{label:n(`account.restriction`),value:r.HasRestriction?r.Restriction.Reason||n(`account.restricted`):n(`common.none`)}),(0,H.jsx)(X,{label:n(`account.freezeSince`),value:r.Restriction.Since?G(r.Restriction.Since):n(`common.none`)}),(0,H.jsx)(X,{label:n(`account.freezeUntil`),value:r.Restriction.Until?G(r.Restriction.Until):n(`common.none`)}),(0,H.jsx)(X,{label:n(`account.freezeAppealURL`),value:r.Restriction.AppealURL||n(`common.none`)}),(0,H.jsx)(X,{label:n(`account.createdAt`),value:G(C.CreatedAt)||`-`})]}),r.About&&(0,H.jsx)(`p`,{className:`about-text`,children:r.About}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:n(`account.authorizationsTitle`),text:n(`account.authorizationsCount`,{count:r.Authorizations.length})}),(0,H.jsx)(Hn,{rows:r.Authorizations,userID:C.ID,onDone:S})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:n(`account.recentAdminOps`),text:n(`account.recent30Audit`)}),(0,H.jsx)(on,{rows:r.AuditLogs})]})]}),side:(0,H.jsxs)(`section`,{className:`action-dock`,children:[(0,H.jsx)(`div`,{className:`dock-title`,children:n(`account.actionDock`)}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:n(`account.freezeUntil`)}),(0,H.jsx)(`input`,{"aria-label":n(`account.freezeUntilAria`),value:p,onChange:e=>m(e.target.value),type:`datetime-local`})]}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:n(`account.freezeAppealURL`)}),(0,H.jsx)(`input`,{"aria-label":n(`account.freezeAppealURLAria`),value:h,onChange:e=>_(e.target.value),type:`url`,placeholder:`https://...`})]}),(0,H.jsx)(Z,{label:C.Frozen?n(`account.updateFreeze`):n(`account.freezeAccount`),icon:(0,H.jsx)(I,{size:15}),path:`/api/actions/set-frozen`,payload:()=>({user_id:C.ID,frozen:!0,freeze_until:new Date(p).toISOString(),freeze_appeal_url:h.trim()}),onDone:S}),C.Frozen&&(0,H.jsx)(Z,{label:n(`account.unfreezeAccount`),icon:(0,H.jsx)(I,{size:15}),path:`/api/actions/set-frozen`,payload:()=>({user_id:C.ID,frozen:!1}),onDone:S}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:n(`account.premiumMonths`)}),(0,H.jsx)(`input`,{"aria-label":n(`account.premiumMonthsAria`),value:l,onChange:e=>u(e.target.value),type:`number`,min:`1`,max:`120`})]}),(0,H.jsxs)(`div`,{className:`action-stack`,children:[(0,H.jsx)(Z,{label:n(`account.setPremium`),icon:(0,H.jsx)(re,{size:15}),tone:`warn`,path:`/api/actions/grant-premium`,payload:()=>({user_id:C.ID,months:Ht(l)}),onDone:S}),(0,H.jsx)(Z,{label:n(`account.clearPremium`),icon:(0,H.jsx)(re,{size:15}),tone:`warn`,path:`/api/actions/grant-premium`,payload:()=>({user_id:C.ID,months:0}),onDone:S}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:n(`account.starsAmount`)}),(0,H.jsx)(`input`,{"aria-label":n(`account.starsAmountAria`),value:d,onChange:e=>f(e.target.value),type:`number`,min:`1`,max:`1000000000`})]}),(0,H.jsx)(Z,{label:n(`account.grantStars`),icon:(0,H.jsx)(ht,{size:15}),tone:`warn`,path:`/api/actions/grant-stars`,payload:()=>({user_id:C.ID,amount:Ht(d)}),onDone:S}),(0,H.jsx)(Z,{label:r.Verified?n(`account.clearVerified`):n(`account.setVerified`),icon:(0,H.jsx)(F,{size:15}),tone:`warn`,path:`/api/actions/set-verified`,payload:()=>({user_id:C.ID,verified:!r.Verified}),onDone:S})]}),(0,H.jsx)(Wn,{idKey:`user_id`,id:C.ID,path:`/api/actions/set-account-flags`,scam:r.Scam,fake:r.Fake,onDone:S}),(0,H.jsx)(`div`,{className:`dock-title`,children:n(`attr.attributes`)}),(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>y(!0),children:[(0,H.jsx)(Re,{size:15}),` `,n(`avatar.change`)]}),(0,H.jsx)(qn,{id:C.ID,firstName:C.FirstName,lastName:C.LastName,onDone:S}),(0,H.jsx)(Jn,{id:C.ID,current:C.Phone,onDone:S}),(0,H.jsx)(Yn,{id:C.ID,current:r.LoginEmail,onDone:S}),(0,H.jsx)(Gn,{id:C.ID,support:r.Support,onDone:S}),(0,H.jsx)(Kn,{idKey:`user_id`,id:C.ID,path:`/api/actions/set-account-username`,current:C.Username,onDone:S}),(0,H.jsx)(Xn,{idKey:`user_id`,id:C.ID,path:`/api/actions/set-account-color`,onDone:S}),(0,H.jsx)(Zn,{idKey:`user_id`,id:C.ID,path:`/api/actions/set-account-emoji-status`,onDone:S})]})}),v&&(0,H.jsx)(Vn,{kind:`user`,id:C.ID,onClose:()=>y(!1),onDone:()=>{x(e=>e+1),S()}})]})}function er(e){return new Date(e.getTime()-e.getTimezoneOffset()*6e4).toISOString().slice(0,16)}function tr({navigate:e}){let{t}=U(),[n,r]=(0,g.useState)(``),[i,a]=(0,g.useState)(``),[o,s]=(0,g.useState)(`50`),[c,l]=(0,g.useState)([]),[u,d]=(0,g.useState)(!1),[f,p]=(0,g.useState)(``),[m,h]=(0,g.useState)(!1),[_,v]=(0,g.useState)(``);async function y(e=!1){let t=i.trim();h(!0),v(``);let r=new URLSearchParams({limit:o});n.trim()&&r.set(`min_level`,n.trim()),t&&r.set(`q`,t),e&&f&&r.set(`before_id`,f);try{let t=await k.accountRatings(r),n=t.rows??[];l(t=>e?[...t,...n]:n),p(t.next_before_id??``),d(!!t.has_more)}catch(e){v(O(e))}finally{h(!1)}}(0,g.useEffect)(()=>{y(!1)},[]);let b=c.reduce((e,t)=>Math.max(e,t.Level),0),x=c.filter(e=>Ut(e.PendingStars)!==0).length,S=c.length>0?(c.reduce((e,t)=>e+t.Level,0)/c.length).toFixed(1):`0`;return(0,H.jsxs)(nn,{title:t(`rating.pageTitle`),eyebrow:t(`rating.eyebrow`),actions:(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>y(!1),disabled:m,children:[(0,H.jsx)(nt,{size:15,className:m?`spin`:``}),` `,t(`common.refresh`)]}),children:[_&&(0,H.jsx)(q,{children:_}),(0,H.jsxs)(`div`,{className:`metric-row`,children:[(0,H.jsx)(Y,{label:t(`rating.metricLoaded`),value:String(c.length)}),(0,H.jsx)(Y,{label:t(`rating.metricTopLevel`),value:String(b),tone:`good`}),(0,H.jsx)(Y,{label:t(`rating.metricAvgLevel`),value:S}),(0,H.jsx)(Y,{label:t(`rating.metricPending`),value:String(x),tone:x?`warn`:`neutral`})]}),(0,H.jsx)(rn,{children:(0,H.jsxs)(`form`,{className:`toolbar`,onSubmit:e=>{e.preventDefault(),y(!1)},children:[(0,H.jsxs)(`label`,{className:`searchbox`,children:[(0,H.jsx)(it,{size:15}),(0,H.jsx)(`input`,{value:i,onChange:e=>a(e.target.value),placeholder:t(`rating.searchPlaceholder`)})]}),(0,H.jsxs)(`label`,{className:`field-inline`,children:[(0,H.jsx)(`span`,{children:t(`rating.minLevel`)}),(0,H.jsx)(`input`,{className:`small-input`,value:n,onChange:e=>r(e.target.value),type:`number`,min:`0`,placeholder:`0`})]}),(0,H.jsxs)(`label`,{className:`field-inline`,children:[(0,H.jsx)(`span`,{children:t(`common.limit`)}),(0,H.jsx)(`input`,{className:`small-input`,value:o,onChange:e=>s(e.target.value),type:`number`,min:`1`,max:`200`})]}),(0,H.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,disabled:m,children:[m?(0,H.jsx)(te,{size:15,className:`spin`}):(0,H.jsx)(it,{size:15}),` `,t(`common.search`)]})]})}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:t(`rating.userID`)}),(0,H.jsx)(`th`,{children:t(`common.username`)}),(0,H.jsx)(`th`,{children:t(`rating.level`)}),(0,H.jsx)(`th`,{children:t(`rating.stars`)}),(0,H.jsx)(`th`,{children:t(`rating.progress`)}),(0,H.jsx)(`th`,{children:t(`rating.pending`)}),(0,H.jsx)(`th`,{children:t(`rating.computedAt`)}),(0,H.jsx)(`th`,{})]})}),(0,H.jsxs)(`tbody`,{children:[c.map(n=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{className:`mono`,children:n.UserID}),(0,H.jsx)(`td`,{children:W(n.Username)||n.FirstName||`-`}),(0,H.jsx)(`td`,{children:(0,H.jsx)(nr,{level:n.Level})}),(0,H.jsx)(`td`,{className:`mono`,children:Wt(n.Stars)}),(0,H.jsx)(`td`,{children:(0,H.jsx)(ir,{row:n})}),(0,H.jsx)(`td`,{className:`mono`,children:Ut(n.PendingStars)===0?`-`:Wt(n.PendingStars)}),(0,H.jsx)(`td`,{children:G(n.ComputedAt)||`-`}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`button`,{className:`row-link`,type:`button`,onClick:()=>e(`/account-ratings/${n.UserID}`),children:[(0,H.jsx)(bt,{size:14}),` `,t(`common.detail`),` `,(0,H.jsx)(ve,{size:14})]})})]},n.UserID)),c.length===0&&(0,H.jsx)(sn,{colSpan:8})]})]})}),u&&(0,H.jsx)(`div`,{className:`toolbar`,children:(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>y(!0),disabled:m,children:[m?(0,H.jsx)(te,{size:15,className:`spin`}):(0,H.jsx)(ge,{size:15}),` `,t(`common.loadMore`)]})})]})}function nr({level:e}){let{t}=U();return(0,H.jsx)(J,{tone:e>=10?`good`:e>=5?`warn`:`neutral`,children:t(`rating.levelValue`,{level:e})})}function rr(e){let t=Ut(e.Stars),n=Ut(e.CurrentLevelStars),r=Ut(e.NextLevelStars),i=r-n;return{percent:i>0?Math.min(100,Math.max(0,(t-n)/i*100)):0,remaining:Math.max(0,r-t),target:r,stars:t}}function ir({row:e}){let{t}=U();if(!e.HasNextLevel)return(0,H.jsx)(`span`,{className:`progress-note`,children:t(`rating.maxLevel`)});let{percent:n,remaining:r,target:i}=rr(e);return(0,H.jsxs)(`div`,{className:`progress-cell`,children:[(0,H.jsx)(`div`,{className:`progress-bar`,role:`img`,"aria-label":`${Math.round(n)}%`,children:(0,H.jsx)(`span`,{style:{width:`${n}%`}})}),(0,H.jsx)(`small`,{children:t(`rating.progressHint`,{remaining:Wt(String(r)),target:Wt(String(i))})})]})}function ar({userID:e,navigate:t}){let{t:n}=U(),[r,i]=(0,g.useState)(null),[a,o]=(0,g.useState)(``),[s,c]=(0,g.useState)(!1),[l,u]=(0,g.useState)(``);async function d(){c(!0),o(``);try{i(await k.accountRating(e))}catch(e){o(O(e))}finally{c(!1)}}if((0,g.useEffect)(()=>{d()},[e]),a&&!r)return(0,H.jsx)(q,{children:a});if(!r)return(0,H.jsx)(cn,{label:n(s?`rating.loadingDetail`:`account.waitingData`)});let f=r.rating,p=r.events??[],m=Ut(f.PendingStars),h=rr(f),_=f.UserID||e;return(0,H.jsxs)(nn,{title:n(`rating.detailTitle`,{user:W(f.Username)||f.FirstName||f.UserID}),eyebrow:n(`rating.detailEyebrow`),actions:(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>t(`/account-ratings`),children:[(0,H.jsx)(ce,{size:15}),` `,n(`common.backToList`)]}),(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:d,disabled:s,children:[(0,H.jsx)(nt,{size:15,className:s?`spin`:``}),` `,n(`common.refresh`)]})]}),children:[a&&(0,H.jsx)(q,{children:a}),(0,H.jsx)(an,{main:(0,H.jsxs)(`div`,{className:`stacked-sections`,children:[(0,H.jsxs)(`section`,{className:`entity-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`entity-title`,children:W(f.Username)||f.FirstName||n(`bots.unnamed`)}),(0,H.jsxs)(`div`,{className:`entity-subtitle`,children:[n(`rating.userID`),`: `,f.UserID]})]}),(0,H.jsxs)(`div`,{className:`entity-badges`,children:[(0,H.jsx)(nr,{level:f.Level}),m!==0&&(0,H.jsx)(J,{tone:`warn`,children:n(`rating.pendingBadge`,{amount:Qt(f.PendingStars)})})]})]}),(0,H.jsxs)(`div`,{className:`metric-row`,children:[(0,H.jsx)(Y,{label:n(`rating.stars`),value:Wt(f.Stars),mono:!0}),(0,H.jsx)(Y,{label:n(`rating.level`),value:String(f.Level),tone:`good`}),(0,H.jsx)(Y,{label:n(`rating.nextLevel`),value:f.HasNextLevel?Wt(f.NextLevelStars):n(`rating.maxLevel`),mono:f.HasNextLevel}),(0,H.jsx)(Y,{label:n(`rating.toNextLevel`),value:f.HasNextLevel?Wt(String(h.remaining)):`-`,mono:!0,tone:f.HasNextLevel&&h.percent>=80?`good`:`neutral`})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:n(`rating.breakdownTitle`),text:n(`rating.breakdownHint`)}),(0,H.jsx)(or,{rating:f}),(0,H.jsxs)(`div`,{className:`summary-grid`,children:[(0,H.jsx)(X,{label:n(`rating.currentLevelStars`),value:Wt(f.CurrentLevelStars),mono:!0}),(0,H.jsx)(X,{label:n(`rating.nextLevelStars`),value:f.HasNextLevel?Wt(f.NextLevelStars):n(`rating.maxLevel`),mono:f.HasNextLevel}),(0,H.jsx)(X,{label:n(`rating.computedAt`),value:G(f.ComputedAt)||`-`}),(0,H.jsx)(X,{label:n(`common.updatedAt`),value:G(f.UpdatedAt)||`-`})]}),(0,H.jsx)(`div`,{className:`progress-wide`,children:(0,H.jsx)(ir,{row:f})})]}),m!==0&&(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:n(`rating.pendingTitle`),text:n(`rating.pendingHint`)}),(0,H.jsxs)(`div`,{className:`summary-grid`,children:[(0,H.jsx)(X,{label:n(`rating.pending`),value:Qt(f.PendingStars),mono:!0}),(0,H.jsx)(X,{label:n(`rating.pendingDate`),value:G(f.PendingDate)||`-`})]})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:n(`rating.eventsTitle`),text:n(`rating.eventsHint`)}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:n(`common.id`)}),(0,H.jsx)(`th`,{children:n(`rating.eventKind`)}),(0,H.jsx)(`th`,{children:n(`rating.amount`)}),(0,H.jsx)(`th`,{children:n(`audit.reason`)}),(0,H.jsx)(`th`,{children:n(`audit.actor`)}),(0,H.jsx)(`th`,{children:n(`common.time`)})]})}),(0,H.jsxs)(`tbody`,{children:[p.map(e=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{className:`mono`,children:e.ID}),(0,H.jsx)(`td`,{children:(0,H.jsx)(sr,{kind:e.Kind})}),(0,H.jsx)(`td`,{className:`mono`,children:Qt(e.Amount)}),(0,H.jsx)(`td`,{className:`truncate`,children:e.Reason||`-`}),(0,H.jsx)(`td`,{children:e.Actor||`-`}),(0,H.jsx)(`td`,{children:G(e.CreatedAt)||`-`})]},e.ID)),p.length===0&&(0,H.jsx)(sn,{colSpan:6})]})]})})]})]}),side:(0,H.jsxs)(`section`,{className:`action-dock`,children:[(0,H.jsx)(`div`,{className:`dock-title`,children:n(`rating.actionDock`)}),(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>t(`/accounts/${f.UserID}`),children:[(0,H.jsx)(wt,{size:15}),` `,n(`rating.openAccount`)]}),(0,H.jsx)(`div`,{className:`action-stack`,children:(0,H.jsx)(Z,{label:n(`rating.recompute`),icon:(0,H.jsx)(me,{size:15}),tone:`neutral`,path:`/api/actions/recompute-account-rating`,payload:()=>({user_id:_}),onDone:d})}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`rating.recomputeHint`)}),(0,H.jsx)(`div`,{className:`dock-title`,children:n(`rating.adjustTitle`)}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:n(`rating.adjustAmount`)}),(0,H.jsx)(`input`,{value:l,onChange:e=>u(e.target.value),type:`number`,step:`1`,placeholder:`-500`})]}),(0,H.jsx)(`div`,{className:`action-stack`,children:(0,H.jsx)(Z,{label:n(`rating.adjust`),icon:(0,H.jsx)(ft,{size:15}),tone:`warn`,path:`/api/actions/adjust-account-rating`,payload:()=>({user_id:_,amount:String(Number.parseInt(l.trim()||`0`,10)||0)}),onDone:()=>{u(``),d()}})}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`rating.adjustHint`)})]})})]})}function or({rating:e}){let{t}=U(),n=[{key:`stars`,label:t(`rating.componentStars`),hint:t(`rating.componentStarsHint`),value:Ut(e.StarsComponent)},{key:`activity`,label:t(`rating.componentActivity`),hint:t(`rating.componentActivityHint`),value:Ut(e.ActivityComponent)},{key:`penalty`,label:t(`rating.componentPenalty`),hint:t(`rating.componentPenaltyHint`),value:-Ut(e.PenaltyComponent)},{key:`manual`,label:t(`rating.componentManual`),hint:t(`rating.componentManualHint`),value:Ut(e.ManualComponent)}],r=Math.max(1,...n.map(e=>Math.abs(e.value))),i=Math.max(0,n.reduce((e,t)=>e+t.value,0)),a=Ut(e.Stars),o=Ut(e.PendingStars);return(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`div`,{className:`breakdown-list`,children:[n.map(e=>{let t=Math.min(100,Math.abs(e.value)/r*100),n=e.value<0?`danger`:e.value>0?`good`:``;return(0,H.jsxs)(`div`,{className:`breakdown-row`,children:[(0,H.jsxs)(`div`,{className:`breakdown-label`,children:[(0,H.jsx)(`strong`,{children:e.label}),(0,H.jsx)(`small`,{children:e.hint})]}),(0,H.jsx)(`div`,{className:`progress-bar ${n}`,role:`img`,"aria-label":String(e.value),children:(0,H.jsx)(`span`,{style:{width:`${t}%`}})}),(0,H.jsx)(`div`,{className:`breakdown-value mono ${n}`,children:Qt(String(e.value))})]},e.key)}),(0,H.jsxs)(`div`,{className:`breakdown-row total`,children:[(0,H.jsx)(`div`,{className:`breakdown-label`,children:(0,H.jsx)(`strong`,{children:t(`rating.componentTotal`)})}),(0,H.jsx)(`div`,{className:`breakdown-value mono`,children:Wt(e.Stars)})]})]}),o===0&&i!==a&&(0,H.jsx)(q,{children:t(`rating.breakdownMismatch`,{sum:Wt(String(i)),total:Wt(e.Stars)})}),o!==0&&(0,H.jsx)(`p`,{className:`bot-create-note`,children:t(`rating.breakdownPending`,{amount:Qt(e.PendingStars)})})]})}function sr({kind:e}){let{t}=U();return(0,H.jsx)(J,{tone:e===`moderation`?`danger`:e===`manual`?`warn`:e===`recompute`?`neutral`:`good`,children:t(`rating.kind.${e}`)})}function cr(e){return e.reduce((e,t)=>(e.devices+=t.DeviceCount,t.PremiumUntil>0&&(e.premium+=1),t.Frozen&&(e.frozen+=1),e),{devices:0,premium:0,frozen:0})}function lr(e){return e.reduce((e,t)=>(t.Megagroup&&(e.megagroups+=1),t.Broadcast&&(e.broadcasts+=1),t.Verified&&(e.verified+=1),e),{megagroups:0,broadcasts:0,verified:0})}var ur={beforeID:0,beforeActiveUS:0};function dr({navigate:e}){let{t}=U(),[n,r]=(0,g.useState)(``),[i,a]=(0,g.useState)(`50`),[o,s]=(0,g.useState)(null),[c,l]=(0,g.useState)({q:``,limit:`50`}),[u,d]=(0,g.useState)(ur),[f,p]=(0,g.useState)([]),[m,h]=(0,g.useState)(!1),[_,v]=(0,g.useState)(``);async function y(e,t=c,n=!1){h(!0),v(``);let r=new URLSearchParams({limit:t.limit});t.q?r.set(`q`,t.q):e.beforeID>0&&(r.set(`before_id`,String(e.beforeID)),r.set(`before_active_us`,String(e.beforeActiveUS)));try{return s(await k.accounts(r)),l(t),d(e),n&&p([]),!0}catch(e){return v(O(e)),!1}finally{h(!1)}}async function b(){await y(ur,{q:n.trim(),limit:i},!0)}async function x(){!o?.listing||!o.has_more||await y({beforeID:o.next_before_id,beforeActiveUS:o.next_before_active_us})&&p(e=>[...e,u])}async function S(){let e=f[f.length-1];e&&await y(e)&&p(e=>e.slice(0,-1))}(0,g.useEffect)(()=>{y(ur,{q:``,limit:`50`},!0)},[]);let C=cr(o?.rows??[]);return(0,H.jsxs)(nn,{title:t(`account.pageTitle`),eyebrow:o?.listing===!1?t(`account.queryResults`):t(`account.recentActive`),actions:(0,H.jsxs)(`button`,{className:`btn`,type:`button`,onClick:()=>y(u),disabled:m,children:[(0,H.jsx)(nt,{size:15}),` `,t(`common.refresh`)]}),children:[_&&(0,H.jsx)(q,{children:_}),(0,H.jsxs)(`div`,{className:`metric-row`,children:[(0,H.jsx)(Y,{label:t(`account.currentPage`),value:String(o?.rows.length??0)}),(0,H.jsx)(Y,{label:t(`account.onlineDevices`),value:String(C.devices)}),(0,H.jsx)(Y,{label:t(`account.premium`),value:String(C.premium),tone:`good`}),(0,H.jsx)(Y,{label:t(`account.frozen`),value:String(C.frozen),tone:C.frozen>0?`danger`:`neutral`})]}),(0,H.jsx)(rn,{children:(0,H.jsxs)(`form`,{className:`toolbar`,onSubmit:e=>{e.preventDefault(),b()},children:[(0,H.jsxs)(`label`,{className:`searchbox`,children:[(0,H.jsx)(it,{size:15}),(0,H.jsx)(`input`,{value:n,onChange:e=>r(e.target.value),placeholder:t(`account.searchPlaceholder`)})]}),(0,H.jsxs)(`label`,{className:`field-inline`,children:[(0,H.jsx)(`span`,{children:t(`common.limit`)}),(0,H.jsx)(`input`,{className:`small-input`,value:i,onChange:e=>a(e.target.value),type:`number`,min:`1`,max:`100`})]}),(0,H.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,disabled:m,children:[m?(0,H.jsx)(te,{size:15,className:`spin`}):(0,H.jsx)(it,{size:15}),` `,t(`common.search`)]}),o?.listing&&f.length>0&&(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>S(),disabled:m,children:[(0,H.jsx)(_e,{size:15}),` `,t(`messages.previousPage`)]}),o?.listing&&o.has_more&&(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>x(),disabled:m,children:[(0,H.jsx)(ve,{size:15}),` `,t(`messages.nextPage`)]})]})}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:t(`account.userID`)}),(0,H.jsx)(`th`,{children:t(`account.phone`)}),(0,H.jsx)(`th`,{children:t(`common.username`)}),(0,H.jsx)(`th`,{children:t(`common.name`)}),(0,H.jsx)(`th`,{children:t(`common.device`)}),(0,H.jsx)(`th`,{children:t(`account.lastActive`)}),(0,H.jsx)(`th`,{children:t(`account.premium`)}),(0,H.jsx)(`th`,{children:t(`common.verified`)}),(0,H.jsx)(`th`,{children:t(`account.frozen`)}),(0,H.jsx)(`th`,{children:t(`common.updatedAt`)}),(0,H.jsx)(`th`,{})]})}),(0,H.jsxs)(`tbody`,{children:[o?.rows.map(n=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{className:`mono`,children:n.ID}),(0,H.jsx)(`td`,{children:Lt(n.Phone)}),(0,H.jsx)(`td`,{children:(0,H.jsx)(un,{username:n.Username,collectibles:n.Collectibles})}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`span`,{className:`table-identity`,children:[(0,H.jsx)(Bn,{id:n.ID,label:Rt(n)}),Rt(n)]})}),(0,H.jsx)(`td`,{children:n.DeviceCount}),(0,H.jsx)(`td`,{children:G(n.LastActiveAt)}),(0,H.jsx)(`td`,{children:n.PremiumUntil>0?(0,H.jsxs)(J,{tone:`good`,children:[t(`account.premium`),` `,Bt(n.PremiumUntil)]}):(0,H.jsx)(J,{children:t(`common.none`)})}),(0,H.jsxs)(`td`,{children:[n.Verified?(0,H.jsx)(J,{tone:`good`,children:t(`common.verified`)}):(0,H.jsx)(J,{children:t(`account.notVerified`)}),` `,(0,H.jsx)(Un,{scam:n.Scam,fake:n.Fake})]}),(0,H.jsx)(`td`,{children:n.Frozen?(0,H.jsx)(J,{tone:`danger`,children:t(`account.frozen`)}):(0,H.jsx)(J,{children:t(`common.normal`)})}),(0,H.jsx)(`td`,{children:G(n.UpdatedAt)}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`button`,{className:`row-link`,onClick:()=>e(`/accounts/${n.ID}`),children:[t(`common.detail`),` `,(0,H.jsx)(ve,{size:14})]})})]},n.ID)),(!o||o.rows.length===0)&&(0,H.jsx)(sn,{colSpan:11})]})]})})]})}function fr({label:e,value:t,onChange:n,variant:r=`panel`}){let{t:i}=U(),[a,o]=(0,g.useState)(``),[s,c]=(0,g.useState)([]),[l,u]=(0,g.useState)(!1),[d,f]=(0,g.useState)(``),[p,m]=(0,g.useState)(!1);async function h(e=!1){e&&r===`dropdown`&&m(!0),u(!0),f(``);let t=new URLSearchParams({limit:`20`});a.trim()&&t.set(`q`,a.trim());try{c((await k.accounts(t)).rows)}catch(e){f(O(e))}finally{u(!1)}}return(0,g.useEffect)(()=>{h(!1)},[]),(0,H.jsxs)(`div`,{className:`entity-picker ${r===`dropdown`?`entity-picker-dropdown`:``}`,onBlur:e=>{r===`dropdown`&&!e.currentTarget.contains(e.relatedTarget)&&m(!1)},children:[(0,H.jsxs)(`div`,{className:`picker-head`,children:[(0,H.jsx)(`span`,{children:e}),t?(0,H.jsxs)(`button`,{className:`link-button`,type:`button`,onClick:()=>{n(null),m(!0)},children:[(0,H.jsx)(Dt,{size:13}),` `,i(`common.clear`)]}):null]}),t?(0,H.jsxs)(`div`,{className:`selected-entity`,children:[(0,H.jsx)(he,{size:15}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:Rt(t)}),(0,H.jsx)(`span`,{className:`mono`,children:t.ID})]}),(0,H.jsx)(`span`,{children:W(t.Username)||Lt(t.Phone)||`-`})]}):null,(!t||r!==`dropdown`)&&(0,H.jsxs)(`div`,{className:`picker-search`,children:[(0,H.jsx)(it,{size:15}),(0,H.jsx)(`input`,{value:a,onChange:e=>o(e.target.value),onFocus:()=>{r===`dropdown`&&m(!0)},onKeyDown:e=>{e.key===`Enter`&&(e.preventDefault(),h(!0))},placeholder:i(`picker.userPlaceholder`)}),(0,H.jsx)(`button`,{className:`btn compact-btn`,type:`button`,onClick:()=>void h(!0),disabled:l,children:l?(0,H.jsx)(te,{size:14,className:`spin`}):i(`common.search`)})]}),d&&(0,H.jsx)(`div`,{className:`picker-error`,children:d}),(r!==`dropdown`||p)&&(0,H.jsxs)(`div`,{className:`picker-results`,children:[s.map(e=>(0,H.jsxs)(`button`,{className:`picker-row ${t?.ID===e.ID?`selected`:``}`,type:`button`,onClick:()=>{n(e),m(!1)},children:[(0,H.jsx)(`span`,{className:`mono`,children:e.ID}),(0,H.jsx)(`strong`,{children:Rt(e)}),(0,H.jsx)(`span`,{children:W(e.Username)||Lt(e.Phone)||`-`}),e.Verified?(0,H.jsx)(J,{tone:`good`,children:i(`picker.verified`)}):(0,H.jsx)(J,{children:i(`picker.regular`)})]},e.ID)),s.length===0&&!l?(0,H.jsx)(`div`,{className:`picker-empty`,children:i(`common.noResults`)}):null]})]})}function pr({label:e,value:t,onChange:n}){let{t:r}=U(),[i,a]=(0,g.useState)(``),[o,s]=(0,g.useState)([]),[c,l]=(0,g.useState)(!1),[u,d]=(0,g.useState)(``);async function f(){l(!0),d(``);let e=new URLSearchParams({limit:`20`});i.trim()&&e.set(`q`,i.trim().replace(/^@/,``));try{s((await k.bots(e)).rows??[])}catch(e){d(O(e))}finally{l(!1)}}return(0,g.useEffect)(()=>{f()},[]),(0,H.jsxs)(`div`,{className:`entity-picker`,children:[(0,H.jsxs)(`div`,{className:`picker-head`,children:[(0,H.jsx)(`span`,{children:e}),t?(0,H.jsxs)(`button`,{className:`link-button`,type:`button`,onClick:()=>n(null),children:[(0,H.jsx)(Dt,{size:13}),` `,r(`common.clear`)]}):null]}),t?(0,H.jsxs)(`div`,{className:`selected-entity`,children:[(0,H.jsx)(he,{size:15}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:t.FirstName||`-`}),(0,H.jsx)(`span`,{className:`mono`,children:t.ID})]}),(0,H.jsx)(`span`,{children:W(t.Username)||`-`})]}):null,(0,H.jsxs)(`div`,{className:`picker-search`,children:[(0,H.jsx)(it,{size:15}),(0,H.jsx)(`input`,{value:i,onChange:e=>a(e.target.value),onKeyDown:e=>{e.key===`Enter`&&(e.preventDefault(),f())},placeholder:r(`picker.botPlaceholder`)}),(0,H.jsx)(`button`,{className:`btn compact-btn`,type:`button`,onClick:f,disabled:c,children:c?(0,H.jsx)(te,{size:14,className:`spin`}):r(`common.search`)})]}),u&&(0,H.jsx)(`div`,{className:`picker-error`,children:u}),(0,H.jsxs)(`div`,{className:`picker-results`,children:[o.map(e=>(0,H.jsxs)(`button`,{className:`picker-row ${t?.ID===e.ID?`selected`:``}`,type:`button`,onClick:()=>n(e),children:[(0,H.jsx)(`span`,{className:`mono`,children:e.ID}),(0,H.jsx)(`strong`,{children:e.FirstName||`-`}),(0,H.jsx)(`span`,{children:W(e.Username)||`-`}),e.System?(0,H.jsx)(J,{tone:`warn`,children:r(`picker.system`)}):(0,H.jsx)(J,{children:r(`picker.regular`)})]},e.ID)),o.length===0&&!c?(0,H.jsx)(`div`,{className:`picker-empty`,children:r(`common.noResults`)}):null]})]})}function mr({label:e,value:t,onChange:n}){let{t:r}=U(),[i,a]=(0,g.useState)(``),[o,s]=(0,g.useState)([]),[c,l]=(0,g.useState)(!1),[u,d]=(0,g.useState)(``);async function f(){l(!0),d(``);let e=new URLSearchParams({limit:`20`});i.trim()&&e.set(`q`,i.trim());try{s((await k.channels(e)).rows)}catch(e){d(O(e))}finally{l(!1)}}return(0,g.useEffect)(()=>{f()},[]),(0,H.jsxs)(`div`,{className:`entity-picker`,children:[(0,H.jsxs)(`div`,{className:`picker-head`,children:[(0,H.jsx)(`span`,{children:e}),t?(0,H.jsxs)(`button`,{className:`link-button`,type:`button`,onClick:()=>n(null),children:[(0,H.jsx)(Dt,{size:13}),` `,r(`common.clear`)]}):null]}),t?(0,H.jsxs)(`div`,{className:`selected-entity`,children:[(0,H.jsx)(he,{size:15}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:t.Title||`-`}),(0,H.jsx)(`span`,{className:`mono`,children:t.ID})]}),(0,H.jsx)(`span`,{children:W(t.Username)||zt(t,r)})]}):null,(0,H.jsxs)(`div`,{className:`picker-search`,children:[(0,H.jsx)(it,{size:15}),(0,H.jsx)(`input`,{value:i,onChange:e=>a(e.target.value),onKeyDown:e=>{e.key===`Enter`&&(e.preventDefault(),f())},placeholder:r(`picker.channelPlaceholder`)}),(0,H.jsx)(`button`,{className:`btn compact-btn`,type:`button`,onClick:f,disabled:c,children:c?(0,H.jsx)(te,{size:14,className:`spin`}):r(`common.search`)})]}),u&&(0,H.jsx)(`div`,{className:`picker-error`,children:u}),(0,H.jsxs)(`div`,{className:`picker-results`,children:[o.map(e=>(0,H.jsxs)(`button`,{className:`picker-row ${t?.ID===e.ID?`selected`:``}`,type:`button`,onClick:()=>n(e),children:[(0,H.jsx)(`span`,{className:`mono`,children:e.ID}),(0,H.jsx)(`strong`,{children:e.Title||`-`}),(0,H.jsx)(`span`,{children:W(e.Username)||zt(e,r)}),e.Verified?(0,H.jsx)(J,{tone:`good`,children:r(`picker.verified`)}):(0,H.jsx)(J,{children:zt(e,r)})]},e.ID)),o.length===0&&!c?(0,H.jsx)(`div`,{className:`picker-empty`,children:r(`common.noResults`)}):null]})]})}function hr({navigate:e}){let{t}=U(),[n,r]=(0,g.useState)(`all`),[i,a]=(0,g.useState)(``),[o,s]=(0,g.useState)(`50`),[c,l]=(0,g.useState)([]),[u,d]=(0,g.useState)(!1),[f,p]=(0,g.useState)(``),[m,h]=(0,g.useState)(!1),[_,v]=(0,g.useState)(``),[y,b]=(0,g.useState)(`vault`),[x,S]=(0,g.useState)(null),[C,w]=(0,g.useState)(null),[T,E]=(0,g.useState)(``),[D,A]=(0,g.useState)(`XTR`),[j,M]=(0,g.useState)(``),[N,P]=(0,g.useState)(``),[F,I]=(0,g.useState)(``),[L,ee]=(0,g.useState)(``),[R,ne]=(0,g.useState)(``),[re,ie]=(0,g.useState)(``);async function ae(e=!1){h(!0),v(``);let t=new URLSearchParams({limit:o});n!==`all`&&t.set(`status`,n),i.trim()&&t.set(`q`,i.trim().replace(/^@/,``)),e&&f&&t.set(`before_id`,f);try{let n=await k.collectibleUsernames(t),r=n.rows??[];l(t=>e?[...t,...r]:r),p(n.next_before_id??``),d(!!n.has_more)}catch(e){v(O(e))}finally{h(!1)}}(0,g.useEffect)(()=>{ae(!1)},[]);let oe=c.filter(e=>e.Status===`vault`).length,se=c.filter(e=>e.Status===`owned`).length,ce=c.filter(e=>e.Status===`burned`).length,ue=Zt(j,D),z=N?Zt(F,N):`0`,de=ue===null,fe=z===null;function pe(){let e={username:T.trim().replace(/^@/,``),currency:D,amount:ue??`0`};if(y===`user`&&x&&(e.owner_user_id=String(x.ID)),y===`channel`&&C&&(e.owner_channel_id=String(C.ID)),N&&(e.crypto_currency=N,e.crypto_amount=z??`0`),L.trim()&&(e.url=L.trim()),R){let t=Date.parse(`${R}T${re||`00:00`}:00Z`);Number.isFinite(t)&&(e.purchase_date=Math.floor(t/1e3))}return e}return(0,H.jsxs)(nn,{title:t(`usernames.pageTitle`),eyebrow:t(`usernames.eyebrow`),actions:(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>ae(!1),disabled:m,children:[(0,H.jsx)(nt,{size:15,className:m?`spin`:``}),` `,t(`common.refresh`)]}),children:[_&&(0,H.jsx)(q,{children:_}),(0,H.jsxs)(`div`,{className:`metric-row`,children:[(0,H.jsx)(Y,{label:t(`usernames.metricLoaded`),value:String(c.length)}),(0,H.jsx)(Y,{label:t(`usernames.metricVault`),value:String(oe)}),(0,H.jsx)(Y,{label:t(`usernames.metricOwned`),value:String(se),tone:`good`}),(0,H.jsx)(Y,{label:t(`usernames.metricBurned`),value:String(ce),tone:ce?`danger`:`neutral`})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:t(`usernames.mintTitle`),text:t(`usernames.mintHint`)}),(0,H.jsxs)(`div`,{className:`toolbar`,role:`group`,"aria-label":t(`usernames.ownerKind`),children:[(0,H.jsxs)(`button`,{type:`button`,className:`btn ${y===`vault`?`primary`:``}`,onClick:()=>b(`vault`),children:[(0,H.jsx)(Et,{size:15}),` `,t(`usernames.ownerVault`)]}),(0,H.jsx)(`button`,{type:`button`,className:`btn ${y===`user`?`primary`:``}`,onClick:()=>b(`user`),children:t(`usernames.ownerUser`)}),(0,H.jsx)(`button`,{type:`button`,className:`btn ${y===`channel`?`primary`:``}`,onClick:()=>b(`channel`),children:t(`usernames.ownerChannel`)})]}),y===`user`&&(0,H.jsx)(fr,{label:t(`usernames.ownerUser`),value:x,onChange:S}),y===`channel`&&(0,H.jsx)(mr,{label:t(`usernames.ownerChannel`),value:C,onChange:w}),(0,H.jsxs)(`div`,{className:`bot-create-fields`,children:[(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:t(`common.username`)}),(0,H.jsx)(`input`,{value:T,onChange:e=>E(e.target.value),placeholder:`durov`})]}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:t(`usernames.currency`)}),(0,H.jsxs)(`select`,{value:D,onChange:e=>A(e.target.value),children:[(0,H.jsx)(`option`,{value:`XTR`,children:`XTR`}),(0,H.jsx)(`option`,{value:`TON`,children:`TON`}),(0,H.jsx)(`option`,{value:`USD`,children:`USD`})]})]}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:t(`usernames.amount`,{currency:D})}),(0,H.jsx)(`input`,{value:j,onChange:e=>M(e.target.value),inputMode:`decimal`,placeholder:`1000`})]}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:t(`usernames.cryptoCurrency`)}),(0,H.jsxs)(`select`,{value:N,onChange:e=>P(e.target.value),children:[(0,H.jsx)(`option`,{value:``,children:t(`usernames.cryptoNone`)}),(0,H.jsx)(`option`,{value:`TON`,children:`TON`})]})]}),N!==``&&(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:t(`usernames.cryptoAmount`,{currency:N})}),(0,H.jsx)(`input`,{value:F,onChange:e=>I(e.target.value),inputMode:`decimal`,placeholder:`12.5`})]}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:t(`usernames.url`)}),(0,H.jsx)(`input`,{value:L,onChange:e=>ee(e.target.value),placeholder:`https://fragment.com/username/durov`})]}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:t(`usernames.purchaseDate`)}),(0,H.jsx)(`input`,{value:R,onChange:e=>ne(e.target.value),type:`date`})]}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:t(`usernames.purchaseTime`)}),(0,H.jsx)(`input`,{value:re,onChange:e=>ie(e.target.value),type:`time`,step:60,disabled:!R})]})]}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:t(`usernames.amountHint`,{currency:D,decimals:String(qt(D)),preview:Xt(ue??`0`,D)})}),de&&(0,H.jsx)(q,{children:t(`usernames.amountInvalid`,{currency:D,decimals:String(qt(D))})}),N!==``&&fe&&(0,H.jsx)(q,{children:t(`usernames.amountInvalid`,{currency:N,decimals:String(qt(N))})}),(0,H.jsxs)(`div`,{className:`bot-create-actions`,children:[(0,H.jsx)(`span`,{className:`bot-create-note`,children:t(`usernames.mintNote`)}),(0,H.jsx)(Z,{disabled:de||fe,label:t(`usernames.mint`),icon:(0,H.jsx)(Qe,{size:15}),tone:`neutral`,path:`/api/actions/mint-collectible-username`,payload:pe,onDone:()=>ae(!1)})]})]}),(0,H.jsx)(rn,{children:(0,H.jsxs)(`form`,{className:`toolbar`,onSubmit:e=>{e.preventDefault(),ae(!1)},children:[(0,H.jsxs)(`label`,{className:`searchbox`,children:[(0,H.jsx)(it,{size:15}),(0,H.jsx)(`input`,{value:i,onChange:e=>a(e.target.value),placeholder:t(`usernames.searchPlaceholder`)})]}),(0,H.jsxs)(`label`,{className:`field-inline`,children:[(0,H.jsx)(`span`,{children:t(`common.status`)}),(0,H.jsxs)(`select`,{value:n,onChange:e=>r(e.target.value),children:[(0,H.jsx)(`option`,{value:`all`,children:t(`usernames.statusAll`)}),(0,H.jsx)(`option`,{value:`vault`,children:t(`usernames.statusVault`)}),(0,H.jsx)(`option`,{value:`owned`,children:t(`usernames.statusOwned`)}),(0,H.jsx)(`option`,{value:`burned`,children:t(`usernames.statusBurned`)})]})]}),(0,H.jsxs)(`label`,{className:`field-inline`,children:[(0,H.jsx)(`span`,{children:t(`common.limit`)}),(0,H.jsx)(`input`,{className:`small-input`,value:o,onChange:e=>s(e.target.value),type:`number`,min:`1`,max:`200`})]}),(0,H.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,disabled:m,children:[m?(0,H.jsx)(te,{size:15,className:`spin`}):(0,H.jsx)(it,{size:15}),` `,t(`common.search`)]})]})}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:t(`common.username`)}),(0,H.jsx)(`th`,{children:t(`common.status`)}),(0,H.jsx)(`th`,{children:t(`common.owner`)}),(0,H.jsx)(`th`,{children:t(`usernames.price`)}),(0,H.jsx)(`th`,{children:t(`usernames.purchaseDate`)}),(0,H.jsx)(`th`,{children:t(`usernames.transfers`)}),(0,H.jsx)(`th`,{children:t(`common.updatedAt`)}),(0,H.jsx)(`th`,{})]})}),(0,H.jsxs)(`tbody`,{children:[c.map(n=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{children:(0,H.jsx)(`strong`,{children:W(n.Username)})}),(0,H.jsx)(`td`,{children:(0,H.jsx)(gr,{status:n.Status})}),(0,H.jsx)(`td`,{children:_r(n,t(`usernames.statusVault`))}),(0,H.jsx)(`td`,{className:`mono`,children:vr(n)}),(0,H.jsx)(`td`,{children:G(n.PurchaseDate)||`-`}),(0,H.jsx)(`td`,{className:`mono`,children:n.TransferCount}),(0,H.jsx)(`td`,{children:G(n.UpdatedAt)||`-`}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`button`,{className:`row-link`,type:`button`,onClick:()=>e(`/collectible-usernames/${n.ID}`),children:[(0,H.jsx)(le,{size:14}),` `,t(`common.detail`),` `,(0,H.jsx)(ve,{size:14})]})})]},n.ID)),c.length===0&&(0,H.jsx)(sn,{colSpan:8})]})]})}),u&&(0,H.jsx)(`div`,{className:`toolbar`,children:(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>ae(!0),disabled:m,children:[m?(0,H.jsx)(te,{size:15,className:`spin`}):(0,H.jsx)(ge,{size:15}),` `,t(`common.loadMore`)]})})]})}function gr({status:e}){let{t}=U();return e===`owned`?(0,H.jsx)(J,{tone:`good`,children:t(`usernames.statusOwned`)}):e===`burned`?(0,H.jsxs)(J,{tone:`danger`,children:[(0,H.jsx)(Ae,{size:12}),` `,t(`usernames.statusBurned`)]}):(0,H.jsxs)(J,{children:[(0,H.jsx)(Et,{size:12}),` `,t(`usernames.statusVault`)]})}function _r(e,t){return!e.OwnerPeerType||e.OwnerPeerID===``||e.OwnerPeerID===`0`?t:`${W(e.OwnerUsername)||e.OwnerName||e.OwnerPeerID} · ${e.OwnerPeerType}:${e.OwnerPeerID}`}function vr(e){let t=Xt(e.Amount,e.Currency);return e.CryptoCurrency&&e.CryptoAmount&&e.CryptoAmount!==`0`?`${Xt(e.CryptoAmount,e.CryptoCurrency)} (${t})`:t}function Q({id:e,navigate:t}){let{t:n}=U(),[r,i]=(0,g.useState)(null),[a,o]=(0,g.useState)(``),[s,c]=(0,g.useState)(!1),[l,u]=(0,g.useState)(`user`),[d,f]=(0,g.useState)(null),[p,m]=(0,g.useState)(null);async function h(){c(!0),o(``);try{i(await k.collectibleUsername(e))}catch(e){o(O(e))}finally{c(!1)}}if((0,g.useEffect)(()=>{h()},[e]),a&&!r)return(0,H.jsx)(q,{children:a});if(!r)return(0,H.jsx)(cn,{label:n(s?`usernames.loadingDetail`:`account.waitingData`)});let _=r.asset,v=r.transfers??[],y=n(`usernames.statusVault`),b=!!_.OwnerPeerType&&_.OwnerPeerID!==``&&_.OwnerPeerID!==`0`,x=_.Status===`burned`;function S(){b&&t(_.OwnerPeerType===`channel`?`/channels/${_.OwnerPeerID}`:`/accounts/${_.OwnerPeerID}`)}function C(){let e={username:_.Username};return l===`user`&&d&&(e.to_user_id=String(d.ID)),l===`channel`&&p&&(e.to_channel_id=String(p.ID)),e}return(0,H.jsxs)(nn,{title:n(`usernames.detailTitle`,{username:W(_.Username)}),eyebrow:n(`usernames.detailEyebrow`),actions:(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>t(`/collectible-usernames`),children:[(0,H.jsx)(ce,{size:15}),` `,n(`common.backToList`)]}),(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:h,disabled:s,children:[(0,H.jsx)(nt,{size:15,className:s?`spin`:``}),` `,n(`common.refresh`)]})]}),children:[a&&(0,H.jsx)(q,{children:a}),(0,H.jsx)(an,{main:(0,H.jsxs)(`div`,{className:`stacked-sections`,children:[(0,H.jsxs)(`section`,{className:`entity-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`entity-title`,children:W(_.Username)}),(0,H.jsx)(`div`,{className:`entity-subtitle`,children:n(`usernames.assetID`,{id:_.ID})})]}),(0,H.jsxs)(`div`,{className:`entity-badges`,children:[(0,H.jsx)(gr,{status:_.Status}),(0,H.jsx)(J,{tone:_.TransferCount>0?`warn`:`neutral`,children:n(`usernames.transferCount`,{count:_.TransferCount})}),_.Status===`owned`&&(0,H.jsx)(J,{tone:_.RegistryActive?`good`:`warn`,children:_.RegistryActive?n(`usernames.registryActive`):n(`usernames.registryHidden`)})]})]}),(0,H.jsxs)(`div`,{className:`summary-grid`,children:[(0,H.jsx)(X,{label:n(`common.owner`),value:_r(_,y)}),(0,H.jsx)(X,{label:n(`usernames.price`),value:vr(_),mono:!0}),(0,H.jsx)(X,{label:n(`usernames.purchaseDate`),value:G(_.PurchaseDate)||`-`}),(0,H.jsx)(X,{label:n(`usernames.originalOwner`),value:br(_.OriginalOwnerPeerType,_.OriginalOwnerPeerID,y,_.OriginalOwnerUsername)}),(0,H.jsx)(X,{label:n(`usernames.transfers`),value:String(_.TransferCount),mono:!0}),(0,H.jsx)(X,{label:n(`account.createdAt`),value:G(_.CreatedAt)||`-`}),(0,H.jsx)(X,{label:n(`common.updatedAt`),value:G(_.UpdatedAt)||`-`})]}),(0,H.jsxs)(`div`,{className:`toolbar`,children:[b&&(0,H.jsx)(`button`,{className:`row-link`,type:`button`,onClick:S,children:_.OwnerPeerType===`channel`?n(`usernames.openOwnerChannel`):n(`usernames.openOwnerAccount`)}),_.URL&&(0,H.jsxs)(`a`,{className:`row-link`,href:_.URL,target:`_blank`,rel:`noreferrer noopener`,children:[(0,H.jsx)(Ce,{size:14}),` `,n(`usernames.openMarketplace`)]})]}),!x&&(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:n(`usernames.transferTitle`),text:n(`usernames.transferHint`)}),(0,H.jsxs)(`div`,{className:`toolbar`,role:`group`,"aria-label":n(`usernames.recipientKind`),children:[(0,H.jsx)(`button`,{type:`button`,className:`btn ${l===`user`?`primary`:``}`,onClick:()=>u(`user`),children:n(`usernames.recipientUser`)}),(0,H.jsx)(`button`,{type:`button`,className:`btn ${l===`channel`?`primary`:``}`,onClick:()=>u(`channel`),children:n(`usernames.recipientChannel`)})]}),l===`user`?(0,H.jsx)(fr,{label:n(`usernames.recipientUser`),value:d,onChange:f}):(0,H.jsx)(mr,{label:n(`usernames.recipientChannel`),value:p,onChange:m}),(0,H.jsxs)(`div`,{className:`bot-create-actions`,children:[(0,H.jsx)(`span`,{className:`bot-create-note`,children:n(`usernames.transferNote`)}),(0,H.jsx)(Z,{label:n(`usernames.transfer`),icon:(0,H.jsx)(se,{size:15}),tone:`warn`,path:`/api/actions/transfer-collectible-username`,payload:C,onDone:h})]})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:n(`usernames.historyTitle`),text:n(`usernames.historyHint`)}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:n(`common.id`)}),(0,H.jsx)(`th`,{children:n(`usernames.eventKind`)}),(0,H.jsx)(`th`,{children:n(`usernames.fromPeer`)}),(0,H.jsx)(`th`,{children:n(`usernames.toPeer`)}),(0,H.jsx)(`th`,{children:n(`usernames.price`)}),(0,H.jsx)(`th`,{children:n(`audit.actor`)}),(0,H.jsx)(`th`,{children:n(`audit.reason`)}),(0,H.jsx)(`th`,{children:n(`common.time`)})]})}),(0,H.jsxs)(`tbody`,{children:[v.map(e=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{className:`mono`,children:e.ID}),(0,H.jsx)(`td`,{children:(0,H.jsx)(yr,{kind:e.Kind})}),(0,H.jsx)(`td`,{className:`mono`,children:br(e.FromPeerType,e.FromPeerID,y,e.FromUsername)}),(0,H.jsx)(`td`,{className:`mono`,children:br(e.ToPeerType,e.ToPeerID,y,e.ToUsername)}),(0,H.jsx)(`td`,{className:`mono`,children:e.Amount&&e.Amount!==`0`?Xt(e.Amount,e.Currency):`-`}),(0,H.jsx)(`td`,{children:e.Actor||`-`}),(0,H.jsx)(`td`,{className:`truncate`,children:e.Reason||`-`}),(0,H.jsx)(`td`,{children:G(e.CreatedAt)||`-`})]},e.ID)),v.length===0&&(0,H.jsx)(sn,{colSpan:8})]})]})})]})]}),side:(0,H.jsxs)(`section`,{className:`action-dock`,children:[(0,H.jsx)(`div`,{className:`dock-title`,children:n(`usernames.actionDock`)}),x?(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`usernames.burnedHint`)}):(0,H.jsxs)(H.Fragment,{children:[(0,H.jsx)(`div`,{className:`action-stack`,children:(0,H.jsx)(Z,{label:n(`usernames.revoke`),icon:(0,H.jsx)(xt,{size:15}),tone:`warn`,path:`/api/actions/revoke-collectible-username`,payload:()=>({username:_.Username,burn:!1}),onDone:h})}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`usernames.revokeHint`)}),(0,H.jsxs)(`div`,{className:`danger-zone`,children:[(0,H.jsx)(Z,{label:n(`usernames.burn`),icon:(0,H.jsx)(Ae,{size:15}),tone:`danger`,path:`/api/actions/revoke-collectible-username`,payload:()=>({username:_.Username,burn:!0}),onDone:h}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`usernames.burnHint`)}),(0,H.jsx)(Z,{label:n(`usernames.delete`),icon:(0,H.jsx)(yt,{size:15}),tone:`danger`,path:`/api/actions/delete-collectible-username`,payload:()=>({username:_.Username}),onDone:()=>t(`/collectible-usernames`)}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`usernames.deleteHint`)})]})]})]})})]})}function yr({kind:e}){let{t}=U();return(0,H.jsx)(J,{tone:e===`burn`?`danger`:e===`revoke`?`warn`:e===`mint`?`good`:`neutral`,children:t(`usernames.kind.${e}`)})}function br(e,t,n,r=``){if(!e||t===``||t===`0`)return n;let i=W(r);return i?`${i} · ${e}:${t}`:`${e}:${t}`}function xr(e){return e.startsWith(`+`)?e:`+${e}`}function Sr(){let{t:e}=U(),[t,n]=(0,g.useState)([]),[r,i]=(0,g.useState)(!1),[a,o]=(0,g.useState)(``),[s,c]=(0,g.useState)(``),[l,u]=(0,g.useState)(``),[d,f]=(0,g.useState)(``),[p,m]=(0,g.useState)(`+888`),[h,_]=(0,g.useState)(`exclusive`),[v,y]=(0,g.useState)(null),[b,x]=(0,g.useState)(``),[S,C]=(0,g.useState)(``),[w,T]=(0,g.useState)(``),[E,D]=(0,g.useState)(null),[A,j]=(0,g.useState)(null),[M,N]=(0,g.useState)(``),[P,F]=(0,g.useState)(``);async function I(){i(!0),o(``);try{let e=new URLSearchParams({limit:`200`});s.trim()&&e.set(`q`,s.trim()),l&&e.set(`status`,l),d&&e.set(`tier`,d),n((await k.collectiblePhones(e)).assets??[])}catch(e){o(O(e))}finally{i(!1)}}(0,g.useEffect)(()=>{I()},[]);let L=(0,g.useMemo)(()=>({standard:t.filter(e=>e.tier===`standard`).length,exclusive:t.filter(e=>e.tier===`exclusive`).length,owned:t.filter(e=>e.status===`owned`).length}),[t]),ee=Zt(b,`USD`),R=Zt(S,`TON`),te=ee===null||ee===`0`||R===null||R===`0`,ne=Zt(M,`USD`),re=Zt(P,`TON`),ie=ne===null||ne===`0`||re===null||re===`0`,ae=()=>({phone:p,tier:h,owner_user_id:v?String(v.ID):void 0,currency:`USD`,amount:ee??`0`,crypto_currency:`TON`,crypto_amount:R??`0`,url:w.trim()||void 0});function oe(e){D(e),j(null),N(e.currency===`USD`?Jt(e.amount,`USD`).replace(/ /g,``):``),F(e.crypto_currency===`TON`?Jt(e.crypto_amount,`TON`).replace(/ /g,``):``)}return(0,H.jsxs)(nn,{title:e(`phones.pageTitle`),eyebrow:e(`phones.eyebrow`),actions:(0,H.jsxs)(`button`,{className:`btn icon-text`,onClick:I,disabled:r,children:[(0,H.jsx)(nt,{size:15,className:r?`spin`:``}),e(`common.refresh`)]}),children:[a&&(0,H.jsx)(q,{children:a}),(0,H.jsxs)(`div`,{className:`metric-row compact-metrics`,children:[(0,H.jsxs)(`div`,{className:`metric`,children:[(0,H.jsx)(`span`,{children:e(`phones.standard`)}),(0,H.jsx)(`strong`,{children:L.standard})]}),(0,H.jsxs)(`div`,{className:`metric`,children:[(0,H.jsx)(`span`,{children:e(`phones.exclusive`)}),(0,H.jsx)(`strong`,{children:L.exclusive})]}),(0,H.jsxs)(`div`,{className:`metric`,children:[(0,H.jsx)(`span`,{children:e(`usernames.statusOwned`)}),(0,H.jsx)(`strong`,{children:L.owned})]})]}),(0,H.jsxs)(`section`,{className:`section-block collectible-phone-compose`,children:[(0,H.jsx)(K,{title:e(`phones.mintTitle`),text:e(`phones.mintHint`)}),(0,H.jsxs)(`div`,{className:`compact-form-grid`,children:[(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:e(`phones.number`)}),(0,H.jsx)(`input`,{value:p,onChange:e=>m(e.target.value),placeholder:`+8881111`})]}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:e(`phones.class`)}),(0,H.jsxs)(`select`,{value:h,onChange:e=>_(e.target.value),children:[(0,H.jsx)(`option`,{value:`standard`,children:e(`phones.standard`)}),(0,H.jsx)(`option`,{value:`exclusive`,children:e(`phones.exclusive`)})]})]}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:e(`phones.fiatAmount`)}),(0,H.jsx)(`input`,{value:b,onChange:e=>x(e.target.value),inputMode:`decimal`,placeholder:`5593.00`})]}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:e(`phones.tonAmount`)}),(0,H.jsx)(`input`,{value:S,onChange:e=>C(e.target.value),inputMode:`decimal`,placeholder:`3753`})]}),(0,H.jsxs)(`label`,{className:`duration-field compact-span-2`,children:[(0,H.jsx)(`span`,{children:e(`usernames.url`)}),(0,H.jsx)(`input`,{value:w,onChange:e=>T(e.target.value),placeholder:`https://fragment.com/number/8881111`})]})]}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:e(`phones.pricePreview`,{crypto:Xt(R??`0`,`TON`),fiat:Xt(ee??`0`,`USD`)})}),te&&(b!==``||S!==``)&&(0,H.jsx)(q,{children:e(`phones.priceInvalid`)}),(0,H.jsxs)(`div`,{className:`compact-owner-row`,children:[(0,H.jsx)(fr,{label:e(`phones.ownerOptional`),value:v,onChange:y}),(0,H.jsx)(Z,{disabled:te,label:e(`phones.mint`),icon:(0,H.jsx)(Xe,{size:15}),tone:`neutral`,path:`/api/actions/mint-collectible-phone`,payload:ae,onDone:I})]})]}),(0,H.jsxs)(`div`,{className:`toolbar collectible-phone-filter`,children:[(0,H.jsxs)(`label`,{className:`searchbox`,children:[(0,H.jsx)(it,{size:15}),(0,H.jsx)(`input`,{value:s,onChange:e=>c(e.target.value),placeholder:e(`phones.search`)})]}),(0,H.jsxs)(`select`,{value:d,onChange:e=>f(e.target.value),children:[(0,H.jsx)(`option`,{value:``,children:e(`phones.allClasses`)}),(0,H.jsx)(`option`,{value:`standard`,children:e(`phones.standard`)}),(0,H.jsx)(`option`,{value:`exclusive`,children:e(`phones.exclusive`)})]}),(0,H.jsxs)(`select`,{value:l,onChange:e=>u(e.target.value),children:[(0,H.jsx)(`option`,{value:``,children:e(`usernames.statusAll`)}),(0,H.jsx)(`option`,{value:`vault`,children:e(`usernames.statusVault`)}),(0,H.jsx)(`option`,{value:`owned`,children:e(`usernames.statusOwned`)}),(0,H.jsx)(`option`,{value:`burned`,children:e(`usernames.statusBurned`)})]}),(0,H.jsx)(`button`,{className:`btn primary`,onClick:I,children:e(`common.search`)})]}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:e(`phones.number`)}),(0,H.jsx)(`th`,{children:e(`phones.class`)}),(0,H.jsx)(`th`,{children:e(`common.status`)}),(0,H.jsx)(`th`,{children:e(`common.owner`)}),(0,H.jsx)(`th`,{children:e(`usernames.price`)}),(0,H.jsx)(`th`,{children:e(`usernames.transfers`)}),(0,H.jsx)(`th`,{})]})}),(0,H.jsxs)(`tbody`,{children:[t.map(t=>(0,H.jsxs)(`tr`,{className:E?.id===t.id?`selected-row`:``,children:[(0,H.jsx)(`td`,{className:`mono`,children:(0,H.jsx)(`strong`,{children:xr(t.phone)})}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(J,{tone:t.tier===`exclusive`?`warn`:`neutral`,children:[t.tier===`exclusive`?(0,H.jsx)(B,{size:13}):(0,H.jsx)(lt,{size:13}),` `,e(`phones.${t.tier}`)]})}),(0,H.jsx)(`td`,{children:(0,H.jsx)(J,{tone:t.status===`owned`?`good`:t.status===`burned`?`danger`:`neutral`,children:e(`usernames.status${t.status[0].toUpperCase()}${t.status.slice(1)}`)})}),(0,H.jsx)(`td`,{className:`mono`,children:t.owner_user_id===`0`?`—`:t.owner_user_id}),(0,H.jsx)(`td`,{className:`mono`,children:Cr(t)}),(0,H.jsx)(`td`,{children:t.transfer_count}),(0,H.jsx)(`td`,{children:(0,H.jsx)(`button`,{className:`row-link`,onClick:()=>oe(t),children:e(`phones.manage`)})})]},t.id)),!t.length&&(0,H.jsx)(sn,{colSpan:7})]})]})}),E&&E.status!==`burned`&&(0,H.jsxs)(`section`,{className:`section-block phone-action-strip`,children:[(0,H.jsxs)(`div`,{className:`phone-action-summary`,children:[(0,H.jsx)(`strong`,{children:xr(E.phone)}),(0,H.jsx)(`span`,{children:e(`phones.manageHint`)})]}),(0,H.jsxs)(`div`,{className:`phone-price-editor`,children:[(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:e(`phones.fiatAmount`)}),(0,H.jsx)(`input`,{value:M,onChange:e=>N(e.target.value),inputMode:`decimal`,placeholder:`5593.00`})]}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:e(`phones.tonAmount`)}),(0,H.jsx)(`input`,{value:P,onChange:e=>F(e.target.value),inputMode:`decimal`,placeholder:`3753`})]}),(0,H.jsx)(Z,{disabled:ie,label:e(`phones.updatePrice`),icon:(0,H.jsx)(rt,{size:14}),tone:`neutral`,path:`/api/actions/update-collectible-phone-price`,payload:()=>({phone:E.phone,currency:`USD`,amount:ne??`0`,crypto_currency:`TON`,crypto_amount:re??`0`}),onDone:()=>{D(null),I()}})]}),(0,H.jsxs)(`div`,{className:`phone-transfer-editor`,children:[(0,H.jsx)(fr,{label:e(`usernames.recipientUser`),value:A,onChange:j}),(0,H.jsxs)(`div`,{className:`toolbar`,children:[(0,H.jsx)(Z,{label:e(`usernames.transfer`),icon:(0,H.jsx)(Xe,{size:14}),tone:`neutral`,path:`/api/actions/transfer-collectible-phone`,payload:()=>({phone:E.phone,to_user_id:A?String(A.ID):void 0}),onDone:I}),E.status===`owned`&&(0,H.jsx)(Z,{label:e(`usernames.revoke`),icon:(0,H.jsx)(xt,{size:14}),tone:`warn`,path:`/api/actions/revoke-collectible-phone`,payload:()=>({phone:E.phone,burn:!1}),onDone:I}),(0,H.jsx)(Z,{label:e(`usernames.burn`),icon:(0,H.jsx)(Ae,{size:14}),tone:`danger`,path:`/api/actions/revoke-collectible-phone`,payload:()=>({phone:E.phone,burn:!0}),onDone:I})]})]})]})]})}function Cr(e){let t=Xt(e.amount,e.currency);return e.crypto_currency===`TON`&&e.crypto_amount&&e.crypto_amount!==`0`?`${Xt(e.crypto_amount,e.crypto_currency)} (${t})`:t}function wr({id:e,navigate:t}){let{t:n}=U(),[r,i]=(0,g.useState)(null),[a,o]=(0,g.useState)(``),[s,c]=(0,g.useState)(!1),[l,u]=(0,g.useState)(0);async function d(){o(``);try{i(await k.channel(e))}catch(e){o(O(e))}}if((0,g.useEffect)(()=>{d()},[e]),a)return(0,H.jsx)(q,{children:a});if(!r)return(0,H.jsx)(cn,{label:n(`channel.loadingDetail`)});let f=r.Channel;return(0,H.jsxs)(nn,{title:`${zt(f,n)} #${f.ID}`,eyebrow:n(`channel.detailProfile`),actions:(0,H.jsxs)(`button`,{className:`btn icon-text`,onClick:()=>t(`/channels`),children:[(0,H.jsx)(ce,{size:15}),` `,n(`common.backToList`)]}),children:[(0,H.jsx)(an,{main:(0,H.jsxs)(`div`,{className:`stacked-sections`,children:[(0,H.jsxs)(`section`,{className:`entity-head`,children:[(0,H.jsxs)(`div`,{className:`entity-identity`,children:[(0,H.jsx)(Bn,{id:f.ID,kind:`channel`,label:f.Title||f.Username,size:64,refreshKey:l}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`entity-title`,children:f.Title||`-`}),(0,H.jsxs)(`div`,{className:`entity-subtitle`,children:[W(f.Username)||n(`account.noUsername`),` · `,n(`channel.creator`,{id:f.CreatorUserID})]})]})]}),(0,H.jsxs)(`div`,{className:`entity-badges`,children:[(0,H.jsx)(J,{children:zt(f,n)}),f.Verified?(0,H.jsx)(J,{tone:`good`,children:n(`common.verified`)}):(0,H.jsx)(J,{children:n(`account.notVerified`)}),(0,H.jsx)(Un,{scam:f.Scam,fake:f.Fake}),f.Deleted?(0,H.jsx)(J,{tone:`danger`,children:n(`common.deleted`)}):(0,H.jsx)(J,{children:n(`common.valid`)})]})]}),(0,H.jsxs)(`div`,{className:`summary-grid`,children:[(0,H.jsx)(X,{label:n(`channel.channelID`),value:String(f.ID),mono:!0}),(0,H.jsx)(X,{label:`access_hash`,value:String(f.AccessHash),mono:!0}),(0,H.jsx)(X,{label:n(`common.members`),value:`${f.ParticipantsCount} / ${n(`common.admins`)} ${f.AdminsCount}`}),(0,H.jsx)(X,{label:n(`channel.governance`),value:n(`channel.governanceValue`,{banned:f.BannedCount,kicked:f.KickedCount})}),(0,H.jsx)(X,{label:n(`channel.flags`),value:`broadcast=${f.Broadcast} megagroup=${f.Megagroup} forum=${f.Forum}`}),(0,H.jsx)(X,{label:`top / pinned / PTS`,value:`${f.TopMessageID} / ${f.PinnedMessageID} / ${f.PTS}`}),(0,H.jsx)(X,{label:n(`account.createdAt`),value:Bt(f.Date)||`-`}),(0,H.jsx)(X,{label:n(`common.updatedAt`),value:G(f.UpdatedAt)||`-`})]}),f.About&&(0,H.jsx)(`p`,{className:`about-text`,children:f.About}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:n(`account.recentAdminOps`),text:n(`account.recent30Audit`)}),(0,H.jsx)(on,{rows:r.AuditLogs})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:n(`channel.rawRow`),text:n(`channel.rawRowText`)}),(0,H.jsx)(ln,{value:r.ChannelJSON})]})]}),side:(0,H.jsxs)(`section`,{className:`action-dock`,children:[(0,H.jsx)(`div`,{className:`dock-title`,children:n(`channel.actionDock`)}),(0,H.jsx)(Z,{label:f.Verified?n(`channel.clearVerified`):n(`channel.setVerified`),icon:(0,H.jsx)(F,{size:15}),tone:`warn`,path:`/api/actions/set-channel-verified`,payload:()=>({channel_id:f.ID,verified:!f.Verified}),onDone:d}),(0,H.jsx)(Wn,{idKey:`channel_id`,id:f.ID,path:`/api/actions/set-channel-flags`,scam:f.Scam,fake:f.Fake,onDone:d}),(0,H.jsx)(`div`,{className:`dock-title`,children:n(`attr.settings`)}),(0,H.jsx)(Qn,{channel:f,onDone:d}),(0,H.jsx)(`div`,{className:`dock-title`,children:n(`attr.attributes`)}),(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>c(!0),children:[(0,H.jsx)(Re,{size:15}),` `,n(`avatar.change`)]}),(0,H.jsx)(Kn,{idKey:`channel_id`,id:f.ID,path:`/api/actions/set-channel-username`,current:f.Username,onDone:d}),(0,H.jsx)(Xn,{idKey:`channel_id`,id:f.ID,path:`/api/actions/set-channel-color`,onDone:d}),(0,H.jsx)(Zn,{idKey:`channel_id`,id:f.ID,path:`/api/actions/set-channel-emoji-status`,onDone:d})]})}),s&&(0,H.jsx)(Vn,{kind:`channel`,id:f.ID,onClose:()=>c(!1),onDone:()=>{u(e=>e+1),d()}})]})}var Tr={beforeID:0,beforeUpdatedUS:0};function Er({navigate:e}){let{t}=U(),[n,r]=(0,g.useState)(``),[i,a]=(0,g.useState)(`50`),[o,s]=(0,g.useState)(null),[c,l]=(0,g.useState)({q:``,limit:`50`}),[u,d]=(0,g.useState)(Tr),[f,p]=(0,g.useState)([]),[m,h]=(0,g.useState)(!1),[_,v]=(0,g.useState)(``);async function y(e,t=c,n=!1){h(!0),v(``);let r=new URLSearchParams({limit:t.limit});t.q?r.set(`q`,t.q):e.beforeID>0&&(r.set(`before_id`,String(e.beforeID)),r.set(`before_updated_us`,String(e.beforeUpdatedUS)));try{return s(await k.channels(r)),l(t),d(e),n&&p([]),!0}catch(e){return v(O(e)),!1}finally{h(!1)}}async function b(){await y(Tr,{q:n.trim(),limit:i},!0)}async function x(){!o?.listing||!o.has_more||await y({beforeID:o.next_before_id,beforeUpdatedUS:o.next_before_updated_us})&&p(e=>[...e,u])}async function S(){let e=f[f.length-1];e&&await y(e)&&p(e=>e.slice(0,-1))}(0,g.useEffect)(()=>{y(Tr,{q:``,limit:`50`},!0)},[]);let C=lr(o?.rows??[]);return(0,H.jsxs)(nn,{title:t(`channel.pageTitle`),eyebrow:o?.listing===!1?t(`account.queryResults`):t(`channel.recentUpdated`),actions:(0,H.jsxs)(`button`,{className:`btn`,type:`button`,onClick:()=>y(u),disabled:m,children:[(0,H.jsx)(nt,{size:15}),` `,t(`common.refresh`)]}),children:[_&&(0,H.jsx)(q,{children:_}),(0,H.jsxs)(`div`,{className:`metric-row`,children:[(0,H.jsx)(Y,{label:t(`channel.currentPage`),value:String(o?.rows.length??0)}),(0,H.jsx)(Y,{label:t(`channel.megagroups`),value:String(C.megagroups)}),(0,H.jsx)(Y,{label:t(`channel.broadcasts`),value:String(C.broadcasts)}),(0,H.jsx)(Y,{label:t(`channel.verifiedCount`),value:String(C.verified),tone:`good`})]}),(0,H.jsx)(rn,{children:(0,H.jsxs)(`form`,{className:`toolbar`,onSubmit:e=>{e.preventDefault(),b()},children:[(0,H.jsxs)(`label`,{className:`searchbox`,children:[(0,H.jsx)(it,{size:15}),(0,H.jsx)(`input`,{value:n,onChange:e=>r(e.target.value),placeholder:t(`channel.searchPlaceholder`)})]}),(0,H.jsxs)(`label`,{className:`field-inline`,children:[(0,H.jsx)(`span`,{children:t(`common.limit`)}),(0,H.jsx)(`input`,{className:`small-input`,value:i,onChange:e=>a(e.target.value),type:`number`,min:`1`,max:`100`})]}),(0,H.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,disabled:m,children:[m?(0,H.jsx)(te,{size:15,className:`spin`}):(0,H.jsx)(it,{size:15}),` `,t(`common.search`)]}),o?.listing&&f.length>0&&(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>S(),disabled:m,children:[(0,H.jsx)(_e,{size:15}),` `,t(`messages.previousPage`)]}),o?.listing&&o.has_more&&(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>x(),disabled:m,children:[(0,H.jsx)(ve,{size:15}),` `,t(`messages.nextPage`)]})]})}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:t(`channel.channelID`)}),(0,H.jsx)(`th`,{children:t(`channel.kind`)}),(0,H.jsx)(`th`,{children:t(`common.username`)}),(0,H.jsx)(`th`,{children:t(`channel.title`)}),(0,H.jsx)(`th`,{children:t(`common.members`)}),(0,H.jsx)(`th`,{children:t(`common.admins`)}),(0,H.jsx)(`th`,{children:`PTS`}),(0,H.jsx)(`th`,{children:t(`common.verified`)}),(0,H.jsx)(`th`,{children:t(`common.updatedAt`)}),(0,H.jsx)(`th`,{})]})}),(0,H.jsxs)(`tbody`,{children:[o?.rows.map(n=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{className:`mono`,children:n.ID}),(0,H.jsx)(`td`,{children:zt(n,t)}),(0,H.jsx)(`td`,{children:W(n.Username)}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`span`,{className:`table-identity`,children:[(0,H.jsx)(Bn,{id:n.ID,kind:`channel`,label:n.Title||n.Username}),n.Title]})}),(0,H.jsx)(`td`,{children:n.ParticipantsCount}),(0,H.jsx)(`td`,{children:n.AdminsCount}),(0,H.jsx)(`td`,{children:n.PTS}),(0,H.jsxs)(`td`,{children:[n.Verified?(0,H.jsx)(J,{tone:`good`,children:t(`common.verified`)}):(0,H.jsx)(J,{children:t(`account.notVerified`)}),` `,(0,H.jsx)(Un,{scam:n.Scam,fake:n.Fake})]}),(0,H.jsx)(`td`,{children:G(n.UpdatedAt)}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`button`,{className:`row-link`,onClick:()=>e(`/channels/${n.ID}`),children:[t(`common.detail`),` `,(0,H.jsx)(ve,{size:14})]})})]},n.ID)),(!o||o.rows.length===0)&&(0,H.jsx)(sn,{colSpan:10})]})]})})]})}function Dr({botID:e,onClose:t}){let{t:n}=U(),[r,i]=(0,g.useState)(``),[a,o]=(0,g.useState)(!1),[s,c]=(0,g.useState)(``),[l,u]=(0,g.useState)(!1);async function d(){if(!r.trim()){c(n(`action.reasonRequired`));return}o(!0),c(``),u(!1);try{let t=await k.action(`/api/actions/export-bot-token`,{reason:r.trim(),confirm:!0,bot_user_id:e}),i=t.details?.token;if(t.error||typeof i!=`string`||!i){c(t.error||n(`bots.noTokenReturned`));return}await navigator.clipboard.writeText(i),u(!0)}catch(e){c(O(e))}finally{o(!1)}}return(0,Ln.createPortal)((0,H.jsx)(`div`,{className:`modal-backdrop`,role:`presentation`,children:(0,H.jsxs)(`section`,{className:`modal command-modal`,role:`dialog`,"aria-modal":`true`,"aria-label":n(`bots.copyToken`),children:[(0,H.jsxs)(`div`,{className:`modal-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`eyebrow`,children:n(`common.bot`)}),(0,H.jsx)(`h2`,{children:n(`bots.copyToken`)})]}),(0,H.jsx)(`button`,{className:`icon-btn`,type:`button`,onClick:t,disabled:a,"aria-label":n(`common.close`),children:(0,H.jsx)(Dt,{size:15})})]}),(0,H.jsxs)(`div`,{className:`command-body`,children:[(0,H.jsx)(`p`,{children:n(`bots.copyTokenHint`)}),(0,H.jsxs)(`label`,{className:`form-field`,children:[(0,H.jsx)(`span`,{children:n(`action.reason`)}),(0,H.jsx)(`textarea`,{value:r,onChange:e=>i(e.target.value),rows:3})]}),s&&(0,H.jsx)(q,{children:s}),l&&(0,H.jsx)(`div`,{className:`secret-reveal`,children:(0,H.jsxs)(`div`,{className:`secret-reveal-label`,children:[(0,H.jsx)(he,{size:14}),` `,n(`bots.tokenCopied`)]})})]}),(0,H.jsxs)(`div`,{className:`modal-actions`,children:[(0,H.jsx)(`button`,{className:`btn`,type:`button`,onClick:t,disabled:a,children:n(`common.close`)}),(0,H.jsxs)(`button`,{className:`btn primary icon-text`,type:`button`,onClick:()=>void d(),disabled:a,children:[(0,H.jsx)(be,{size:15}),n(l?`bots.copyAgain`:`bots.copyToken`)]})]})]})}),document.body)}function Or({id:e,navigate:t}){let{t:n}=U(),[r,i]=(0,g.useState)(null),[a,o]=(0,g.useState)(``),[s,c]=(0,g.useState)(!1),[l,u]=(0,g.useState)(!1),[d,f]=(0,g.useState)(!1),[p,m]=(0,g.useState)(0),h=yn(fn);async function _(){c(!0),o(``);try{i(await k.bot(e))}catch(e){o(O(e))}finally{c(!1)}}if((0,g.useEffect)(()=>{_()},[e]),a)return(0,H.jsx)(q,{children:a});if(!r)return(0,H.jsx)(cn,{label:n(s?`bots.loadingDetail`:`account.waitingData`)});let v=r.Bot,y=`${v.FirstName} ${v.LastName}`.trim();return(0,H.jsxs)(nn,{title:n(`bots.detailTitle`,{id:v.ID}),eyebrow:n(`bots.profile`),actions:(0,H.jsxs)(`button`,{className:`btn icon-text`,onClick:()=>t(`/bots`),children:[(0,H.jsx)(ce,{size:15}),` `,n(`common.backToList`)]}),children:[(0,H.jsx)(an,{main:(0,H.jsxs)(`div`,{className:`stacked-sections`,children:[(0,H.jsxs)(`section`,{className:`entity-head`,children:[(0,H.jsxs)(`div`,{className:`entity-identity`,children:[(0,H.jsx)(Bn,{id:v.ID,label:y||v.Username,size:64,refreshKey:p}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`entity-title`,children:y||n(`bots.unnamed`)}),(0,H.jsx)(`div`,{className:`entity-subtitle`,children:W(v.Username)||n(`account.noUsername`)})]})]}),(0,H.jsxs)(`div`,{className:`entity-badges`,children:[(0,H.jsx)(J,{tone:v.System?`warn`:`neutral`,children:v.System?n(`bots.system`):n(`bots.user`)}),v.Verified?(0,H.jsx)(J,{tone:`good`,children:n(`common.verified`)}):(0,H.jsx)(J,{children:n(`account.notVerified`)}),(0,H.jsx)(Un,{scam:v.Scam,fake:v.Fake})]})]}),(0,H.jsxs)(`div`,{className:`summary-grid`,children:[(0,H.jsx)(X,{label:n(`bots.botID`),value:String(v.ID),mono:!0}),(0,H.jsx)(X,{label:n(`bots.owner`),value:v.OwnerUserID>0?`${v.OwnerUserID} ${W(r.OwnerUsername)}`.trim():n(`common.none`)}),(0,H.jsx)(X,{label:n(`bots.type`),value:v.System?n(`bots.system`):n(`bots.user`)}),(0,H.jsx)(X,{label:n(`common.updatedAt`),value:G(v.UpdatedAt)||`-`}),(0,H.jsx)(X,{label:n(`account.createdAt`),value:G(v.CreatedAt)||`-`})]}),r.About&&(0,H.jsx)(`p`,{className:`about-text`,children:r.About}),r.Description&&r.Description.trim()!==r.About.trim()&&(0,H.jsx)(`p`,{className:`about-text`,children:r.Description}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:n(`account.recentAdminOps`),text:n(`account.recent30Audit`)}),(0,H.jsx)(on,{rows:r.AuditLogs})]})]}),side:(0,H.jsxs)(`section`,{className:`action-dock`,children:[(0,H.jsx)(`div`,{className:`dock-title`,children:n(`bots.actionDock`)}),(0,H.jsx)(`div`,{className:`action-stack`,children:(0,H.jsx)(Z,{label:v.Verified?n(`account.clearVerified`):n(`account.setVerified`),icon:(0,H.jsx)(F,{size:15}),tone:`neutral`,path:`/api/actions/set-verified`,payload:()=>({user_id:v.ID,verified:!v.Verified}),onDone:_})}),(0,H.jsx)(Wn,{idKey:`user_id`,id:v.ID,path:`/api/actions/set-account-flags`,scam:v.Scam,fake:v.Fake,onDone:_}),(0,H.jsx)(`div`,{className:`dock-title`,children:n(`attr.attributes`)}),(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>u(!0),children:[(0,H.jsx)(Re,{size:15}),` `,n(`avatar.change`)]}),!v.System&&(0,H.jsx)(qn,{id:v.ID,firstName:v.FirstName,lastName:v.LastName,onDone:_}),(0,H.jsx)(Kn,{idKey:`user_id`,id:v.ID,path:`/api/actions/set-account-username`,current:v.Username,onDone:_}),(0,H.jsx)(Xn,{idKey:`user_id`,id:v.ID,path:`/api/actions/set-account-color`,onDone:_}),(0,H.jsx)(Zn,{idKey:`user_id`,id:v.ID,path:`/api/actions/set-account-emoji-status`,onDone:_}),v.System?(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`bots.systemHint`)}):(0,H.jsxs)(`div`,{className:`danger-zone`,children:[h&&(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>f(!0),children:[(0,H.jsx)(ze,{size:15}),` `,n(`bots.copyToken`)]}),(0,H.jsx)(Z,{label:n(`bots.delete`),icon:(0,H.jsx)(yt,{size:15}),tone:`danger`,path:`/api/actions/delete-bot`,payload:()=>({bot_user_id:v.ID}),onDone:()=>t(`/bots`)}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`bots.deleteHint`)})]})]})}),l&&(0,H.jsx)(Vn,{kind:`user`,id:v.ID,onClose:()=>u(!1),onDone:()=>{m(e=>e+1),_()}}),d&&(0,H.jsx)(Dr,{botID:v.ID,onClose:()=>f(!1)})]})}function kr({onClose:e,onCreated:t}){let{t:n}=U(),[r,i]=(0,g.useState)(``),[a,o]=(0,g.useState)(``),[s,c]=(0,g.useState)(``),l=s.trim().replace(/^@/,``),u=Ht(r)>0&&a.trim().length>0&&/^[A-Za-z0-9_]{2,29}bot$/i.test(l);return(0,Ln.createPortal)((0,H.jsx)(`div`,{className:`modal-backdrop`,role:`presentation`,children:(0,H.jsxs)(`section`,{className:`modal command-modal`,role:`dialog`,"aria-modal":`true`,"aria-label":n(`bots.createTitle`),children:[(0,H.jsxs)(`div`,{className:`modal-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`eyebrow`,children:n(`layout.bots`)}),(0,H.jsx)(`h2`,{children:n(`bots.createTitle`)}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`bots.createHint`)})]}),(0,H.jsx)(`button`,{className:`icon-btn`,type:`button`,onClick:e,"aria-label":n(`common.close`),children:(0,H.jsx)(Dt,{size:15})})]}),(0,H.jsxs)(`div`,{className:`command-body`,children:[(0,H.jsxs)(`div`,{className:`bot-create-fields`,children:[(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:n(`bots.ownerUserID`)}),(0,H.jsx)(`input`,{value:r,onChange:e=>i(e.target.value),type:`number`,min:`1`,placeholder:`123456789`})]}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:n(`bots.name`)}),(0,H.jsx)(`input`,{value:a,onChange:e=>o(e.target.value),placeholder:n(`bots.namePlaceholder`),maxLength:64})]}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:n(`bots.username`)}),(0,H.jsx)(`input`,{value:s,onChange:e=>c(e.target.value),placeholder:`my_service_bot`})]})]}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`bots.usernameHint`)}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`bots.tokenResultHint`)})]}),(0,H.jsxs)(`div`,{className:`modal-actions`,children:[(0,H.jsx)(`button`,{className:`btn`,type:`button`,onClick:e,children:n(`common.close`)}),(0,H.jsx)(Z,{label:n(`bots.create`),icon:(0,H.jsx)(Qe,{size:15}),tone:`neutral`,disabled:!u,path:`/api/actions/create-bot`,secretField:`token`,payload:()=>({owner_user_id:Ht(r),name:a.trim(),username:l}),onDone:t})]})]})}),document.body)}function Ar({navigate:e}){let{t}=U(),[n,r]=(0,g.useState)(``),[i,a]=(0,g.useState)(`50`),[o,s]=(0,g.useState)(null),[c,l]=(0,g.useState)({q:``,limit:`50`}),[u,d]=(0,g.useState)(0),[f,p]=(0,g.useState)([]),[m,h]=(0,g.useState)(!1),[_,v]=(0,g.useState)(``),[y,b]=(0,g.useState)(!1);async function x(e,t=c,n=!1){h(!0),v(``);let r=new URLSearchParams({limit:t.limit});t.q?r.set(`q`,t.q):e>0&&r.set(`before_id`,String(e));try{return s(await k.bots(r)),l(t),d(e),n&&p([]),!0}catch(e){return v(O(e)),!1}finally{h(!1)}}async function S(){await x(0,{q:n.trim(),limit:i},!0)}async function C(){!o?.listing||!o.has_more||await x(o.next_before_id)&&p(e=>[...e,u])}async function w(){let e=f[f.length-1];e!==void 0&&await x(e)&&p(e=>e.slice(0,-1))}(0,g.useEffect)(()=>{x(0,{q:``,limit:`50`},!0)},[]);let T=o?.rows??[],E=T.filter(e=>e.Verified).length,D=T.filter(e=>e.System).length;return(0,H.jsxs)(nn,{title:t(`bots.pageTitle`),eyebrow:o?.listing===!1?t(`bots.queryResults`):t(`bots.recent`),actions:(0,H.jsxs)(`div`,{className:`toolbar`,children:[(0,H.jsxs)(`button`,{className:`btn`,type:`button`,onClick:()=>b(!0),children:[(0,H.jsx)(Qe,{size:15}),` `,t(`bots.create`)]}),(0,H.jsxs)(`button`,{className:`btn`,type:`button`,onClick:()=>x(u),disabled:m,children:[(0,H.jsx)(nt,{size:15}),` `,t(`common.refresh`)]})]}),children:[_&&(0,H.jsx)(q,{children:_}),(0,H.jsxs)(`div`,{className:`metric-row`,children:[(0,H.jsx)(Y,{label:t(`bots.currentPage`),value:String(T.length)}),(0,H.jsx)(Y,{label:t(`common.verified`),value:String(E),tone:`good`}),(0,H.jsx)(Y,{label:t(`bots.system`),value:String(D)})]}),y&&(0,H.jsx)(kr,{onClose:()=>b(!1),onCreated:()=>x(0,{q:``,limit:i},!0)}),(0,H.jsx)(rn,{children:(0,H.jsxs)(`form`,{className:`toolbar`,onSubmit:e=>{e.preventDefault(),S()},children:[(0,H.jsxs)(`label`,{className:`searchbox`,children:[(0,H.jsx)(it,{size:15}),(0,H.jsx)(`input`,{value:n,onChange:e=>r(e.target.value),placeholder:t(`bots.searchPlaceholder`)})]}),(0,H.jsxs)(`label`,{className:`field-inline`,children:[(0,H.jsx)(`span`,{children:t(`common.limit`)}),(0,H.jsx)(`input`,{className:`small-input`,value:i,onChange:e=>a(e.target.value),type:`number`,min:`1`,max:`100`})]}),(0,H.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,disabled:m,children:[m?(0,H.jsx)(te,{size:15,className:`spin`}):(0,H.jsx)(it,{size:15}),` `,t(`common.search`)]}),o?.listing&&f.length>0&&(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>w(),disabled:m,children:[(0,H.jsx)(_e,{size:15}),` `,t(`messages.previousPage`)]}),o?.listing&&o.has_more&&(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>C(),disabled:m,children:[(0,H.jsx)(ve,{size:15}),` `,t(`messages.nextPage`)]})]})}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:t(`bots.botID`)}),(0,H.jsx)(`th`,{children:t(`common.username`)}),(0,H.jsx)(`th`,{children:t(`common.name`)}),(0,H.jsx)(`th`,{children:t(`bots.owner`)}),(0,H.jsx)(`th`,{children:t(`common.verified`)}),(0,H.jsx)(`th`,{children:t(`bots.type`)}),(0,H.jsx)(`th`,{children:t(`account.createdAt`)}),(0,H.jsx)(`th`,{})]})}),(0,H.jsxs)(`tbody`,{children:[T.map(n=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{className:`mono`,children:n.ID}),(0,H.jsx)(`td`,{children:W(n.Username)||`-`}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`span`,{className:`table-identity`,children:[(0,H.jsx)(Bn,{id:n.ID,label:`${n.FirstName} ${n.LastName}`.trim()||n.Username}),`${n.FirstName} ${n.LastName}`.trim()||`-`]})}),(0,H.jsx)(`td`,{className:`mono`,children:n.OwnerUserID>0?n.OwnerUserID:`-`}),(0,H.jsxs)(`td`,{children:[n.Verified?(0,H.jsxs)(J,{tone:`good`,children:[(0,H.jsx)(F,{size:12}),` `,t(`common.verified`)]}):(0,H.jsx)(J,{children:t(`account.notVerified`)}),` `,(0,H.jsx)(Un,{scam:n.Scam,fake:n.Fake})]}),(0,H.jsx)(`td`,{children:n.System?(0,H.jsx)(J,{tone:`warn`,children:t(`bots.system`)}):(0,H.jsx)(J,{children:t(`bots.user`)})}),(0,H.jsx)(`td`,{children:G(n.CreatedAt)}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`button`,{className:`row-link`,onClick:()=>e(`/bots/${n.ID}`),children:[(0,H.jsx)(de,{size:14}),` `,t(`common.detail`),` `,(0,H.jsx)(ve,{size:14})]})})]},n.ID)),T.length===0&&(0,H.jsx)(sn,{colSpan:8})]})]})})]})}function jr(e){let t=e.split(/[\s,]+/).filter(Boolean);if(t.length===0||t.length>200)return null;let n=t.map(e=>Number(e));return n.some(e=>!Number.isSafeInteger(e)||e<=0)||new Set(n).size!==n.length?null:n}function Mr({onClose:e,onCreated:t}){let{t:n}=U(),[r,i]=(0,g.useState)(``),[a,o]=(0,g.useState)(`all`),[s,c]=(0,g.useState)(``),l=(0,g.useMemo)(()=>jr(s),[s]),u=new TextEncoder().encode(r.trim()).length,d=r.trim().length>0&&u<=4096&&(a===`all`||l!==null);return(0,Ln.createPortal)((0,H.jsx)(`div`,{className:`modal-backdrop`,role:`presentation`,children:(0,H.jsxs)(`section`,{className:`modal command-modal`,role:`dialog`,"aria-modal":`true`,"aria-label":n(`broadcast.createTitle`),children:[(0,H.jsxs)(`div`,{className:`modal-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`eyebrow`,children:n(`layout.broadcasts`)}),(0,H.jsx)(`h2`,{children:n(`broadcast.createTitle`)}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`broadcast.createHint`)})]}),(0,H.jsx)(`button`,{className:`icon-btn`,type:`button`,onClick:e,"aria-label":n(`common.close`),children:(0,H.jsx)(Dt,{size:15})})]}),(0,H.jsxs)(`div`,{className:`command-body`,children:[(0,H.jsxs)(`label`,{className:`form-field`,children:[(0,H.jsx)(`span`,{children:n(`broadcast.message`)}),(0,H.jsx)(`textarea`,{rows:7,maxLength:4096,value:r,onChange:e=>i(e.target.value),placeholder:n(`broadcast.messagePlaceholder`)}),(0,H.jsx)(`small`,{className:`bot-create-note`,children:n(`broadcast.messageBytes`,{count:u})})]}),(0,H.jsxs)(`label`,{className:`form-field`,children:[(0,H.jsx)(`span`,{children:n(`broadcast.target`)}),(0,H.jsxs)(`select`,{value:a,onChange:e=>o(e.target.value),children:[(0,H.jsx)(`option`,{value:`all`,children:n(`broadcast.targetAll`)}),(0,H.jsx)(`option`,{value:`selected`,children:n(`broadcast.targetSelected`)})]})]}),a===`selected`&&(0,H.jsxs)(`label`,{className:`form-field`,children:[(0,H.jsx)(`span`,{children:n(`broadcast.userIDs`)}),(0,H.jsx)(`textarea`,{rows:4,value:s,onChange:e=>c(e.target.value),placeholder:`123456789, 123456790`}),(0,H.jsx)(`small`,{className:`bot-create-note`,children:n(`broadcast.userIDsHint`)})]})]}),(0,H.jsxs)(`div`,{className:`modal-actions`,children:[(0,H.jsx)(`button`,{className:`btn`,type:`button`,onClick:e,children:n(`common.close`)}),(0,H.jsx)(Z,{label:n(`broadcast.create`),icon:(0,H.jsx)(V,{size:15}),tone:`warn`,disabled:!d,path:`/api/actions/create-broadcast`,payload:()=>({message:r.trim(),target_mode:a,user_ids:a===`selected`?l??[]:[]}),onDone:t})]})]})}),document.body)}function Nr(e){return e.EnumerationDone?BigInt(e.SentCount)+BigInt(e.FailedCount)[...e,r])}async function h(){let e=a[a.length-1];e!==void 0&&await p(e)&&o(e=>e.slice(0,-1))}(0,g.useEffect)(()=>{p(`0`,!0)},[]);let _=t?.rows??[],v=_.filter(e=>Nr(e)!==`completed`).length,y=_.reduce((e,t)=>e+Number(t.FailedCount),0);return(0,H.jsxs)(nn,{title:e(`broadcast.pageTitle`),eyebrow:e(`broadcast.pageHint`),actions:(0,H.jsxs)(`div`,{className:`toolbar`,children:[(0,H.jsxs)(`button`,{className:`btn warn`,type:`button`,onClick:()=>f(!0),children:[(0,H.jsx)(V,{size:15}),` `,e(`broadcast.create`)]}),(0,H.jsxs)(`button`,{className:`btn`,type:`button`,onClick:()=>p(r),disabled:s,children:[(0,H.jsx)(nt,{size:15}),` `,e(`common.refresh`)]})]}),children:[l&&(0,H.jsx)(q,{children:l}),d&&(0,H.jsx)(Mr,{onClose:()=>f(!1),onCreated:()=>p(`0`,!0)}),(0,H.jsxs)(`div`,{className:`metric-row`,children:[(0,H.jsx)(Y,{label:e(`broadcast.currentPage`),value:String(_.length)}),(0,H.jsx)(Y,{label:e(`broadcast.active`),value:String(v)}),(0,H.jsx)(Y,{label:e(`broadcast.failed`),value:String(y),tone:y>0?`danger`:`neutral`})]}),(0,H.jsx)(rn,{children:(0,H.jsxs)(`div`,{className:`toolbar`,children:[a.length>0&&(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:h,disabled:s,children:[(0,H.jsx)(_e,{size:15}),` `,e(`messages.previousPage`)]}),t?.has_more&&(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:m,disabled:s,children:[(0,H.jsx)(ve,{size:15}),` `,e(`messages.nextPage`)]})]})}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:e(`broadcast.id`)}),(0,H.jsx)(`th`,{children:e(`broadcast.message`)}),(0,H.jsx)(`th`,{children:e(`broadcast.target`)}),(0,H.jsx)(`th`,{children:e(`broadcast.progress`)}),(0,H.jsx)(`th`,{children:e(`broadcast.status`)}),(0,H.jsx)(`th`,{children:e(`broadcast.createdBy`)}),(0,H.jsx)(`th`,{children:e(`account.createdAt`)})]})}),(0,H.jsxs)(`tbody`,{children:[_.map(t=>{let n=Nr(t);return(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{className:`mono`,children:t.ID}),(0,H.jsx)(`td`,{title:t.Message,children:t.Message.length>100?`${t.Message.slice(0,100)}…`:t.Message}),(0,H.jsx)(`td`,{children:(0,H.jsx)(J,{children:e(t.TargetMode===`all`?`broadcast.targetAll`:`broadcast.targetSelected`)})}),(0,H.jsxs)(`td`,{children:[t.SentCount,` / `,t.TargetCount,BigInt(t.FailedCount)>0n?` · ${e(`broadcast.failed`)} ${t.FailedCount}`:``]}),(0,H.jsx)(`td`,{children:(0,H.jsx)(J,{tone:n===`completed`?`good`:n===`delivering`?`warn`:`neutral`,children:e(`broadcast.${n}`)})}),(0,H.jsx)(`td`,{children:t.CreatedBy||`-`}),(0,H.jsx)(`td`,{children:G(t.CreatedAt)})]},t.ID)}),_.length===0&&(0,H.jsx)(sn,{colSpan:7})]})]})})]})}var Fr=c(o(((e,t)=>{typeof document<`u`&&typeof navigator<`u`&&(function(n,r){typeof e==`object`&&t!==void 0?t.exports=r():typeof define==`function`&&define.amd?define(r):(n=typeof globalThis<`u`?globalThis:n||self,n.lottie=r())})(e,(function(){var n=``,r=!1,i=-999999,a=function(e){r=!!e},o=function(){return r},s=function(e){n=e},c=function(){return n};function l(e){return document.createElement(e)}function u(e,t){var n,r=e.length,i;for(n=0;n1?n[1]=1:n[1]<=0&&(n[1]=0),L(n[0],n[1],n[2])}function te(e,t){var n=ee(e[0]*255,e[1]*255,e[2]*255);return n[2]+=t,n[2]>1?n[2]=1:n[2]<0&&(n[2]=0),L(n[0],n[1],n[2])}function ne(e,t){var n=ee(e[0]*255,e[1]*255,e[2]*255);return n[0]+=t/360,n[0]>1?--n[0]:n[0]<0&&(n[0]+=1),L(n[0],n[1],n[2])}(function(){var e=[],t,n;for(t=0;t<256;t+=1)n=t.toString(16),e[t]=n.length===1?`0`+n:n;return function(t,n,r){return t<0&&(t=0),n<0&&(n=0),r<0&&(r=0),`#`+e[t]+e[n]+e[r]}})();var re=function(e){g=!!e},ie=function(){return g},ae=function(e){_=e},oe=function(){return _},se=function(){return v},ce=function(e){E=e},le=function(){return E},ue=function(e){y=e};function z(e){return document.createElementNS(`http://www.w3.org/2000/svg`,e)}function de(e){"@babel/helpers - typeof";return de=typeof Symbol==`function`&&typeof Symbol.iterator==`symbol`?function(e){return typeof e}:function(e){return e&&typeof Symbol==`function`&&e.constructor===Symbol&&e!==Symbol.prototype?`symbol`:typeof e},de(e)}var fe=function(){var e=1,t=[],n,r,i={onmessage:function(){},postMessage:function(e){n({data:e})}},a={postMessage:function(e){i.onmessage({data:e})}};function s(e){if(window.Worker&&window.Blob&&o()){var t=new Blob([`var _workerSelf = self; self.onmessage = `,e.toString()],{type:`text/javascript`}),r=URL.createObjectURL(t);return new Worker(r)}return n=e,i}function c(){r||(r=s(function(e){function t(){function e(t,n){var o,s,c=t.length,l,u,d,f;for(s=0;s=0;--t)if(e[t].ty===`sh`)if(e[t].ks.k.i)a(e[t].ks.k);else for(o=e[t].ks.k.length,r=0;rn[0]?!0:n[0]>e[0]?!1:e[1]>n[1]?!0:n[1]>e[1]?!1:e[2]>n[2]?!0:n[2]>e[2]?!1:null}var s=function(){var e=[4,4,14];function t(e){var t=e.t.d;e.t.d={k:[{s:t,t:0}]}}function n(e){var n,r=e.length;for(n=0;n=0;--n)if(e[n].ty===`sh`)if(e[n].ks.k.i)e[n].ks.k.c=e[n].closed;else for(a=e[n].ks.k.length,i=0;i500)&&(this._imageLoaded(),clearInterval(n)),t+=1}.bind(this),50)}function a(t){var n=r(t,this.assetsPath,this.path),i=z(`image`);b?this.testImageLoaded(i):i.addEventListener(`load`,this._imageLoaded,!1),i.addEventListener(`error`,function(){a.img=e,this._imageLoaded()}.bind(this),!1),i.setAttributeNS(`http://www.w3.org/1999/xlink`,`href`,n),this._elementHelper.append?this._elementHelper.append(i):this._elementHelper.appendChild(i);var a={img:i,assetData:t};return a}function o(t){var n=r(t,this.assetsPath,this.path),i=l(`img`);i.crossOrigin=`anonymous`,i.addEventListener(`load`,this._imageLoaded,!1),i.addEventListener(`error`,function(){a.img=e,this._imageLoaded()}.bind(this),!1),i.src=n;var a={img:i,assetData:t};return a}function s(e){var t={assetData:e},n=r(e,this.assetsPath,this.path);return fe.loadData(n,function(e){t.img=e,this._footageLoaded()}.bind(this),function(){t.img={},this._footageLoaded()}.bind(this)),t}function c(e,t){this.imagesLoadedCb=t;var n,r=e.length;for(n=0;nthis.animationData.op&&(this.animationData.op=e.op,this.totalFrames=Math.floor(e.op-this.animationData.ip));var t=this.animationData.layers,n,r=t.length,i=e.layers,a,o=i.length;for(a=0;athis.timeCompleted&&(this.currentFrame=this.timeCompleted),this.trigger(`enterFrame`),this.renderFrame(),this.trigger(`drawnFrame`)},B.prototype.renderFrame=function(){if(!(this.isLoaded===!1||!this.renderer))try{this.expressionsPlugin&&this.expressionsPlugin.resetFrame(),this.renderer.renderFrame(this.currentFrame+this.firstFrame)}catch(e){this.triggerRenderFrameError(e)}},B.prototype.play=function(e){e&&this.name!==e||this.isPaused===!0&&(this.isPaused=!1,this.trigger(`_play`),this.audioController.resume(),this._idle&&(this._idle=!1,this.trigger(`_active`)))},B.prototype.pause=function(e){e&&this.name!==e||this.isPaused===!1&&(this.isPaused=!0,this.trigger(`_pause`),this._idle=!0,this.trigger(`_idle`),this.audioController.pause())},B.prototype.togglePause=function(e){e&&this.name!==e||(this.isPaused===!0?this.play():this.pause())},B.prototype.stop=function(e){e&&this.name!==e||(this.pause(),this.playCount=0,this._completedLoop=!1,this.setCurrentRawFrameValue(0))},B.prototype.getMarkerData=function(e){for(var t,n=0;n=this.totalFrames-1&&this.frameModifier>0?!this.loop||this.playCount===this.loop?this.checkSegments(t>this.totalFrames?t%this.totalFrames:0)||(n=!0,t=this.totalFrames-1):t>=this.totalFrames?(this.playCount+=1,this.checkSegments(t%this.totalFrames)||(this.setCurrentRawFrameValue(t%this.totalFrames),this._completedLoop=!0,this.trigger(`loopComplete`))):this.setCurrentRawFrameValue(t):t<0?this.checkSegments(t%this.totalFrames)||(this.loop&&!(this.playCount--<=0&&this.loop!==!0)?(this.setCurrentRawFrameValue(this.totalFrames+t%this.totalFrames),this._completedLoop?this.trigger(`loopComplete`):this._completedLoop=!0):(n=!0,t=0)):this.setCurrentRawFrameValue(t),n&&(this.setCurrentRawFrameValue(t),this.pause(),this.trigger(`complete`))}},B.prototype.adjustSegment=function(e,t){this.playCount=0,e[1]0&&(this.playSpeed<0?this.setSpeed(-this.playSpeed):this.setDirection(-1)),this.totalFrames=e[0]-e[1],this.timeCompleted=this.totalFrames,this.firstFrame=e[1],this.setCurrentRawFrameValue(this.totalFrames-.001-t)):e[1]>e[0]&&(this.frameModifier<0&&(this.playSpeed<0?this.setSpeed(-this.playSpeed):this.setDirection(1)),this.totalFrames=e[1]-e[0],this.timeCompleted=this.totalFrames,this.firstFrame=e[0],this.setCurrentRawFrameValue(.001+t)),this.trigger(`segmentStart`)},B.prototype.setSegment=function(e,t){var n=-1;this.isPaused&&(this.currentRawFrame+this.firstFramet&&(n=t-e)),this.firstFrame=e,this.totalFrames=t-e,this.timeCompleted=this.totalFrames,n!==-1&&this.goToAndStop(n,!0)},B.prototype.playSegments=function(e,t){if(t&&(this.segments.length=0),xe(e[0])===`object`){var n,r=e.length;for(n=0;n=0;--n)t[n].animation.destroy(e)}function T(e,t,n){var r=[].concat([].slice.call(document.getElementsByClassName(`lottie`)),[].slice.call(document.getElementsByClassName(`bodymovin`))),i,a=r.length;for(i=0;i0?n=c:t=c;while(Math.abs(s)>a&&++l=i?g(e,d,t,n):f===0?d:h(e,a,a+c,t,n)}},e}(),we=function(){function e(e){return e.concat(m(e.length))}return{double:e}}(),Te=function(){return function(e,t,n){var r=0,i=e,a=m(i),o={newElement:s,release:c};function s(){var e;return r?(--r,e=a[r]):e=t(),e}function c(e){r===i&&(a=we.double(a),i*=2),n&&n(e),a[r]=e,r+=1}return o}}(),Ee=function(){function e(){return{addedLength:0,percents:p(`float32`,le()),lengths:p(`float32`,le())}}return Te(8,e)}(),De=function(){function e(){return{lengths:[],totalLength:0}}function t(e){var t,n=e.lengths.length;for(t=0;t-.001&&o<.001}function n(n,r,i,a,o,s,c,l,u){if(i===0&&s===0&&u===0)return t(n,r,a,o,c,l);var d=e.sqrt(e.pow(a-n,2)+e.pow(o-r,2)+e.pow(s-i,2)),f=e.sqrt(e.pow(c-n,2)+e.pow(l-r,2)+e.pow(u-i,2)),p=e.sqrt(e.pow(c-a,2)+e.pow(l-o,2)+e.pow(u-s,2)),m=d>f?d>p?d-f-p:p-f-d:p>f?p-f-d:f-d-p;return m>-1e-4&&m<1e-4}var r=function(){return function(e,t,n,r){var i=le(),a,o,s,c,l,u=0,d,f=[],p=[],m=Ee.newElement();for(s=n.length,a=0;ao?-1:1,l=!0;l;)if(r[a]<=o&&r[a+1]>o?(s=(o-r[a])/(r[a+1]-r[a]),l=!1):a+=c,a<0||a>=i-1){if(a===i-1)return n[a];l=!1}return n[a]+(n[a+1]-n[a])*s}function l(t,n,r,i,a,o){var s=c(a,o),l=1-s;return[e.round((l*l*l*t[0]+(s*l*l+l*s*l+l*l*s)*r[0]+(s*s*l+l*s*s+s*l*s)*i[0]+s*s*s*n[0])*1e3)/1e3,e.round((l*l*l*t[1]+(s*l*l+l*s*l+l*l*s)*r[1]+(s*s*l+l*s*s+s*l*s)*i[1]+s*s*s*n[1])*1e3)/1e3]}var u=p(`float32`,8);function d(t,n,r,i,a,o,s){a<0?a=0:a>1&&(a=1);var l=c(a,s);o=o>1?1:o;var d=c(o,s),f,p=t.length,m=1-l,h=1-d,g=m*m*m,_=l*m*m*3,v=l*l*m*3,y=l*l*l,b=m*m*h,x=l*m*h+m*l*h+m*m*d,S=l*l*h+m*l*d+l*m*d,C=l*l*d,w=m*h*h,T=l*h*h+m*d*h+m*h*d,E=l*d*h+m*d*d+l*h*d,D=l*d*d,O=h*h*h,k=d*h*h+h*d*h+h*h*d,A=d*d*h+h*d*d+d*h*d,j=d*d*d;for(f=0;f=l.t-n){c.h&&(c=l),i=0;break}if(l.t-n>e){i=a;break}a=v||e=v?x.points.length-1:0;for(f=x.points[S].point.length,d=0;d=T&&C=v)r[0]=b[0],r[1]=b[1],r[2]=b[2];else if(e<=y)r[0]=c.s[0],r[1]=c.s[1],r[2]=c.s[2];else{var j=Fe(c.s),M=Fe(b),N=(e-y)/(v-y);Pe(r,Ne(j,M,N))}else for(a=0;a=v?m=1:e1e-6?(f=Math.acos(p),m=Math.sin(f),h=Math.sin((1-n)*f)/m,g=Math.sin(n*f)/m):(h=1-n,g=n),r[0]=h*i+g*c,r[1]=h*a+g*l,r[2]=h*o+g*u,r[3]=h*s+g*d,r}function Pe(e,t){var n=t[0],r=t[1],i=t[2],a=t[3],o=Math.atan2(2*r*a-2*n*i,1-2*r*r-2*i*i),s=Math.asin(2*n*r+2*i*a),c=Math.atan2(2*n*a-2*r*i,1-2*n*n-2*i*i);e[0]=o/D,e[1]=s/D,e[2]=c/D}function Fe(e){var t=e[0]*D,n=e[1]*D,r=e[2]*D,i=Math.cos(t/2),a=Math.cos(n/2),o=Math.cos(r/2),s=Math.sin(t/2),c=Math.sin(n/2),l=Math.sin(r/2),u=i*a*o-s*c*l;return[s*c*o+i*a*l,s*a*o+i*c*l,i*c*o-s*a*l,u]}function Ie(){var e=this.comp.renderedFrame-this.offsetTime,t=this.keyframes[0].t-this.offsetTime,n=this.keyframes[this.keyframes.length-1].t-this.offsetTime;if(!(e===this._caching.lastFrame||this._caching.lastFrame!==Ae&&(this._caching.lastFrame>=n&&e>=n||this._caching.lastFrame=e&&(this._caching._lastKeyframeIndex=-1,this._caching.lastIndex=0);var r=this.interpolateValue(e,this._caching);this.pv=r}return this._caching.lastFrame=e,this.pv}function Le(e){var t;if(this.propType===`unidimensional`)t=e*this.mult,je(this.v-t)>1e-5&&(this.v=t,this._mdf=!0);else for(var n=0,r=this.v.length;n1e-5&&(this.v[n]=t,this._mdf=!0),n+=1}function Re(){if(!(this.elem.globalData.frameId===this.frameId||!this.effectsSequence.length)){if(this.lock){this.setVValue(this.pv);return}this.lock=!0,this._mdf=this._isFirstFrame;var e,t=this.effectsSequence.length,n=this.kf?this.pv:this.data.k;for(e=0;e=this._maxLength&&this.doubleArrayLength(),n){case`v`:a=this.v;break;case`i`:a=this.i;break;case`o`:a=this.o;break;default:a=[];break}(!a[r]||a[r]&&!i)&&(a[r]=Ge.newElement()),a[r][0]=e,a[r][1]=t},Ke.prototype.setTripleAt=function(e,t,n,r,i,a,o,s){this.setXYAt(e,t,`v`,o,s),this.setXYAt(n,r,`o`,o,s),this.setXYAt(i,a,`i`,o,s)},Ke.prototype.reverse=function(){var e=new Ke;e.setPathData(this.c,this._length);var t=this.v,n=this.o,r=this.i,i=0;this.c&&(e.setTripleAt(t[0][0],t[0][1],r[0][0],r[0][1],n[0][0],n[0][1],0,!1),i=1);var a=this._length-1,o=this._length,s;for(s=i;s=p[p.length-1].t-this.offsetTime)i=p[p.length-1].s?p[p.length-1].s[0]:p[p.length-2].e[0],o=!0;else{for(var m=r,h=p.length-1,g=!0,_,v,y;g&&(_=p[m],v=p[m+1],!(v.t-this.offsetTime>e));)m=v.t-this.offsetTime)d=1;else if(e<_.t-this.offsetTime)d=0;else{var b;y.__fnct?b=y.__fnct:(b=Ce.getBezierEasing(_.o.x,_.o.y,_.i.x,_.i.y).get,y.__fnct=b),d=b((e-(_.t-this.offsetTime))/(v.t-this.offsetTime-(_.t-this.offsetTime)))}a=v.s?v.s[0]:_.e[0]}i=_.s[0]}for(l=t._length,u=i.i[0].length,n.lastIndex=r,s=0;sr&&t>r)||(this._caching.lastIndex=i0||e>-1e-6&&e<0?r(e*t)/t:e}function P(){var e=this.props,t=N(e[0]),n=N(e[1]),r=N(e[4]),i=N(e[5]),a=N(e[12]),o=N(e[13]);return`matrix(`+t+`,`+n+`,`+r+`,`+i+`,`+a+`,`+o+`)`}return function(){this.reset=i,this.rotate=a,this.rotateX=o,this.rotateY=s,this.rotateZ=c,this.skew=u,this.skewFromAxis=d,this.shear=l,this.scale=f,this.setTransform=m,this.translate=h,this.transform=g,this.multiply=_,this.applyToPoint=S,this.applyToX=C,this.applyToY=w,this.applyToZ=T,this.applyToPointArray=A,this.applyToTriplePoints=k,this.applyToPointStringified=j,this.toCSS=M,this.to2dCSS=P,this.clone=b,this.cloneFromProps=x,this.equals=y,this.inversePoints=O,this.inversePoint=D,this.getInverseMatrix=E,this._t=this.transform,this.isIdentity=v,this._identity=!0,this._identityCalculated=!1,this.props=p(`float32`,16),this.reset()}}();function Qe(e){"@babel/helpers - typeof";return Qe=typeof Symbol==`function`&&typeof Symbol.iterator==`symbol`?function(e){return typeof e}:function(e){return e&&typeof Symbol==`function`&&e.constructor===Symbol&&e!==Symbol.prototype?`symbol`:typeof e},Qe(e)}var $e={},et=`__[STANDALONE]__`,tt=`__[ANIMATIONDATA]__`,nt=``;function rt(e){s(e)}function it(){et===!0?Se.searchAnimations(tt,et,nt):Se.searchAnimations()}function at(e){re(e)}function ot(e){ue(e)}function st(e){return et===!0&&(e.animationData=JSON.parse(tt)),Se.loadAnimation(e)}function ct(e){if(typeof e==`string`)switch(e){case`high`:ce(200);break;default:case`medium`:ce(50);break;case`low`:ce(10);break}else!isNaN(e)&&e>1&&ce(e)}function lt(){return typeof navigator<`u`}function ut(e,t){e===`expressions`&&ae(t)}function dt(e){switch(e){case`propertyFactory`:return V;case`shapePropertyFactory`:return Xe;case`matrix`:return Ze;default:return null}}$e.play=Se.play,$e.pause=Se.pause,$e.setLocationHref=rt,$e.togglePause=Se.togglePause,$e.setSpeed=Se.setSpeed,$e.setDirection=Se.setDirection,$e.stop=Se.stop,$e.searchAnimations=it,$e.registerAnimation=Se.registerAnimation,$e.loadAnimation=st,$e.setSubframeRendering=at,$e.resize=Se.resize,$e.goToAndStop=Se.goToAndStop,$e.destroy=Se.destroy,$e.setQuality=ct,$e.inBrowser=lt,$e.installPlugin=ut,$e.freeze=Se.freeze,$e.unfreeze=Se.unfreeze,$e.setVolume=Se.setVolume,$e.mute=Se.mute,$e.unmute=Se.unmute,$e.getRegisteredAnimations=Se.getRegisteredAnimations,$e.useWebWorker=a,$e.setIDPrefix=ot,$e.__getFactory=dt,$e.version=`5.13.0`;function ft(){document.readyState===`complete`&&(clearInterval(_t),it())}function pt(e){for(var t=mt.split(`&`),n=0;n=1?a.push({s:e-1,e:t-1}):(a.push({s:e,e:1}),a.push({s:0,e:t-1}));var o=[],s,c=a.length,l;for(s=0;sr+n)){var u=l.s*i<=r?0:(l.s*i-r)/n,d=l.e*i>=r+n?1:(l.e*i-r)/n;o.push([u,d])}return o.length||o.push([0,0]),o},bt.prototype.releasePathsData=function(e){var t,n=e.length;for(t=0;t1?1+r:this.s.v<0?0+r:this.s.v+r,n=this.e.v>1?1+r:this.e.v<0?0+r:this.e.v+r,t>n){var i=t;t=n,n=i}t=Math.round(t*1e4)*1e-4,n=Math.round(n*1e4)*1e-4,this.sValue=t,this.eValue=n}else t=this.sValue,n=this.eValue;var a,o,s=this.shapes.length,c,l,u,d,f,p=0;if(n===t)for(o=0;o=0;--o)if(h=this.shapes[o],h.shape._mdf){for(g=h.localShapeCollection,g.releaseShapes(),this.m===2&&s>1?(b=this.calculateShapeEdges(t,n,h.totalShapeLength,y,p),y+=h.totalShapeLength):b=[[_,v]],l=b.length,c=0;c=1?m.push({s:h.totalShapeLength*(_-1),e:h.totalShapeLength*(v-1)}):(m.push({s:h.totalShapeLength*_,e:h.totalShapeLength}),m.push({s:0,e:h.totalShapeLength*(v-1)}));var x=this.addShapes(h,m[0]);if(m[0].s!==m[0].e){if(m.length>1)if(h.shape.paths.shapes[h.shape.paths._length-1].c){var S=x.pop();this.addPaths(x,g),x=this.addShapes(h,m[1],S)}else this.addPaths(x,g),x=this.addShapes(h,m[1]);this.addPaths(x,g)}}h.shape.paths=g}}else if(this._mdf)for(o=0;ot.e){n.c=!1;break}else t.s<=l&&t.e>=l+u.addedLength?(this.addSegment(i[a].v[s-1],i[a].o[s-1],i[a].i[s],i[a].v[s],n,d,g),g=!1):(p=ke.getNewSegment(i[a].v[s-1],i[a].v[s],i[a].o[s-1],i[a].i[s],(t.s-l)/u.addedLength,(t.e-l)/u.addedLength,f[s-1]),this.addSegmentFromArray(p,n,d,g),g=!1,n.c=!1),l+=u.addedLength,d+=1;if(i[a].c&&f.length){if(u=f[s-1],l<=t.e){var _=f[s-1].addedLength;t.s<=l&&t.e>=l+_?(this.addSegment(i[a].v[s-1],i[a].o[s-1],i[a].i[0],i[a].v[0],n,d,g),g=!1):(p=ke.getNewSegment(i[a].v[s-1],i[a].v[0],i[a].o[s-1],i[a].i[0],(t.s-l)/_,(t.e-l)/_,f[s-1]),this.addSegmentFromArray(p,n,d,g),g=!1,n.c=!1)}else n.c=!1;l+=u.addedLength,d+=1}if(n._length&&(n.setXYAt(n.v[h][0],n.v[h][1],`i`,h),n.setXYAt(n.v[n._length-1][0],n.v[n._length-1][1],`o`,n._length-1)),l>t.e)break;a=this.p.keyframes[this.p.keyframes.length-1].t?(r=this.p.getValueAtTime(this.p.keyframes[this.p.keyframes.length-1].t/n,0),i=this.p.getValueAtTime((this.p.keyframes[this.p.keyframes.length-1].t-.05)/n,0)):(r=this.p.pv,i=this.p.getValueAtTime((this.p._caching.lastFrame+this.p.offsetTime-.01)/n,this.p.offsetTime));else if(this.px&&this.px.keyframes&&this.py.keyframes&&this.px.getValueAtTime&&this.py.getValueAtTime){r=[],i=[];var a=this.px,o=this.py;a._caching.lastFrame+a.offsetTime<=a.keyframes[0].t?(r[0]=a.getValueAtTime((a.keyframes[0].t+.01)/n,0),r[1]=o.getValueAtTime((o.keyframes[0].t+.01)/n,0),i[0]=a.getValueAtTime(a.keyframes[0].t/n,0),i[1]=o.getValueAtTime(o.keyframes[0].t/n,0)):a._caching.lastFrame+a.offsetTime>=a.keyframes[a.keyframes.length-1].t?(r[0]=a.getValueAtTime(a.keyframes[a.keyframes.length-1].t/n,0),r[1]=o.getValueAtTime(o.keyframes[o.keyframes.length-1].t/n,0),i[0]=a.getValueAtTime((a.keyframes[a.keyframes.length-1].t-.01)/n,0),i[1]=o.getValueAtTime((o.keyframes[o.keyframes.length-1].t-.01)/n,0)):(r=[a.pv,o.pv],i[0]=a.getValueAtTime((a._caching.lastFrame+a.offsetTime-.01)/n,a.offsetTime),i[1]=o.getValueAtTime((o._caching.lastFrame+o.offsetTime-.01)/n,o.offsetTime))}else i=e,r=i;this.v.rotate(-Math.atan2(r[1]-i[1],r[0]-i[0]))}this.data.p&&this.data.p.s?this.data.p.z?this.v.translate(this.px.v,this.py.v,-this.pz.v):this.v.translate(this.px.v,this.py.v,0):this.v.translate(this.p.v[0],this.p.v[1],-this.p.v[2])}this.frameId=this.elem.globalData.frameId}}function r(){if(this.appliedTransformations=0,this.pre.reset(),!this.a.effectsSequence.length)this.pre.translate(-this.a.v[0],-this.a.v[1],this.a.v[2]),this.appliedTransformations=1;else return;if(!this.s.effectsSequence.length)this.pre.scale(this.s.v[0],this.s.v[1],this.s.v[2]),this.appliedTransformations=2;else return;if(this.sk)if(!this.sk.effectsSequence.length&&!this.sa.effectsSequence.length)this.pre.skewFromAxis(-this.sk.v,this.sa.v),this.appliedTransformations=3;else return;this.r?this.r.effectsSequence.length||(this.pre.rotate(-this.r.v),this.appliedTransformations=4):!this.rz.effectsSequence.length&&!this.ry.effectsSequence.length&&!this.rx.effectsSequence.length&&!this.or.effectsSequence.length&&(this.pre.rotateZ(-this.rz.v).rotateY(this.ry.v).rotateX(this.rx.v).rotateZ(-this.or.v[2]).rotateY(this.or.v[1]).rotateX(this.or.v[0]),this.appliedTransformations=4)}function i(){}function a(e){this._addDynamicProperty(e),this.elem.addDynamicProperty(e),this._isDirty=!0}function o(e,t,n){if(this.elem=e,this.frameId=-1,this.propType=`transform`,this.data=t,this.v=new Ze,this.pre=new Ze,this.appliedTransformations=0,this.initDynamicPropertyContainer(n||e),t.p&&t.p.s?(this.px=V.getProp(e,t.p.x,0,0,this),this.py=V.getProp(e,t.p.y,0,0,this),t.p.z&&(this.pz=V.getProp(e,t.p.z,0,0,this))):this.p=V.getProp(e,t.p||{k:[0,0,0]},1,0,this),t.rx){if(this.rx=V.getProp(e,t.rx,0,D,this),this.ry=V.getProp(e,t.ry,0,D,this),this.rz=V.getProp(e,t.rz,0,D,this),t.or.k[0].ti){var r,i=t.or.k.length;for(r=0;r0;)--n,this._elements.unshift(t[n]);this.dynamicProperties.length?this.k=!0:this.getValue(!0)},Ct.prototype.resetElements=function(e){var t,n=e.length;for(t=0;t0?Math.floor(f):Math.ceil(f),h=this.pMatrix.props,g=this.rMatrix.props,_=this.sMatrix.props;this.pMatrix.reset(),this.rMatrix.reset(),this.sMatrix.reset(),this.tMatrix.reset(),this.matrix.reset();var v=0;if(f>0){for(;vm;)this.applyTransforms(this.pMatrix,this.rMatrix,this.sMatrix,this.tr,1,!0),--v;p&&(this.applyTransforms(this.pMatrix,this.rMatrix,this.sMatrix,this.tr,-p,!0),v-=p)}r=this.data.m===1?0:this._currentCopies-1,i=this.data.m===1?1:-1,a=this._currentCopies;for(var y,b;a;){if(t=this.elemsData[r].it,n=t[t.length-1].transform.mProps.v.props,b=n.length,t[t.length-1].transform.mProps._mdf=!0,t[t.length-1].transform.op._mdf=!0,t[t.length-1].transform.op.v=this._currentCopies===1?this.so.v:this.so.v+(this.eo.v-this.so.v)*(r/(this._currentCopies-1)),v!==0){for((r!==0&&i===1||r!==this._currentCopies-1&&i===-1)&&this.applyTransforms(this.pMatrix,this.rMatrix,this.sMatrix,this.tr,1,!1),this.matrix.transform(g[0],g[1],g[2],g[3],g[4],g[5],g[6],g[7],g[8],g[9],g[10],g[11],g[12],g[13],g[14],g[15]),this.matrix.transform(_[0],_[1],_[2],_[3],_[4],_[5],_[6],_[7],_[8],_[9],_[10],_[11],_[12],_[13],_[14],_[15]),this.matrix.transform(h[0],h[1],h[2],h[3],h[4],h[5],h[6],h[7],h[8],h[9],h[10],h[11],h[12],h[13],h[14],h[15]),y=0;y0&&r<1?[t]:[]:[t-r,t+r].filter(function(e){return e>0&&e<1})},jt.prototype.split=function(e){if(e<=0)return[At(this.points[0]),this];if(e>=1)return[this,At(this.points[this.points.length-1])];var t=Ot(this.points[0],this.points[1],e),n=Ot(this.points[1],this.points[2],e),r=Ot(this.points[2],this.points[3],e),i=Ot(t,n,e),a=Ot(n,r,e),o=Ot(i,a,e);return[new jt(this.points[0],t,i,o,!0),new jt(o,a,r,this.points[3],!0)]};function Mt(e,t){var n=e.points[0][t],r=e.points[e.points.length-1][t];if(n>r){var i=r;r=n,n=i}for(var a=H(3*e.a[t],2*e.b[t],e.c[t]),o=0;o0&&a[o]<1){var s=e.point(a[o])[t];sr&&(r=s)}return{min:n,max:r}}jt.prototype.bounds=function(){return{x:Mt(this,0),y:Mt(this,1)}},jt.prototype.boundingBox=function(){var e=this.bounds();return{left:e.x.min,right:e.x.max,top:e.y.min,bottom:e.y.max,width:e.x.max-e.x.min,height:e.y.max-e.y.min,cx:(e.x.max+e.x.min)/2,cy:(e.y.max+e.y.min)/2}};function U(e,t,n){var r=e.boundingBox();return{cx:r.cx,cy:r.cy,width:r.width,height:r.height,bez:e,t:(t+n)/2,t1:t,t2:n}}function Nt(e){var t=e.bez.split(.5);return[U(t[0],e.t1,e.t),U(t[1],e.t,e.t2)]}function Pt(e,t){return Math.abs(e.cx-t.cx)*2=a||e.width<=r&&e.height<=r&&t.width<=r&&t.height<=r){i.push([e.t,t.t]);return}var o=Nt(e),s=Nt(t);Ft(o[0],s[0],n+1,r,i,a),Ft(o[0],s[1],n+1,r,i,a),Ft(o[1],s[0],n+1,r,i,a),Ft(o[1],s[1],n+1,r,i,a)}}jt.prototype.intersections=function(e,t,n){t===void 0&&(t=2),n===void 0&&(n=7);var r=[];return Ft(U(this,0,1),U(e,0,1),0,t,r,n),r},jt.shapeSegment=function(e,t){var n=(t+1)%e.length();return new jt(e.v[t],e.o[t],e.i[n],e.v[n],!0)},jt.shapeSegmentInverted=function(e,t){var n=(t+1)%e.length();return new jt(e.v[n],e.i[n],e.o[t],e.v[t],!0)};function It(e,t){return[e[1]*t[2]-e[2]*t[1],e[2]*t[0]-e[0]*t[2],e[0]*t[1]-e[1]*t[0]]}function Lt(e,t,n,r){var i=[e[0],e[1],1],a=[t[0],t[1],1],o=[n[0],n[1],1],s=[r[0],r[1],1],c=It(It(i,a),It(o,s));return Et(c[2])?null:[c[0]/c[2],c[1]/c[2]]}function W(e,t,n){return[e[0]+Math.cos(t)*n,e[1]-Math.sin(t)*n]}function Rt(e,t){return Math.hypot(e[0]-t[0],e[1]-t[1])}function zt(e,t){return Tt(e[0],t[0])&&Tt(e[1],t[1])}function G(){}u([yt],G),G.prototype.initModifierProperties=function(e,t){this.getValue=this.processKeys,this.amplitude=V.getProp(e,t.s,0,null,this),this.frequency=V.getProp(e,t.r,0,null,this),this.pointsType=V.getProp(e,t.pt,0,null,this),this._isAnimated=this.amplitude.effectsSequence.length!==0||this.frequency.effectsSequence.length!==0||this.pointsType.effectsSequence.length!==0};function Bt(e,t,n,r,i,a,o){var s=n-Math.PI/2,c=n+Math.PI/2,l=t[0]+Math.cos(n)*r*i,u=t[1]-Math.sin(n)*r*i;e.setTripleAt(l,u,l+Math.cos(s)*a,u-Math.sin(s)*a,l+Math.cos(c)*o,u-Math.sin(c)*o,e.length())}function Vt(e,t){var n=[t[0]-e[0],t[1]-e[1]],r=-Math.PI*.5;return[Math.cos(r)*n[0]-Math.sin(r)*n[1],Math.sin(r)*n[0]+Math.cos(r)*n[1]]}function Ht(e,t){var n=t===0?e.length()-1:t-1,r=(t+1)%e.length(),i=e.v[n],a=e.v[r],o=Vt(i,a);return Math.atan2(0,1)-Math.atan2(o[1],o[0])}function Ut(e,t,n,r,i,a,o){var s=Ht(t,n),c=t.v[n%t._length],l=t.v[n===0?t._length-1:n-1],u=t.v[(n+1)%t._length],d=a===2?Math.sqrt((c[0]-l[0])**2+(c[1]-l[1])**2):0,f=a===2?Math.sqrt((c[0]-u[0])**2+(c[1]-u[1])**2):0;Bt(e,t.v[n%t._length],s,o,r,f/((i+1)*2),d/((i+1)*2),a)}function Wt(e,t,n,r,i,a){for(var o=0;o1&&t.length>1&&(i=Jt(e[0],t[t.length-1]),i)?[[e[0].split(i[0])[0]],[t[t.length-1].split(i[1])[1]]]:[n,r]}function Xt(e){for(var t,n=1;n1&&(t=Yt(e[e.length-1],e[0]),e[e.length-1]=t[0],e[0]=t[1]),e}function Zt(e,t){var n=e.inflectionPoints(),r,i,a,o;if(n.length===0)return[Kt(e,t)];if(n.length===1||Tt(n[1],1))return a=e.split(n[0]),r=a[0],i=a[1],[Kt(r,t),Kt(i,t)];a=e.split(n[0]),r=a[0];var s=(n[1]-n[0])/(1-n[0]);return a=a[1].split(s),o=a[0],i=a[1],[Kt(r,t),Kt(o,t),Kt(i,t)]}function Qt(){}u([yt],Qt),Qt.prototype.initModifierProperties=function(e,t){this.getValue=this.processKeys,this.amount=V.getProp(e,t.a,0,null,this),this.miterLimit=V.getProp(e,t.ml,0,null,this),this.lineJoin=t.lj,this._isAnimated=this.amount.effectsSequence.length!==0},Qt.prototype.processPath=function(e,t,n,r){var i=qe.newElement();i.c=e.c;var a=e.length();e.c||--a;var o,s,c,l=[];for(o=0;o=0;--o)c=jt.shapeSegmentInverted(e,o),l.push(Zt(c,t));l=Xt(l);var u=null,d=null;for(o=0;o0&&(o=!1),o){var u=l(`style`);u.setAttribute(`f-forigin`,n[r].fOrigin),u.setAttribute(`f-origin`,n[r].origin),u.setAttribute(`f-family`,n[r].fFamily),u.type=`text/css`,u.innerText=`@font-face {font-family: `+n[r].fFamily+`; font-style: normal; src: url('`+n[r].fPath+`');}`,t.appendChild(u)}}else if(n[r].fOrigin===`g`||n[r].origin===1){for(s=document.querySelectorAll(`link[f-forigin="g"], link[f-origin="1"]`),c=0;c=55296&&n<=56319){var r=e.charCodeAt(1);r>=56320&&r<=57343&&(t=(n-55296)*1024+r-56320+65536)}return t}function S(e,t){var n=e.toString(16)+t.toString(16);return d.indexOf(n)!==-1}function C(e){return e===s}function w(e){return e===o}function T(e){var t=x(e);return t>=c&&t<=u}function E(e){return T(e.substr(0,2))&&T(e.substr(2,2))}function D(e){return t.indexOf(e)!==-1}function O(e,t){var o=x(e.substr(t,2));if(o!==n)return!1;var s=0;for(t+=2;s<5;){if(o=x(e.substr(t,2)),oa)return!1;s+=1,t+=2}return x(e.substr(t,2))===r}function k(){this.isLoaded=!0}var A=function(){this.fonts=[],this.chars=null,this.typekitLoaded=0,this.isLoaded=!1,this._warned=!1,this.initTime=Date.now(),this.setIsLoadedBinded=this.setIsLoaded.bind(this),this.checkLoadedFontsBinded=this.checkLoadedFonts.bind(this)};return A.isModifier=S,A.isZeroWidthJoiner=C,A.isFlagEmoji=E,A.isRegionalCode=T,A.isCombinedCharacter=D,A.isRegionalFlag=O,A.isVariationSelector=w,A.BLACK_FLAG_CODE_POINT=n,A.prototype={addChars:_,addFonts:g,getCharData:v,getFontByName:b,measureText:y,checkLoadedFonts:m,setIsLoaded:k},A}();function tn(e){this.animationData=e}tn.prototype.getProp=function(e){return this.animationData.slots&&this.animationData.slots[e.sid]?Object.assign(e,this.animationData.slots[e.sid].p):e};function nn(e){return new tn(e)}function rn(){}rn.prototype={initRenderable:function(){this.isInRange=!1,this.hidden=!1,this.isTransparent=!1,this.renderableComponents=[]},addRenderableComponent:function(e){this.renderableComponents.indexOf(e)===-1&&this.renderableComponents.push(e)},removeRenderableComponent:function(e){this.renderableComponents.indexOf(e)!==-1&&this.renderableComponents.splice(this.renderableComponents.indexOf(e),1)},prepareRenderableFrame:function(e){this.checkLayerLimits(e)},checkTransparency:function(){this.finalTransform.mProp.o.v<=0?!this.isTransparent&&this.globalData.renderConfig.hideOnTransparent&&(this.isTransparent=!0,this.hide()):this.isTransparent&&(this.isTransparent=!1,this.show())},checkLayerLimits:function(e){this.data.ip-this.data.st<=e&&this.data.op-this.data.st>e?this.isInRange!==!0&&(this.globalData._mdf=!0,this._mdf=!0,this.isInRange=!0,this.show()):this.isInRange!==!1&&(this.globalData._mdf=!0,this.isInRange=!1,this.hide())},renderRenderable:function(){var e,t=this.renderableComponents.length;for(e=0;e.1)&&this.audio.seek(this._currentTime/this.globalData.frameRate):(this.audio.play(),this.audio.seek(this._currentTime/this.globalData.frameRate),this._isPlaying=!0))},mn.prototype.show=function(){},mn.prototype.hide=function(){this.audio.pause(),this._isPlaying=!1},mn.prototype.pause=function(){this.audio.pause(),this._isPlaying=!1,this._canPlay=!1},mn.prototype.resume=function(){this._canPlay=!0},mn.prototype.setRate=function(e){this.audio.rate(e)},mn.prototype.volume=function(e){this._volumeMultiplier=e,this._previousVolume=e*this._volume,this.audio.volume(this._previousVolume)},mn.prototype.getBaseElement=function(){return null},mn.prototype.destroy=function(){},mn.prototype.sourceRectAtTime=function(){},mn.prototype.initExpressions=function(){};function hn(){}hn.prototype.checkLayers=function(e){var t,n=this.layers.length,r;for(this.completeLayers=!0,t=n-1;t>=0;--t)this.elements[t]||(r=this.layers[t],r.ip-r.st<=e-this.layers[t].st&&r.op-r.st>e-this.layers[t].st&&this.buildItem(t)),this.completeLayers=this.elements[t]?this.completeLayers:!1;this.checkPendingElements()},hn.prototype.createItem=function(e){switch(e.ty){case 2:return this.createImage(e);case 0:return this.createComp(e);case 1:return this.createSolid(e);case 3:return this.createNull(e);case 4:return this.createShape(e);case 5:return this.createText(e);case 6:return this.createAudio(e);case 13:return this.createCamera(e);case 15:return this.createFootage(e);default:return this.createNull(e)}},hn.prototype.createCamera=function(){throw Error(`You're using a 3d camera. Try the html renderer.`)},hn.prototype.createAudio=function(e){return new mn(e,this.globalData,this)},hn.prototype.createFootage=function(e){return new pn(e,this.globalData,this)},hn.prototype.buildAllItems=function(){var e,t=this.layers.length;for(e=0;e0&&(this.maskElement.setAttribute(`id`,p),this.element.maskedElement.setAttribute(b,`url(`+c()+`#`+p+`)`),r.appendChild(this.maskElement)),this.viewData.length&&this.element.addRenderableComponent(this)}vn.prototype.getMaskProperty=function(e){return this.viewData[e].prop},vn.prototype.renderFrame=function(e){var t=this.element.finalTransform.mat,n,r=this.masksProperties.length;for(n=0;n1&&(r+=` C`+t.o[i-1][0]+`,`+t.o[i-1][1]+` `+t.i[0][0]+`,`+t.i[0][1]+` `+t.v[0][0]+`,`+t.v[0][1]),n.lastPath!==r){var o=``;n.elem&&(t.c&&(o=e.inv?this.solidPath+r:r),n.elem.setAttribute(`d`,o)),n.lastPath=r}},vn.prototype.destroy=function(){this.element=null,this.globalData=null,this.maskElement=null,this.data=null,this.masksProperties=null};var yn=function(){var e={};e.createFilter=t,e.createAlphaToLuminanceFilter=n;function t(e,t){var n=z(`filter`);return n.setAttribute(`id`,e),t!==!0&&(n.setAttribute(`filterUnits`,`objectBoundingBox`),n.setAttribute(`x`,`0%`),n.setAttribute(`y`,`0%`),n.setAttribute(`width`,`100%`),n.setAttribute(`height`,`100%`)),n}function n(){var e=z(`feColorMatrix`);return e.setAttribute(`type`,`matrix`),e.setAttribute(`color-interpolation-filters`,`sRGB`),e.setAttribute(`values`,`0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 1`),e}return e}(),bn=function(){var e={maskType:!0,svgLumaHidden:!0,offscreenCanvas:typeof OffscreenCanvas<`u`};return(/MSIE 10/i.test(navigator.userAgent)||/MSIE 9/i.test(navigator.userAgent)||/rv:11.0/i.test(navigator.userAgent)||/Edge\/\d./i.test(navigator.userAgent))&&(e.maskType=!1),/firefox/i.test(navigator.userAgent)&&(e.svgLumaHidden=!1),e}(),xn={},Sn=`filter_result_`;function Cn(e){var t,n=`SourceGraphic`,r=e.data.ef?e.data.ef.length:0,i=I(),a=yn.createFilter(i,!0),o=0;this.filters=[];var s;for(t=0;t=0&&(n=this.shapeModifiers[e].processShapes(this._isFirstFrame),!n);--e);}},searchProcessedElement:function(e){for(var t=this.processedElements,n=0,r=t.length;n.01)return!1;n+=1}return!0},Z.prototype.checkCollapsable=function(){if(this.o.length/2!=this.c.length/4)return!1;if(this.data.k.k[0].s)for(var e=0,t=this.data.k.k.length;e0;)c=r.transformers[g].mProps._mdf||c,--h,--g;if(c)for(h=f-r.styles[u].lvl,g=r.transformers.length-1;h>0;)m.multiply(r.transformers[g].mProps.v),--h,--g}else m=e;if(p=r.sh.paths,o=p._length,c){for(s=``,a=0;a=1?v=.99:v<=-1&&(v=-.99);var y=g*v,b=Math.cos(_+t.a.v)*y+a[0],x=Math.sin(_+t.a.v)*y+a[1];r.setAttribute(`fx`,b),r.setAttribute(`fy`,x),i&&!t.g._collapsable&&(t.of.setAttribute(`fx`,b),t.of.setAttribute(`fy`,x))}}}function u(e,t,n){var r=t.style,i=t.d;i&&(i._mdf||n)&&i.dashStr&&(r.pElem.setAttribute(`stroke-dasharray`,i.dashStr),r.pElem.setAttribute(`stroke-dashoffset`,i.dashoffset[0])),t.c&&(t.c._mdf||n)&&r.pElem.setAttribute(`stroke`,`rgb(`+C(t.c.v[0])+`,`+C(t.c.v[1])+`,`+C(t.c.v[2])+`)`),(t.o._mdf||n)&&r.pElem.setAttribute(`stroke-opacity`,t.o.v),(t.w._mdf||n)&&(r.pElem.setAttribute(`stroke-width`,t.w.v),r.msElem&&r.msElem.setAttribute(`stroke-width`,t.w.v))}return n}();function Wn(e,t,n){this.shapes=[],this.shapesData=e.shapes,this.stylesList=[],this.shapeModifiers=[],this.itemsData=[],this.processedElements=[],this.animatedContents=[],this.initElement(e,t,n),this.prevViewData=[]}u([dn,_n,wn,kn,Tn,fn,En],Wn),Wn.prototype.initSecondaryElement=function(){},Wn.prototype.identityMatrix=new Ze,Wn.prototype.buildExpressionInterface=function(){},Wn.prototype.createContent=function(){this.searchShapes(this.shapesData,this.itemsData,this.prevViewData,this.layerElement,0,[],!0),this.filterUniqueShapes()},Wn.prototype.filterUniqueShapes=function(){var e,t=this.shapes.length,n,r,i=this.stylesList.length,a,o=[],s=!1;for(r=0;r1&&s&&this.setShapesAsAnimated(o)}},Wn.prototype.setShapesAsAnimated=function(e){var t,n=e.length;for(t=0;t=0;--c){if(g=this.searchProcessedElement(e[c]),g?t[c]=n[g-1]:e[c]._render=o,e[c].ty===`fl`||e[c].ty===`st`||e[c].ty===`gf`||e[c].ty===`gs`||e[c].ty===`no`)g?t[c].style.closed=e[c].hd:t[c]=this.createStyleElement(e[c],i),e[c]._render&&t[c].style.pElem.parentNode!==r&&r.appendChild(t[c].style.pElem),f.push(t[c].style);else if(e[c].ty===`gr`){if(!g)t[c]=this.createGroupElement(e[c]);else for(d=t[c].it.length,u=0;u1,this.kf&&this.addEffect(this.getKeyframeValue.bind(this)),this.kf},Kn.prototype.addEffect=function(e){this.effectsSequence.push(e),this.elem.addDynamicProperty(this)},Kn.prototype.getValue=function(e){if(!((this.elem.globalData.frameId===this.frameId||!this.effectsSequence.length)&&!e)){this.currentData.t=this.data.d.k[this.keysIndex].s.t;var t=this.currentData,n=this.keysIndex;if(this.lock){this.setCurrentData(this.currentData);return}this.lock=!0,this._mdf=!1;var r,i=this.effectsSequence.length,a=e||this.data.d.k[this.keysIndex].s;for(r=0;rt);)n+=1;return this.keysIndex!==n&&(this.keysIndex=n),this.data.d.k[this.keysIndex].s},Kn.prototype.buildFinalText=function(e){for(var t=[],n=0,r=e.length,i,a,o=!1,s=!1,c=``;n=55296&&i<=56319?en.isRegionalFlag(e,n)?c=e.substr(n,14):(a=e.charCodeAt(n+1),a>=56320&&a<=57343&&(en.isModifier(i,a)?(c=e.substr(n,2),o=!0):c=en.isFlagEmoji(e.substr(n,4))?e.substr(n,4):e.substr(n,2))):i>56319?(a=e.charCodeAt(n+1),en.isVariationSelector(i)&&(o=!0)):en.isZeroWidthJoiner(i)&&(o=!0,s=!0),o?(t[t.length-1]+=c,o=!1):t.push(c),n+=c.length;return t},Kn.prototype.completeTextData=function(e){e.__complete=!0;var t=this.elem.globalData.fontManager,n=this.data,r=[],i,a,o,s=0,c,l=n.m.g,u=0,d=0,f=0,p=[],m=0,h=0,g,_,v=t.getFontByName(e.f),y,b=0,x=$t(v);e.fWeight=x.weight,e.fStyle=x.style,e.finalSize=e.s,e.finalText=this.buildFinalText(e.t),a=e.finalText.length,e.finalLineHeight=e.lh;var S=e.tr/1e3*e.finalSize,C;if(e.sz)for(var w=!0,T=e.sz[0],E=e.sz[1],D,O;w;){O=this.buildFinalText(e.t),D=0,m=0,a=O.length,S=e.tr/1e3*e.finalSize;var k=-1;for(i=0;iT&&O[i]!==` `?(k===-1?a+=1:i=k,D+=e.finalLineHeight||e.finalSize*1.2,O.splice(i,+(k===i),`\r`),k=-1,m=0):(m+=b,m+=S);D+=v.ascent*e.finalSize/100,this.canResize&&e.finalSize>this.minimumFontSize&&Eh?m:h,m=-2*S,c=``,o=!0,f+=1):c=j,t.chars?(y=t.getCharData(j,v.fStyle,t.getFontByName(e.f).fFamily),b=o?0:y.w*e.finalSize/100):b=t.measureText(c,e.f,e.finalSize),j===` `?A+=b+S:(m+=b+S+A,A=0),r.push({l:b,an:b,add:u,n:o,anIndexes:[],val:c,line:f,animatorJustifyOffset:0}),l==2){if(u+=b,c===``||c===` `||i===a-1){for((c===``||c===` `)&&(u-=b);d<=i;)r[d].an=u,r[d].ind=s,r[d].extra=b,d+=1;s+=1,u=0}}else if(l==3){if(u+=b,c===``||i===a-1){for(c===``&&(u-=b);d<=i;)r[d].an=u,r[d].ind=s,r[d].extra=b,d+=1;u=0,s+=1}}else r[s].ind=s,r[s].extra=0,s+=1;if(e.l=r,h=m>h?m:h,p.push(m),e.sz)e.boxWidth=e.sz[0],e.justifyOffset=0;else switch(e.boxWidth=h,e.j){case 1:e.justifyOffset=-e.boxWidth;break;case 2:e.justifyOffset=-e.boxWidth/2;break;default:e.justifyOffset=0}e.lineWidths=p;var M=n.a,N,P;_=M.length;var F,I,L=[];for(g=0;g<_;g+=1){for(N=M[g],N.a.sc&&(e.strokeColorAnim=!0),N.a.sw&&(e.strokeWidthAnim=!0),(N.a.fc||N.a.fh||N.a.fs||N.a.fb)&&(e.fillColorAnim=!0),I=0,F=N.s.b,i=0;i0?i=this.ne.v/100:a=-this.ne.v/100,this.xe.v>0?o=1-this.xe.v/100:s=1+this.xe.v/100;var c=Ce.getBezierEasing(i,a,o,s).get,l=0,u=this.finalS,d=this.finalE,f=this.data.sh;if(f===2)l=d===u?+(r>=d):e(0,t(.5/(d-u)+(r-u)/(d-u),1)),l=c(l);else if(f===3)l=d===u?r>=d?0:1:1-e(0,t(.5/(d-u)+(r-u)/(d-u),1)),l=c(l);else if(f===4)d===u?l=0:(l=e(0,t(.5/(d-u)+(r-u)/(d-u),1)),l<.5?l*=2:l=1-2*(l-.5)),l=c(l);else if(f===5){if(d===u)l=0;else{var p=d-u;r=t(e(0,r+.5-u),d-u);var m=-p/2+r,h=p/2;l=Math.sqrt(1-m*m/(h*h))}l=c(l)}else f===6?(d===u?l=0:(r=t(e(0,r+.5-u),d-u),l=(1+Math.cos(Math.PI+Math.PI*2*r/(d-u)))/2),l=c(l)):(r>=n(u)&&(l=r-u<0?e(0,t(t(d,1)-(u-r),1)):e(0,t(d-r,1))),l=c(l));if(this.sm.v!==100){var g=this.sm.v*.01;g===0&&(g=1e-8);var _=.5-g*.5;l<_?l=0:(l=(l-_)/g,l>1&&(l=1))}return l*this.a.v},getValue:function(e){this.iterateDynamicProperties(),this._mdf=e||this._mdf,this._currentTextLength=this.elem.textProperty.currentData.l.length||0,e&&this.data.r===2&&(this.e.v=this._currentTextLength);var t=this.data.r===2?1:100/this.data.totalChars,n=this.o.v/t,r=this.s.v/t+n,i=this.e.v/t+n;if(r>i){var a=r;r=i,i=a}this.finalS=r,this.finalE=i}},u([We],r);function i(e,t,n){return new r(e,t,n)}return{getTextSelectorProp:i}}();function Jn(e,t,n){var r={propType:!1},i=V.getProp,a=t.a;this.a={r:a.r?i(e,a.r,0,D,n):r,rx:a.rx?i(e,a.rx,0,D,n):r,ry:a.ry?i(e,a.ry,0,D,n):r,sk:a.sk?i(e,a.sk,0,D,n):r,sa:a.sa?i(e,a.sa,0,D,n):r,s:a.s?i(e,a.s,1,.01,n):r,a:a.a?i(e,a.a,1,0,n):r,o:a.o?i(e,a.o,0,.01,n):r,p:a.p?i(e,a.p,1,0,n):r,sw:a.sw?i(e,a.sw,0,0,n):r,sc:a.sc?i(e,a.sc,1,0,n):r,fc:a.fc?i(e,a.fc,1,0,n):r,fh:a.fh?i(e,a.fh,0,0,n):r,fs:a.fs?i(e,a.fs,0,.01,n):r,fb:a.fb?i(e,a.fb,0,.01,n):r,t:a.t?i(e,a.t,0,0,n):r},this.s=qn.getTextSelectorProp(e,t.s,n),this.s.t=t.s.t}function Yn(e,t,n){this._isFirstFrame=!0,this._hasMaskedPath=!1,this._frameId=-1,this._textData=e,this._renderType=t,this._elem=n,this._animatorsData=m(this._textData.a.length),this._pathData={},this._moreOptions={alignment:{}},this.renderedLetters=[],this.lettersChangedFlag=!1,this.initDynamicPropertyContainer(n)}Yn.prototype.searchProperties=function(){var e,t=this._textData.a.length,n,r=V.getProp;for(e=0;e=m+we||!x?(T=(m+we-g)/h.partialLength,ie=b.point[0]+(h.point[0]-b.point[0])*T,ae=b.point[1]+(h.point[1]-b.point[1])*T,a.translate(-n[0]*f[u].an*.005,-(n[1]*A)*.01),_=!1):x&&(g+=h.partialLength,v+=1,v>=x.length&&(v=0,y+=1,S[y]?x=S[y].points:D.v.c?(v=0,y=0,x=S[y].points):(g-=h.partialLength,x=null)),x&&(b=h,h=x[v],C=h.partialLength));re=f[u].an/2-f[u].add,a.translate(-re,0,0)}else re=f[u].an/2-f[u].add,a.translate(-re,0,0),a.translate(-n[0]*f[u].an*.005,-n[1]*A*.01,0);for(P=0;Pe?this.textSpans[e].span:z(s?`g`:`text`),b<=e){if(c.setAttribute(`stroke-linecap`,`butt`),c.setAttribute(`stroke-linejoin`,`round`),c.setAttribute(`stroke-miterlimit`,`4`),this.textSpans[e].span=c,s){var S=z(`g`);c.appendChild(S),this.textSpans[e].childSpan=S}this.textSpans[e].span=c,this.layerElement.appendChild(c)}c.style.display=`inherit`}if(l.reset(),d&&(o[e].n&&(f=-g,p+=n.yOffset,p+=+!!h,h=!1),this.applyTextPropertiesToMatrix(n,l,o[e].line,f,p),f+=o[e].l||0,f+=g),s){x=this.globalData.fontManager.getCharData(n.finalText[e],r.fStyle,this.globalData.fontManager.getFontByName(n.f).fFamily);var C;if(x.t===1)C=new rr(x.data,this.globalData,this);else{var w=Zn;x.data&&x.data.shapes&&(w=this.buildShapeData(x.data,n.finalSize)),C=new Wn(w,this.globalData,this)}if(this.textSpans[e].glyph){var T=this.textSpans[e].glyph;this.textSpans[e].childSpan.removeChild(T.layerElement),T.destroy()}this.textSpans[e].glyph=C,C._debug=!0,C.prepareFrame(0),C.renderFrame(),this.textSpans[e].childSpan.appendChild(C.layerElement),x.t===1&&this.textSpans[e].childSpan.setAttribute(`transform`,`scale(`+n.finalSize/100+`,`+n.finalSize/100+`)`)}else d&&c.setAttribute(`transform`,`translate(`+l.props[12]+`,`+l.props[13]+`)`),c.textContent=o[e].val,c.setAttributeNS(`http://www.w3.org/XML/1998/namespace`,`xml:space`,`preserve`)}d&&c&&c.setAttribute(`d`,u)}for(;e=0;--t)(this.completeLayers||this.elements[t])&&this.elements[t].prepareFrame(e-this.layers[t].st);if(this.globalData._mdf)for(t=0;t=0;--n)(this.completeLayers||this.elements[n])&&(this.elements[n].prepareFrame(this.renderedFrame-this.layers[n].st),this.elements[n]._mdf&&(this._mdf=!0))}},nr.prototype.renderInnerContent=function(){var e,t=this.layers.length;for(e=0;e=0;--n)e.finalTransform.multiply(e.transforms[n].transform.mProps.v);e._mdf=i},processSequences:function(e){var t,n=this.sequenceList.length;for(t=0;t=1){this.buffers=[];var e=this.globalData.canvasContext,t=cr.createCanvas(e.canvas.width,e.canvas.height);this.buffers.push(t);var n=cr.createCanvas(e.canvas.width,e.canvas.height);this.buffers.push(n),this.data.tt>=3&&!document._isProxy&&cr.loadLumaCanvas()}this.canvasContext=this.globalData.canvasContext,this.transformCanvas=this.globalData.transformCanvas,this.renderableEffectsManager=new ur(this),this.searchEffectTransforms()},createContent:function(){},setBlendMode:function(){var e=this.globalData;if(e.blendMode!==this.data.bm){e.blendMode=this.data.bm;var t=an(this.data.bm);e.canvasContext.globalCompositeOperation=t}},createRenderableComponents:function(){this.maskManager=new dr(this.data,this),this.transformEffects=this.renderableEffectsManager.getEffects(gn.TRANSFORM_EFFECT)},hideElement:function(){!this.hidden&&(!this.isInRange||this.isTransparent)&&(this.hidden=!0)},showElement:function(){this.isInRange&&!this.isTransparent&&(this.hidden=!1,this._isFirstFrame=!0,this.maskManager._isFirstFrame=!0)},clearCanvas:function(e){e.clearRect(this.transformCanvas.tx,this.transformCanvas.ty,this.transformCanvas.w*this.transformCanvas.sx,this.transformCanvas.h*this.transformCanvas.sy)},prepareLayer:function(){if(this.data.tt>=1){var e=this.buffers[0].getContext(`2d`);this.clearCanvas(e),e.drawImage(this.canvasContext.canvas,0,0),this.currentTransform=this.canvasContext.getTransform(),this.canvasContext.setTransform(1,0,0,1,0,0),this.clearCanvas(this.canvasContext),this.canvasContext.setTransform(this.currentTransform)}},exitLayer:function(){if(this.data.tt>=1){var e=this.buffers[1],t=e.getContext(`2d`);if(this.clearCanvas(t),t.drawImage(this.canvasContext.canvas,0,0),this.canvasContext.setTransform(1,0,0,1,0,0),this.clearCanvas(this.canvasContext),this.canvasContext.setTransform(this.currentTransform),this.comp.getElementById(`tp`in this.data?this.data.tp:this.data.ind-1).renderFrame(!0),this.canvasContext.setTransform(1,0,0,1,0,0),this.data.tt>=3&&!document._isProxy){var n=cr.getLumaCanvas(this.canvasContext.canvas);n.getContext(`2d`).drawImage(this.canvasContext.canvas,0,0),this.clearCanvas(this.canvasContext),this.canvasContext.drawImage(n,0,0)}this.canvasContext.globalCompositeOperation=pr[this.data.tt],this.canvasContext.drawImage(e,0,0),this.canvasContext.globalCompositeOperation=`destination-over`,this.canvasContext.drawImage(this.buffers[0],0,0),this.canvasContext.setTransform(this.currentTransform),this.canvasContext.globalCompositeOperation=`source-over`}},renderFrame:function(e){if(!(this.hidden||this.data.hd)&&!(this.data.td===1&&!e)){this.renderTransform(),this.renderRenderable(),this.renderLocalTransform(),this.setBlendMode();var t=this.data.ty===0;this.prepareLayer(),this.globalData.renderer.save(t),this.globalData.renderer.ctxTransform(this.finalTransform.localMat.props),this.globalData.renderer.ctxOpacity(this.finalTransform.localOpacity),this.renderInnerContent(),this.globalData.renderer.restore(t),this.exitLayer(),this.maskManager.hasMasks&&this.globalData.renderer.restore(!0),this._isFirstFrame&&=!1}},destroy:function(){this.canvasContext=null,this.data=null,this.globalData=null,this.maskManager.destroy()},mHelper:new Ze},fr.prototype.hide=fr.prototype.hideElement,fr.prototype.show=fr.prototype.showElement;function mr(e,t,n,r){this.styledShapes=[],this.tr=[0,0,0,0,0,0];var i=4;t.ty===`rc`?i=5:t.ty===`el`?i=6:t.ty===`sr`&&(i=7),this.sh=Xe.getShapeProp(e,t,i,e);var a,o=n.length,s;for(a=0;a=0;--a){if(d=this.searchProcessedElement(e[a]),d?t[a]=n[d-1]:e[a]._shouldRender=r,e[a].ty===`fl`||e[a].ty===`st`||e[a].ty===`gf`||e[a].ty===`gs`)d?t[a].style.closed=!1:t[a]=this.createStyleElement(e[a],m),l.push(t[a].style);else if(e[a].ty===`gr`){if(!d)t[a]=this.createGroupElement(e[a]);else for(c=t[a].it.length,s=0;s=0;--i)t[i].ty===`tr`?(o=n[i].transform,this.renderShapeTransform(e,o)):t[i].ty===`sh`||t[i].ty===`el`||t[i].ty===`rc`||t[i].ty===`sr`?this.renderPath(t[i],n[i]):t[i].ty===`fl`?this.renderFill(t[i],n[i],o):t[i].ty===`st`?this.renderStroke(t[i],n[i],o):t[i].ty===`gf`||t[i].ty===`gs`?this.renderGradientFill(t[i],n[i],o):t[i].ty===`gr`?this.renderShape(o,t[i].it,n[i].it):t[i].ty;r&&this.drawLayer()},hr.prototype.renderStyledShape=function(e,t){if(this._isFirstFrame||t._mdf||e.transforms._mdf){var n=e.trNodes,r=t.paths,i,a,o,s=r._length;n.length=0;var c=e.transforms.finalTransform;for(o=0;o=1?u=.99:u<=-1&&(u=-.99);var d=c*u,f=Math.cos(l+t.a.v)*d+o[0],p=Math.sin(l+t.a.v)*d+o[1];i=a.createRadialGradient(f,p,0,o[0],o[1],c)}var m,h=e.g.p,g=t.g.c,_=1;for(m=0;ma&&c===`xMidYMid slice`||ii&&s===`meet`||ai&&s===`slice`)?this.transformCanvas.tx=(n-this.transformCanvas.w*(r/this.transformCanvas.h))/2*this.renderConfig.dpr:l===`xMax`&&(ai&&s===`slice`)?this.transformCanvas.tx=(n-this.transformCanvas.w*(r/this.transformCanvas.h))*this.renderConfig.dpr:this.transformCanvas.tx=0,u===`YMid`&&(a>i&&s===`meet`||ai&&s===`meet`||a=0;--e)this.elements[e]&&this.elements[e].destroy&&this.elements[e].destroy();this.elements.length=0,this.globalData.canvasContext=null,this.animationItem.container=null,this.destroyed=!0},Q.prototype.renderFrame=function(e,t){if(!(this.renderedFrame===e&&this.renderConfig.clearCanvas===!0&&!t||this.destroyed||e===-1)){this.renderedFrame=e,this.globalData.frameNum=e-this.animationItem._isFirstFrame,this.globalData.frameId+=1,this.globalData._mdf=!this.renderConfig.clearCanvas||t,this.globalData.projectInterface.currentFrame=e;var n,r=this.layers.length;for(this.completeLayers||this.checkLayers(e),n=r-1;n>=0;--n)(this.completeLayers||this.elements[n])&&this.elements[n].prepareFrame(e-this.layers[n].st);if(this.globalData._mdf){for(this.renderConfig.clearCanvas===!0?this.canvasContext.clearRect(0,0,this.transformCanvas.w,this.transformCanvas.h):this.save(),n=r-1;n>=0;--n)(this.completeLayers||this.elements[n])&&this.elements[n].renderFrame();this.renderConfig.clearCanvas!==!0&&this.restore()}}},Q.prototype.buildItem=function(e){var t=this.elements;if(!(t[e]||this.layers[e].ty===99)){var n=this.createItem(this.layers[e],this,this.globalData);t[e]=n,n.initExpressions()}},Q.prototype.checkPendingElements=function(){for(;this.pendingElements.length;)this.pendingElements.pop().checkParenting()},Q.prototype.hide=function(){this.animationItem.container.style.display=`none`},Q.prototype.show=function(){this.animationItem.container.style.display=`block`};function yr(){this.opacity=-1,this.transform=p(`float32`,16),this.fillStyle=``,this.strokeStyle=``,this.lineWidth=``,this.lineCap=``,this.lineJoin=``,this.miterLimit=``,this.id=Math.random()}function br(){this.stack=[],this.cArrPos=0,this.cTr=new Ze;var e,t=15;for(e=0;e=0;--t)(this.completeLayers||this.elements[t])&&this.elements[t].renderFrame()},xr.prototype.destroy=function(){var e;for(e=this.layers.length-1;e>=0;--e)this.elements[e]&&this.elements[e].destroy();this.layers=null,this.elements=null},xr.prototype.createComp=function(e){return new xr(e,this.globalData,this)};function Sr(e,t){this.animationItem=e,this.renderConfig={clearCanvas:t&&t.clearCanvas!==void 0?t.clearCanvas:!0,context:t&&t.context||null,progressiveLoad:t&&t.progressiveLoad||!1,preserveAspectRatio:t&&t.preserveAspectRatio||`xMidYMid meet`,imagePreserveAspectRatio:t&&t.imagePreserveAspectRatio||`xMidYMid slice`,contentVisibility:t&&t.contentVisibility||`visible`,className:t&&t.className||``,id:t&&t.id||``,runExpressions:!t||t.runExpressions===void 0||t.runExpressions},this.renderConfig.dpr=t&&t.dpr||1,this.animationItem.wrapper&&(this.renderConfig.dpr=t&&t.dpr||window.devicePixelRatio||1),this.renderedFrame=-1,this.globalData={frameNum:-1,_mdf:!1,renderConfig:this.renderConfig,currentGlobalAlpha:-1},this.contextData=new br,this.elements=[],this.pendingElements=[],this.transformMat=new Ze,this.completeLayers=!1,this.rendererType=`canvas`,this.renderConfig.clearCanvas&&(this.ctxTransform=this.contextData.transform.bind(this.contextData),this.ctxOpacity=this.contextData.opacity.bind(this.contextData),this.ctxFillStyle=this.contextData.fillStyle.bind(this.contextData),this.ctxStrokeStyle=this.contextData.strokeStyle.bind(this.contextData),this.ctxLineWidth=this.contextData.lineWidth.bind(this.contextData),this.ctxLineCap=this.contextData.lineCap.bind(this.contextData),this.ctxLineJoin=this.contextData.lineJoin.bind(this.contextData),this.ctxMiterLimit=this.contextData.miterLimit.bind(this.contextData),this.ctxFill=this.contextData.fill.bind(this.contextData),this.ctxFillRect=this.contextData.fillRect.bind(this.contextData),this.ctxStroke=this.contextData.stroke.bind(this.contextData),this.save=this.contextData.save.bind(this.contextData))}return u([Q],Sr),Sr.prototype.createComp=function(e){return new xr(e,this.globalData,this)},ve(`canvas`,Sr),vt.registerModifier(`tm`,bt),vt.registerModifier(`pb`,xt),vt.registerModifier(`rp`,Ct),vt.registerModifier(`rd`,wt),vt.registerModifier(`zz`,G),vt.registerModifier(`op`,Qt),$e}))}))(),1);function Ir({loader:e,cacheKey:t,className:n,playOnHover:r=!0,onError:i}){let a=(0,g.useRef)(null),o=(0,g.useRef)(null);(0,g.useEffect)(()=>{let t=!1;return e().then(e=>{t||!a.current||(o.current?.destroy(),o.current=Fr.default.loadAnimation({container:a.current,renderer:`canvas`,loop:!0,autoplay:!1,animationData:structuredClone(e)}),o.current.goToAndStop(0,!0))}).catch(()=>i?.()),()=>{t=!0,o.current?.destroy(),o.current=null}},[t]);function s(){r&&o.current?.play()}function c(){r&&o.current?.goToAndStop(0,!0)}return(0,H.jsx)(`div`,{className:n,ref:a,onMouseEnter:s,onMouseLeave:c})}function Lr(e){return e<1024?`${e} B`:e<1024*1024?`${(e/1024).toFixed(1)} KB`:`${(e/(1024*1024)).toFixed(1)} MB`}function Rr(e){let t=e.toLowerCase();return t.includes(`tgsticker`)||t.includes(`lottie`)||t.includes(`json`)}function zr({row:e}){let[t,n]=(0,g.useState)(!Rr(e.MimeType));return(0,g.useEffect)(()=>{n(!Rr(e.MimeType))},[e.DocumentID,e.MimeType]),t?(0,H.jsx)(`div`,{className:`emoji-glyph`,children:e.Alt||`🙂`}):(0,H.jsx)(Ir,{className:`emoji-anim`,cacheKey:e.DocumentID,loader:()=>k.emojiAnimation(e.DocumentID),onError:()=>n(!0)})}function Br({row:e}){let{t}=U(),[n,r]=(0,g.useState)(!1);async function i(){try{await navigator.clipboard.writeText(e.DocumentID),r(!0),setTimeout(()=>r(!1),1200)}catch{}}return(0,H.jsxs)(`div`,{className:`emoji-card`,children:[(0,H.jsx)(`div`,{className:`emoji-preview`,children:(0,H.jsx)(zr,{row:e})}),(0,H.jsxs)(`div`,{className:`emoji-meta`,children:[(0,H.jsx)(`span`,{className:`emoji-alt`,children:e.Alt||`—`}),(0,H.jsxs)(`button`,{className:`emoji-id`,type:`button`,onClick:i,title:t(`emoji.copyID`),children:[(0,H.jsx)(`span`,{className:`mono`,children:e.DocumentID}),n?(0,H.jsx)(he,{size:12}):(0,H.jsx)(be,{size:12})]}),(0,H.jsxs)(`span`,{className:`emoji-sub`,children:[e.SetTitle||t(`emoji.noSet`),` · `,Lr(e.Size)]})]})]})}function Vr({navigate:e}){let{t}=U(),[n,r]=(0,g.useState)(``),[i,a]=(0,g.useState)(null),[o,s]=(0,g.useState)(0),[c,l]=(0,g.useState)(!1),[u,d]=(0,g.useState)(``);async function f(e=!1){l(!0),d(``);let t=new URLSearchParams;n.trim()?t.set(`q`,n.trim()):e&&t.set(`before_id`,String(o));try{let e=await k.emoji(t);a(e),s(e.next_before_id)}catch(e){d(O(e))}finally{l(!1)}}(0,g.useEffect)(()=>{f(!1)},[]);let p=i?.rows??[];return(0,H.jsxs)(nn,{title:t(`emoji.pageTitle`),eyebrow:i?.listing===!1?t(`emoji.queryResults`):t(`emoji.recent`),actions:(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`button`,{className:`btn`,type:`button`,onClick:()=>e(`/emoji`),children:[(0,H.jsx)(R,{size:15}),` `,t(`emoji.packCatalog`)]}),(0,H.jsxs)(`button`,{className:`btn`,type:`button`,onClick:()=>f(!1),disabled:c,children:[(0,H.jsx)(nt,{size:15}),` `,t(`common.refresh`)]})]}),children:[u&&(0,H.jsx)(q,{children:u}),(0,H.jsx)(`div`,{className:`metric-row`,children:(0,H.jsx)(Y,{label:t(`emoji.currentPage`),value:String(p.length)})}),(0,H.jsx)(rn,{children:(0,H.jsxs)(`form`,{className:`toolbar`,onSubmit:e=>{e.preventDefault(),f(!1)},children:[(0,H.jsxs)(`label`,{className:`searchbox`,children:[(0,H.jsx)(it,{size:15}),(0,H.jsx)(`input`,{value:n,onChange:e=>r(e.target.value),placeholder:t(`emoji.searchPlaceholder`)})]}),(0,H.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,disabled:c,children:[c?(0,H.jsx)(te,{size:15,className:`spin`}):(0,H.jsx)(it,{size:15}),` `,t(`common.search`)]}),i?.listing&&i.has_more&&(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>f(!0),disabled:c,children:[(0,H.jsx)(ve,{size:15}),` `,t(`messages.nextPage`)]})]})}),(0,H.jsx)(`p`,{className:`about-text`,children:t(`emoji.hint`)}),p.length===0?(0,H.jsx)(`div`,{className:`empty-panel`,children:t(`common.noResults`)}):(0,H.jsx)(`div`,{className:`emoji-grid`,children:p.map(e=>(0,H.jsx)(Br,{row:e},e.DocumentID))})]})}function Hr({documentID:e}){let[t,n]=(0,g.useState)(!1);return t?(0,H.jsx)(`div`,{className:`sticker-list-thumb-empty`,children:(0,H.jsx)(Le,{size:14})}):(0,H.jsx)(`video`,{className:`gif-catalog-thumb`,src:k.gifCatalogDocumentPreviewURL(e),muted:!0,loop:!0,autoPlay:!0,playsInline:!0,onError:()=>n(!0)})}function Ur(){let{t:e}=U(),[t,n]=(0,g.useState)([]),[r,i]=(0,g.useState)(!1),[a,o]=(0,g.useState)(``),[s,c]=(0,g.useState)(!1),[l,u]=(0,g.useState)({});async function d(){i(!0),o(``);try{n((await k.gifCatalog()).rows??[])}catch(e){o(O(e))}finally{i(!1)}}(0,g.useEffect)(()=>{d()},[]);let f=(0,g.useMemo)(()=>t.filter(e=>e.Enabled).length,[t]);return(0,H.jsxs)(nn,{title:e(`gifCatalog.title`),eyebrow:e(`gifCatalog.eyebrow`),actions:(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`button`,{className:`btn`,type:`button`,disabled:r,onClick:()=>void d(),children:[(0,H.jsx)(nt,{size:15}),e(`common.refresh`)]}),(0,H.jsxs)(`button`,{className:`btn primary`,type:`button`,onClick:()=>c(!0),children:[(0,H.jsx)(Qe,{size:15}),e(`gifCatalog.add`)]})]}),children:[a&&(0,H.jsx)(q,{children:a}),(0,H.jsxs)(`div`,{className:`metric-row`,children:[(0,H.jsx)(Y,{label:e(`gifCatalog.entries`),value:`${t.length} / 50`}),(0,H.jsx)(Y,{label:e(`common.enabled`),value:String(f),tone:`good`})]}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:e(`common.preview`)}),(0,H.jsx)(`th`,{children:e(`common.title`)}),(0,H.jsx)(`th`,{children:e(`common.document`)}),(0,H.jsx)(`th`,{children:e(`common.source`)}),(0,H.jsx)(`th`,{children:e(`common.status`)}),(0,H.jsx)(`th`,{children:e(`common.order`)}),(0,H.jsx)(`th`,{children:e(`common.actions`)})]})}),(0,H.jsxs)(`tbody`,{children:[t.map(t=>(0,H.jsxs)(`tr`,{className:t.Enabled?``:`gift-row-disabled`,children:[(0,H.jsx)(`td`,{children:(0,H.jsx)(Hr,{documentID:t.DocumentID})}),(0,H.jsx)(`td`,{children:t.Title}),(0,H.jsx)(`td`,{className:`mono`,children:t.DocumentID}),(0,H.jsx)(`td`,{children:t.SourceFilename||e(`gifCatalog.adminUpload`)}),(0,H.jsx)(`td`,{children:t.Enabled?(0,H.jsx)(J,{tone:`good`,children:e(`common.enabled`)}):(0,H.jsx)(J,{tone:`danger`,children:e(`common.disabled`)})}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`div`,{className:`sort-order-editor`,children:[(0,H.jsx)(`input`,{className:`small-input`,type:`number`,value:l[t.ID]??String(t.SortOrder),onChange:e=>u(n=>({...n,[t.ID]:e.target.value}))}),(0,H.jsx)(Z,{compact:!0,tone:`neutral`,label:e(`common.save`),path:`/api/actions/set-gif-catalog-sort-order`,payload:()=>({id:t.ID,sort_order:Number(l[t.ID]??t.SortOrder)}),onDone:()=>void d()})]})}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`div`,{className:`gift-table-actions`,children:[(0,H.jsx)(Z,{compact:!0,tone:`neutral`,label:t.Enabled?e(`common.disable`):e(`common.enable`),path:`/api/actions/set-gif-catalog-enabled`,payload:()=>({id:t.ID,enabled:!t.Enabled}),onDone:()=>void d()}),(0,H.jsx)(Z,{compact:!0,tone:`danger`,label:e(`common.delete`),path:`/api/actions/delete-gif-catalog-entry`,payload:()=>({id:t.ID}),onDone:()=>void d()})]})})]},t.ID)),t.length===0&&(0,H.jsx)(sn,{colSpan:7})]})]})}),s&&(0,H.jsx)(Wr,{onClose:()=>c(!1),onCreated:()=>void d()})]})}function Wr({onClose:e,onCreated:t}){let{t:n}=U(),[r,i]=(0,g.useState)(``),[a,o]=(0,g.useState)(``),[s,c]=(0,g.useState)(null),[l,u]=(0,g.useState)(``),[d,f]=(0,g.useState)(!1),[p,m]=(0,g.useState)(``);function h(e){l&&URL.revokeObjectURL(l),c(e),u(e?URL.createObjectURL(e):``)}async function _(){if(!r.trim()||!a.trim()||!s){m(n(`gifCatalog.required`));return}f(!0),m(``);try{let n=new FormData;n.set(`metadata`,JSON.stringify({command_id:``,reason:a.trim(),confirm:!0,title:r.trim()})),n.set(`file`,s,s.name),await k.createGifCatalogEntry(n),t(),e()}catch(e){m(O(e))}finally{f(!1)}}return(0,Ln.createPortal)((0,H.jsx)(`div`,{className:`modal-backdrop`,role:`presentation`,children:(0,H.jsxs)(`section`,{className:`modal command-modal`,role:`dialog`,"aria-modal":`true`,children:[(0,H.jsxs)(`div`,{className:`modal-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`eyebrow`,children:n(`gifCatalog.newMaterial`)}),(0,H.jsx)(`h2`,{children:n(`gifCatalog.add`)})]}),(0,H.jsx)(`button`,{className:`icon-btn`,type:`button`,onClick:e,disabled:d,"aria-label":n(`common.close`),children:(0,H.jsx)(Dt,{size:15})})]}),(0,H.jsxs)(`div`,{className:`command-body`,children:[(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:n(`common.title`)}),(0,H.jsx)(`input`,{value:r,maxLength:128,onChange:e=>i(e.target.value)})]}),(0,H.jsxs)(`label`,{className:`gift-file-picker ${s?`has-file`:``}`,children:[(0,H.jsx)(`input`,{type:`file`,accept:`.gif,.mp4,image/gif,video/mp4`,onChange:e=>h(e.target.files?.[0]??null)}),(0,H.jsx)(`span`,{className:`gift-file-copy`,children:(0,H.jsx)(`strong`,{children:s?.name??n(`gifCatalog.chooseMedia`)})}),(0,H.jsx)(`span`,{className:`gift-file-action`,children:n(`common.choose`)})]}),l&&(0,H.jsx)(`div`,{className:`gif-catalog-preview`,children:s?.type===`video/mp4`?(0,H.jsx)(`video`,{src:l,autoPlay:!0,loop:!0,muted:!0,playsInline:!0}):(0,H.jsx)(`img`,{src:l,alt:``})}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:n(`action.reason`)}),(0,H.jsx)(`input`,{value:a,onChange:e=>o(e.target.value)})]}),p&&(0,H.jsx)(q,{children:p})]}),(0,H.jsxs)(`div`,{className:`modal-actions`,children:[(0,H.jsx)(`button`,{className:`btn`,type:`button`,onClick:e,disabled:d,children:n(`common.close`)}),(0,H.jsxs)(`button`,{className:`btn primary`,type:`button`,onClick:()=>void _(),disabled:d,children:[d?(0,H.jsx)(te,{className:`spin`,size:15}):(0,H.jsx)(St,{size:15}),n(`gifCatalog.add`)]})]})]})}),document.body)}function Gr({navigate:e}){let{t}=U(),[n,r]=(0,g.useState)(null),[i,a]=(0,g.useState)(``);(0,g.useEffect)(()=>{let e=!1;async function n(){try{let t=await k.dashboard();e||(r(t),a(``))}catch(n){e||a(n instanceof Error?n.message:t(`dashboard.loadError`))}}n();let i=window.setInterval(()=>void n(),15e3);return()=>{e=!0,window.clearInterval(i)}},[t]);let o=n?.counts,s=n?.storage,c=n?.host;return(0,H.jsxs)(`div`,{className:`dashboard-layout`,children:[i&&(0,H.jsx)(q,{children:i}),(0,H.jsxs)(qr,{title:t(`dashboard.needsAttention`),children:[(0,H.jsx)(Jr,{icon:(0,H.jsx)(ke,{}),label:t(`dashboard.pendingReports`),value:o?Wt(String(o.PendingReports)):`…`,tone:o&&o.PendingReports>0?`warn`:`good`,href:`/moderation`,navigate:e}),(0,H.jsx)(Jr,{icon:(0,H.jsx)(F,{}),label:t(`dashboard.verificationRequests`),value:o?Wt(String(o.PendingVerifications)):`…`,tone:o&&o.PendingVerifications>0?`warn`:`good`,href:`/verification`,navigate:e})]}),(0,H.jsxs)(qr,{title:t(`dashboard.peopleChats`),children:[(0,H.jsx)(Jr,{icon:(0,H.jsx)(Tt,{}),label:t(`dashboard.users`),value:o?Wt(String(o.Users)):`…`,href:`/accounts`,navigate:e}),(0,H.jsx)(Jr,{icon:(0,H.jsx)(oe,{}),label:t(`dashboard.onlineNow`),value:o?Wt(String(o.OnlineUsers)):`…`,sub:t(`dashboard.lastFiveMinutes`),href:`/accounts`,navigate:e}),(0,H.jsx)(Jr,{icon:(0,H.jsx)(de,{}),label:t(`dashboard.bots`),value:o?Wt(String(o.Bots)):`…`,href:`/bots`,navigate:e}),(0,H.jsx)(Jr,{icon:(0,H.jsx)(tt,{}),label:t(`dashboard.channels`),value:o?Wt(String(o.BroadcastChannels)):`…`,href:`/channels`,navigate:e}),(0,H.jsx)(Jr,{icon:(0,H.jsx)(ae,{}),label:t(`dashboard.supergroups`),value:o?Wt(String(o.Supergroups)):`…`,href:`/channels`,navigate:e})]}),(0,H.jsxs)(qr,{title:t(`dashboard.content`),children:[(0,H.jsx)(Jr,{icon:(0,H.jsx)(gt,{}),label:t(`dashboard.stickerPacks`),value:o?Wt(String(o.StickerSets)):`…`,href:`/stickers`,navigate:e}),(0,H.jsx)(Jr,{icon:(0,H.jsx)(pt,{}),label:t(`dashboard.emojiPacks`),value:o?Wt(String(o.EmojiSets)):`…`,href:`/emoji`,navigate:e}),(0,H.jsx)(Jr,{icon:(0,H.jsx)(Oe,{}),label:t(`dashboard.gifs`),value:o?Wt(String(o.Gifs)):`…`,sub:t(`dashboard.savedByUsers`),href:`/gif-catalog`,navigate:e}),(0,H.jsx)(Jr,{icon:(0,H.jsx)(Se,{}),label:t(`dashboard.mediaStorageUsed`),value:s?Gt(s.PhysicalBytes):`…`,sub:s?Kr(s,t):void 0,href:`/storage`,navigate:e})]}),(0,H.jsxs)(qr,{title:t(`dashboard.serverHealth`),hint:c?.Ready?void 0:t(`dashboard.waitingForSample`),children:[(0,H.jsx)(Yr,{icon:(0,H.jsx)(xe,{}),label:t(`dashboard.cpuLoad`),percent:c?.Ready?c.CPUPercent:void 0,valueText:c?.Ready?`${c.CPUPercent.toFixed(0)}%`:`…`}),(0,H.jsx)(Yr,{icon:(0,H.jsx)(We,{}),label:t(`dashboard.ramUsed`),percent:c?.Ready&&c.MemTotalBytes>0?c.MemUsedBytes/c.MemTotalBytes*100:void 0,valueText:c?.Ready?Gt(String(c.MemUsedBytes)):`…`,sub:c?.Ready?t(`dashboard.ofTotal`,{total:Gt(String(c.MemTotalBytes))}):void 0}),(0,H.jsx)(Yr,{icon:(0,H.jsx)(Fe,{}),label:t(`dashboard.diskFree`),percent:c?.Ready&&c.DiskTotalBytes>0?(c.DiskTotalBytes-c.DiskFreeBytes)/c.DiskTotalBytes*100:void 0,valueText:c?.Ready?Gt(String(c.DiskFreeBytes)):`…`,sub:c?.Ready?t(`dashboard.ofTotal`,{total:Gt(String(c.DiskTotalBytes))}):void 0,warnAbove:85})]})]})}function Kr(e,t){let n=e.Backends??[];return n.length===1?t(`dashboard.singleBackend`,{backend:n[0].Backend}):n.length>1?t(`dashboard.multipleBackends`,{count:n.length}):t(`dashboard.noMediaObjects`)}function qr({title:e,hint:t,children:n}){return(0,H.jsxs)(`div`,{className:`dashboard-section`,children:[(0,H.jsxs)(`div`,{className:`dashboard-section-title`,children:[e,t&&(0,H.jsx)(`span`,{children:t})]}),(0,H.jsx)(`div`,{className:`dashboard-grid`,children:n})]})}function Jr({icon:e,label:t,value:n,sub:r,tone:i=`neutral`,href:a,navigate:o}){let s=i===`neutral`?``:` ${i}`,c=(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`div`,{className:`stat-tile-head`,children:[(0,H.jsx)(`span`,{className:`stat-tile-icon`,children:e}),i===`warn`&&(0,H.jsx)(ie,{size:15,className:`stat-tile-open`})]}),(0,H.jsx)(`div`,{className:`stat-tile-value`,children:n}),(0,H.jsx)(`div`,{className:`stat-tile-label`,children:t}),r&&(0,H.jsx)(`div`,{className:`stat-tile-sub`,children:r})]});return a&&o?(0,H.jsx)(`a`,{className:`stat-tile clickable${s}`,href:a,onClick:e=>{e.preventDefault(),o(a)},children:c}):(0,H.jsx)(`div`,{className:`stat-tile${s}`,children:c})}function Yr({icon:e,label:t,percent:n,valueText:r,sub:i,warnAbove:a=90}){let o=n===void 0?0:Math.max(0,Math.min(100,n)),s=n===void 0?`neutral`:n>=a?`danger`:n>=a-15?`warn`:`neutral`;return(0,H.jsxs)(`div`,{className:`stat-tile${s===`neutral`?``:` ${s}`}`,children:[(0,H.jsx)(`div`,{className:`stat-tile-head`,children:(0,H.jsx)(`span`,{className:`stat-tile-icon`,children:e})}),(0,H.jsx)(`div`,{className:`stat-tile-value`,children:r}),(0,H.jsx)(`div`,{className:`stat-tile-label`,children:t}),i&&(0,H.jsx)(`div`,{className:`stat-tile-sub`,children:i}),(0,H.jsx)(`div`,{className:`stat-tile-bar`,children:(0,H.jsx)(`span`,{style:{width:`${o}%`}})})]})}function Xr({channelID:e,msgID:t,navigate:n}){let{t:r}=U(),[i,a]=(0,g.useState)(null),[o,s]=(0,g.useState)(``);async function c(){s(``);try{a(await k.groupMessage(e,t))}catch(e){s(O(e))}}if((0,g.useEffect)(()=>{c()},[e,t]),o)return(0,H.jsx)(q,{children:o});if(!i)return(0,H.jsx)(cn,{label:r(`common.loading`)});let l=i.Message;return(0,H.jsx)(nn,{title:r(`messages.groupDetailTitle`,{id:l.ID}),eyebrow:r(`messages.detailEyebrow`),actions:(0,H.jsxs)(`button`,{className:`btn icon-text`,onClick:()=>n(`/messages/groups`),children:[(0,H.jsx)(ce,{size:15}),` `,r(`messages.backGroup`)]}),children:(0,H.jsxs)(`div`,{className:`stacked-sections`,children:[(0,H.jsxs)(`section`,{className:`entity-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`entity-title`,children:r(`messages.channelGroupTitle`,{id:l.ChannelID})}),(0,H.jsx)(`div`,{className:`entity-subtitle`,children:r(`messages.senderSubtitle`,{sender:l.SenderUserID,date:Bt(l.Date)})})]}),(0,H.jsxs)(`div`,{className:`entity-badges`,children:[l.Deleted?(0,H.jsx)(J,{tone:`danger`,children:r(`common.deleted`)}):(0,H.jsx)(J,{children:r(`common.survived`)}),l.Pinned&&(0,H.jsx)(J,{tone:`warn`,children:r(`messages.pinned`)}),l.Post&&(0,H.jsx)(J,{children:r(`messages.channelPost`)}),(0,H.jsxs)(J,{children:[`pts `,l.PTS]})]})]}),(0,H.jsxs)(`div`,{className:`summary-grid`,children:[(0,H.jsx)(X,{label:r(`common.messageId`),value:String(l.ID),mono:!0}),(0,H.jsx)(X,{label:r(`messages.channelGroup`),value:String(l.ChannelID),mono:!0}),(0,H.jsx)(X,{label:r(`common.fromPeer`),value:`${l.FromPeerType}:${l.FromPeerID}`,mono:!0}),(0,H.jsx)(X,{label:r(`common.views`),value:String(l.ViewsCount)})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:r(`messages.channelMessageRow`),text:r(`messages.channelMessagesSnapshot`)}),(0,H.jsx)(ln,{value:i.MessageJSON})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:r(`messages.channelRow`),text:r(`messages.channelSnapshot`)}),(0,H.jsx)(ln,{value:i.ChannelJSON})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:r(`messages.channelUpdateEvents`),text:r(`messages.channelEventsSource`)}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:`PTS`}),(0,H.jsx)(`th`,{children:r(`common.count`)}),(0,H.jsx)(`th`,{children:r(`common.type`)}),(0,H.jsx)(`th`,{children:r(`common.messageId`)}),(0,H.jsx)(`th`,{children:r(`common.sender`)}),(0,H.jsx)(`th`,{children:r(`common.time`)})]})}),(0,H.jsxs)(`tbody`,{children:[i.UpdateEvents.map(e=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{children:e.PTS}),(0,H.jsx)(`td`,{children:e.PTSCount}),(0,H.jsx)(`td`,{children:e.Type}),(0,H.jsx)(`td`,{children:e.MessageID}),(0,H.jsx)(`td`,{children:e.SenderUserID}),(0,H.jsx)(`td`,{children:Bt(e.Date)})]},`${e.PTS}-${e.Type}-${e.MessageID}`)),i.UpdateEvents.length===0&&(0,H.jsx)(sn,{colSpan:6})]})]})})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:r(`messages.eventJson`)}),(0,H.jsxs)(`div`,{className:`raw-grid`,children:[i.UpdateEvents.map(e=>(0,H.jsx)(ln,{value:e.JSON},`${e.PTS}-${e.Type}-json`)),i.UpdateEvents.length===0&&(0,H.jsx)(`div`,{className:`empty-panel`,children:r(`common.noResults`)})]})]})]})})}function Zr({navigate:e}){let{t}=U(),[n,r]=(0,g.useState)(null),[i,a]=(0,g.useState)(``),[o,s]=(0,g.useState)(``),[c,l]=(0,g.useState)(`100`),[u,d]=(0,g.useState)(null),[f,p]=(0,g.useState)(``);async function m(e=!1){if(p(``),!n){p(t(`messages.selectChannel`));return}let r=new URLSearchParams({channel_id:String(n.ID),limit:c});if(e&&u?.rows.length){let e=u.rows[u.rows.length-1];r.set(`before_date`,String(e.Date)),r.set(`before_id`,String(e.ID)),a(String(e.Date)),s(String(e.ID))}else i&&r.set(`before_date`,i),o&&r.set(`before_id`,o);try{d(await k.groupMessages(r))}catch(e){p(O(e))}}function h(e){r(e),a(``),s(``),d(null)}let _=u?.rows??[];return(0,H.jsxs)(nn,{title:t(`messages.groupTitle`),eyebrow:t(`messages.groupEyebrow`),children:[f&&(0,H.jsx)(q,{children:f}),(0,H.jsxs)(rn,{children:[(0,H.jsx)(`div`,{className:`message-selector-grid single`,children:(0,H.jsx)(mr,{label:t(`messages.channelGroup`),value:n,onChange:h})}),(0,H.jsxs)(`form`,{className:`toolbar message-query`,onSubmit:e=>{e.preventDefault(),m(!1)},children:[(0,H.jsx)(`input`,{value:i,onChange:e=>a(e.target.value),placeholder:t(`messages.beforeDatePlaceholder`)}),(0,H.jsx)(`input`,{value:o,onChange:e=>s(e.target.value),placeholder:t(`messages.beforeIDPlaceholder`)}),(0,H.jsx)(`input`,{className:`small-input`,value:c,onChange:e=>l(e.target.value),placeholder:t(`messages.limitPlaceholder`)}),(0,H.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,children:[(0,H.jsx)(it,{size:15}),` `,t(`messages.searchMessages`)]}),_.length?(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>m(!0),children:[(0,H.jsx)(ve,{size:15}),` `,t(`messages.nextPage`)]}):null]})]}),(0,H.jsxs)(`div`,{className:`metric-row`,children:[(0,H.jsx)(Y,{label:t(`messages.currentPage`),value:String(_.length)}),(0,H.jsx)(Y,{label:t(`messages.mediaCount`),value:String(_.filter(e=>e.Media&&e.Media!==`{}`).length)}),(0,H.jsx)(Y,{label:t(`messages.channelPosts`),value:String(_.filter(e=>e.Post).length)}),(0,H.jsx)(Y,{label:t(`messages.channelGroup`),value:n?`${n.Title||zt(n,t)} (${n.ID})`:`-`})]}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:t(`common.messageId`)}),(0,H.jsx)(`th`,{children:t(`common.time`)}),(0,H.jsx)(`th`,{children:t(`common.sender`)}),(0,H.jsx)(`th`,{children:t(`common.fromPeer`)}),(0,H.jsx)(`th`,{children:`PTS`}),(0,H.jsx)(`th`,{children:t(`common.views`)}),(0,H.jsx)(`th`,{children:t(`common.status`)}),(0,H.jsx)(`th`,{children:t(`messages.body`)}),(0,H.jsx)(`th`,{})]})}),(0,H.jsxs)(`tbody`,{children:[_.map(n=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{className:`mono`,children:n.ID}),(0,H.jsx)(`td`,{children:Bt(n.Date)}),(0,H.jsx)(`td`,{className:`mono`,children:n.SenderUserID}),(0,H.jsxs)(`td`,{className:`mono`,children:[n.FromPeerType,`:`,n.FromPeerID]}),(0,H.jsx)(`td`,{children:n.PTS}),(0,H.jsx)(`td`,{children:n.ViewsCount}),(0,H.jsx)(`td`,{children:n.Deleted?(0,H.jsx)(J,{tone:`danger`,children:t(`common.deleted`)}):n.Pinned?(0,H.jsx)(J,{tone:`warn`,children:t(`messages.pinned`)}):(0,H.jsx)(J,{children:t(`common.survived`)})}),(0,H.jsx)(`td`,{className:`truncate`,children:n.Body}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`button`,{className:`row-link`,onClick:()=>e(`/messages/groups/detail?channel_id=${n.ChannelID}&msg_id=${n.ID}`),children:[t(`common.detail`),` `,(0,H.jsx)(ve,{size:14})]})})]},`${n.ChannelID}-${n.ID}`)),_.length===0&&(0,H.jsx)(sn,{colSpan:9})]})]})})]})}function Qr({ownerUserID:e,msgID:t,navigate:n}){let{t:r}=U(),[i,a]=(0,g.useState)(null),[o,s]=(0,g.useState)(``);async function c(){s(``);try{a(await k.message(e,t))}catch(e){s(O(e))}}if((0,g.useEffect)(()=>{c()},[e,t]),o)return(0,H.jsx)(q,{children:o});if(!i)return(0,H.jsx)(cn,{label:r(`common.loading`)});let l=i.Message;return(0,H.jsx)(nn,{title:r(`messages.privateDetailTitle`,{id:l.BoxID}),eyebrow:r(`messages.detailEyebrow`),actions:(0,H.jsxs)(`button`,{className:`btn icon-text`,onClick:()=>n(`/messages/private`),children:[(0,H.jsx)(ce,{size:15}),` `,r(`messages.backPrivate`)]}),children:(0,H.jsx)(an,{main:(0,H.jsxs)(`div`,{className:`stacked-sections`,children:[(0,H.jsxs)(`section`,{className:`entity-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`entity-title`,children:r(`messages.ownerPeerTitle`,{owner:l.OwnerUserID,peer:l.PeerID})}),(0,H.jsx)(`div`,{className:`entity-subtitle`,children:r(`messages.senderSubtitle`,{sender:l.FromUserID,date:Bt(l.Date)})})]}),(0,H.jsxs)(`div`,{className:`entity-badges`,children:[l.Deleted?(0,H.jsx)(J,{tone:`danger`,children:r(`common.deleted`)}):(0,H.jsx)(J,{children:r(`common.survived`)}),(0,H.jsxs)(J,{children:[`pts `,l.PTS]}),(0,H.jsx)(J,{children:l.Outgoing?r(`messages.outgoing`):r(`messages.incoming`)})]})]}),(0,H.jsxs)(`div`,{className:`summary-grid`,children:[(0,H.jsx)(X,{label:r(`messages.boxID`),value:String(l.BoxID),mono:!0}),(0,H.jsx)(X,{label:r(`messages.privateMessageID`),value:String(l.PrivateMessageID),mono:!0}),(0,H.jsx)(X,{label:r(`messages.messageSender`),value:String(l.MessageSenderID),mono:!0}),(0,H.jsx)(X,{label:r(`common.time`),value:Bt(l.Date)})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:r(`messages.messageBox`),text:r(`messages.messageBoxesSnapshot`)}),(0,H.jsx)(ln,{value:i.MessageJSON})]}),(0,H.jsxs)(`div`,{className:`raw-grid`,children:[(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:r(`messages.dialogRow`),text:r(`messages.dialogSnapshot`)}),(0,H.jsx)(ln,{value:i.DialogJSON})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:r(`messages.privateRow`),text:r(`messages.privateSnapshot`)}),(0,H.jsx)(ln,{value:i.PrivateJSON})]})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:r(`messages.userUpdateEvents`),text:r(`messages.userEventsSource`)}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:`PTS`}),(0,H.jsx)(`th`,{children:r(`common.count`)}),(0,H.jsx)(`th`,{children:r(`common.type`)}),(0,H.jsx)(`th`,{children:r(`common.time`)})]})}),(0,H.jsxs)(`tbody`,{children:[i.UpdateEvents.map(e=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{children:e.PTS}),(0,H.jsx)(`td`,{children:e.PTSCount}),(0,H.jsx)(`td`,{children:e.Type}),(0,H.jsx)(`td`,{children:Bt(e.Date)})]},`${e.PTS}-${e.Type}`)),i.UpdateEvents.length===0&&(0,H.jsx)(sn,{colSpan:4})]})]})})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:r(`messages.dispatchOutbox`),text:r(`messages.outboxSource`)}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:`ID`}),(0,H.jsx)(`th`,{children:r(`account.userID`)}),(0,H.jsx)(`th`,{children:`PTS`}),(0,H.jsx)(`th`,{children:r(`common.type`)}),(0,H.jsx)(`th`,{children:r(`common.status`)}),(0,H.jsx)(`th`,{children:r(`messages.attempts`)}),(0,H.jsx)(`th`,{children:r(`common.updatedAt`)})]})}),(0,H.jsxs)(`tbody`,{children:[i.Outbox.map(e=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{children:e.ID}),(0,H.jsx)(`td`,{children:e.TargetUserID}),(0,H.jsx)(`td`,{children:e.PTS}),(0,H.jsx)(`td`,{children:e.EventType}),(0,H.jsx)(`td`,{children:e.Status}),(0,H.jsx)(`td`,{children:e.Attempts}),(0,H.jsx)(`td`,{children:G(e.UpdatedAt)})]},e.ID)),i.Outbox.length===0&&(0,H.jsx)(sn,{colSpan:7})]})]})})]})]}),side:(0,H.jsxs)(`section`,{className:`action-dock`,children:[(0,H.jsx)(`div`,{className:`dock-title`,children:r(`common.operations`)}),(0,H.jsx)(Z,{label:r(`messages.deleteThis`),icon:(0,H.jsx)(yt,{size:15}),path:`/api/actions/delete-messages`,payload:()=>({owner_user_id:l.OwnerUserID,peer_id:l.PeerID,ids:[l.BoxID],revoke:!0}),onDone:c})]})})})}function $r({navigate:e}){let{t}=U(),[n,r]=(0,g.useState)(null),[i,a]=(0,g.useState)(null),[o,s]=(0,g.useState)(``),[c,l]=(0,g.useState)(``),[u,d]=(0,g.useState)(`100`),[f,p]=(0,g.useState)(``),[m,h]=(0,g.useState)(!0),[_,v]=(0,g.useState)(!1),[y,b]=(0,g.useState)(``),[x,S]=(0,g.useState)(`1`),[C,w]=(0,g.useState)(null),[T,E]=(0,g.useState)(``);async function D(e=!1){if(E(``),!n||!i){E(t(`messages.selectPrivatePeers`));return}let r=new URLSearchParams({owner_user_id:String(n.ID),peer_id:String(i.ID),limit:u});if(e&&C?.rows.length){let e=C.rows[C.rows.length-1];r.set(`before_date`,String(e.Date)),r.set(`before_id`,String(e.BoxID)),s(String(e.Date)),l(String(e.BoxID))}else o&&r.set(`before_date`,o),c&&r.set(`before_id`,c);try{w(await k.messages(r))}catch(e){E(O(e))}}function A(e){r(e),s(``),l(``),w(null)}function j(e){a(e),s(``),l(``),w(null)}return(0,H.jsxs)(nn,{title:t(`messages.privateTitle`),eyebrow:t(`messages.privateEyebrow`),children:[T&&(0,H.jsx)(q,{children:T}),(0,H.jsxs)(rn,{children:[(0,H.jsxs)(`div`,{className:`message-selector-grid`,children:[(0,H.jsx)(fr,{label:t(`messages.ownerUser`),value:n,onChange:A}),(0,H.jsx)(fr,{label:t(`messages.peerUser`),value:i,onChange:j})]}),(0,H.jsxs)(`form`,{className:`toolbar message-query`,onSubmit:e=>{e.preventDefault(),D(!1)},children:[(0,H.jsx)(`input`,{value:o,onChange:e=>s(e.target.value),placeholder:t(`messages.beforeDatePlaceholder`)}),(0,H.jsx)(`input`,{value:c,onChange:e=>l(e.target.value),placeholder:t(`messages.beforeIDPlaceholder`)}),(0,H.jsx)(`input`,{className:`small-input`,value:u,onChange:e=>d(e.target.value),placeholder:t(`messages.limitPlaceholder`)}),(0,H.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,children:[(0,H.jsx)(it,{size:15}),` `,t(`messages.searchMessages`)]}),C?.rows.length?(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>D(!0),children:[(0,H.jsx)(ve,{size:15}),` `,t(`messages.nextPage`)]}):null]})]}),(0,H.jsxs)(`div`,{className:`metric-row`,children:[(0,H.jsx)(Y,{label:t(`messages.currentPage`),value:String(C?.rows.length??0)}),(0,H.jsx)(Y,{label:t(`messages.deleted`),value:String((C?.rows??[]).filter(e=>e.Deleted).length),tone:`danger`}),(0,H.jsx)(Y,{label:t(`messages.outgoing`),value:String((C?.rows??[]).filter(e=>e.Outgoing).length)}),(0,H.jsx)(Y,{label:t(`messages.ownerPeer`),value:n&&i?`${Rt(n)} / ${Rt(i)}`:`-`})]}),(0,H.jsxs)(`div`,{className:`operation-row`,children:[(0,H.jsxs)(`div`,{className:`operation-box`,children:[(0,H.jsxs)(`div`,{className:`operation-title`,children:[(0,H.jsx)(yt,{size:15}),` `,t(`messages.deleteSelected`)]}),(0,H.jsx)(`input`,{value:f,onChange:e=>p(e.target.value),placeholder:t(`messages.idsPlaceholder`)}),(0,H.jsxs)(`label`,{className:`checkline`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:m,onChange:e=>h(e.target.checked)}),` `,t(`messages.revoke`)]}),(0,H.jsx)(Z,{path:`/api/actions/delete-messages`,label:t(`messages.previewDelete`),payload:()=>({owner_user_id:n?.ID??0,peer_id:i?.ID??0,ids:$t(f,t(`messages.msgIDsInvalid`)),revoke:m})})]}),(0,H.jsxs)(`div`,{className:`operation-box`,children:[(0,H.jsxs)(`div`,{className:`operation-title`,children:[(0,H.jsx)(Ie,{size:15}),` `,t(`messages.clearHistory`)]}),(0,H.jsx)(`input`,{value:y,onChange:e=>b(e.target.value),placeholder:t(`messages.maxIDPlaceholder`)}),(0,H.jsx)(`input`,{value:x,onChange:e=>S(e.target.value),placeholder:t(`messages.maxBatchesPlaceholder`)}),(0,H.jsxs)(`label`,{className:`checkline`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:m,onChange:e=>h(e.target.checked)}),` `,t(`messages.revoke`)]}),(0,H.jsxs)(`label`,{className:`checkline`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:_,onChange:e=>v(e.target.checked)}),` `,t(`messages.justClear`)]}),(0,H.jsx)(Z,{path:`/api/actions/delete-history`,label:t(`messages.previewClearHistory`),payload:()=>({owner_user_id:n?.ID??0,peer_id:i?.ID??0,max_id:Ht(y),max_batches:Ht(x),just_clear:_,revoke:m})})]})]}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:t(`common.messageId`)}),(0,H.jsx)(`th`,{children:t(`common.time`)}),(0,H.jsx)(`th`,{children:t(`common.sender`)}),(0,H.jsx)(`th`,{children:t(`messages.direction`)}),(0,H.jsx)(`th`,{children:`PTS`}),(0,H.jsx)(`th`,{children:t(`common.status`)}),(0,H.jsx)(`th`,{children:t(`messages.body`)}),(0,H.jsx)(`th`,{})]})}),(0,H.jsxs)(`tbody`,{children:[C?.rows.map(n=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{className:`mono`,children:n.BoxID}),(0,H.jsx)(`td`,{children:Bt(n.Date)}),(0,H.jsx)(`td`,{className:`mono`,children:n.FromUserID}),(0,H.jsx)(`td`,{children:n.Outgoing?t(`messages.outgoing`):t(`messages.incoming`)}),(0,H.jsx)(`td`,{children:n.PTS}),(0,H.jsx)(`td`,{children:n.Deleted?(0,H.jsx)(J,{tone:`danger`,children:t(`common.deleted`)}):(0,H.jsx)(J,{children:t(`common.survived`)})}),(0,H.jsx)(`td`,{className:`truncate`,children:n.Body}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`button`,{className:`row-link`,onClick:()=>e(`/messages/private/detail?owner_user_id=${n.OwnerUserID}&msg_id=${n.BoxID}`),children:[t(`common.detail`),` `,(0,H.jsx)(ve,{size:14})]})})]},`${n.OwnerUserID}-${n.BoxID}`)),(!C||C.rows.length===0)&&(0,H.jsx)(sn,{colSpan:8})]})]})})]})}var ei=0,ti=e=>`${e}-${++ei}`,ni=[{center:`#6f5bea`,edge:`#34278f`,pattern:`#a89df5`,text:`#ffffff`},{center:`#32a86b`,edge:`#17613e`,pattern:`#8ee0b3`,text:`#ffffff`},{center:`#df8d2f`,edge:`#8c421e`,pattern:`#ffd08a`,text:`#ffffff`},{center:`#d95878`,edge:`#7b2944`,pattern:`#f5a1b6`,text:`#ffffff`}];function ri(e){if(!e.length)return e;let t=Math.floor(1e3/e.length),n=1e3%e.length;return e.map((e,r)=>({...e,rarity:String(t+ +(r({key:ti(e),name:``,rarity:`1`,sortOrder:String(t),file:null,animation:null,fileError:``});function ai(e){let t=e.reduce((e,t)=>{let n=Number(t.backdropID);return Number.isInteger(n)?Math.max(e,n):e},0)+1,n=ni[e.length%ni.length];return{key:ti(`backdrop`),name:``,backdropID:String(t),rarity:`1`,sortOrder:String(e.length),...n}}var oi=e=>ri([ii(e,0),ii(e,1)]),si=()=>{let e=ai([]);return ri([e,ai([e])])};function ci({data:e,compact:t=!1}){let n=(0,g.useRef)(null);return(0,g.useEffect)(()=>{if(!n.current)return;let t=Fr.default.loadAnimation({container:n.current,renderer:`canvas`,loop:!0,autoplay:!0,animationData:structuredClone(e)});return()=>t.destroy()},[e]),(0,H.jsx)(`div`,{className:`collectible-animation ${t?`compact`:``}`,ref:n})}function li({giftID:e,attribute:t}){let[n,r]=(0,g.useState)(null),[i,a]=(0,g.useState)(!1);return(0,g.useEffect)(()=>{let n=!1;return a(!1),k.giftCollectibleAnimation(e,t.kind,t.id).then(e=>{n||r(e)}).catch(()=>{n||a(!0)}),()=>{n=!0}},[e,t.id,t.kind]),i?(0,H.jsx)(`div`,{className:`collectible-animation compact failed`,children:`!`}):n?(0,H.jsx)(ci,{data:n,compact:!0}):(0,H.jsx)(`div`,{className:`collectible-animation compact loading`,children:(0,H.jsx)(te,{className:`spin`,size:15})})}async function ui(e){let t=new Uint8Array(await e.arrayBuffer()),n=t;if(t.length>=2&&t[0]===31&&t[1]===139){if(!(`DecompressionStream`in window))throw Error(`This browser cannot preview TGS files`);let e=new Blob([t]).stream().pipeThrough(new DecompressionStream(`gzip`));n=new Uint8Array(await new Response(e).arrayBuffer())}let r=JSON.parse(new TextDecoder().decode(n));if(!r||typeof r!=`object`||Array.isArray(r))throw Error(`Invalid Lottie JSON`);return r}var di=e=>Number.parseInt(e.replace(`#`,``),16),fi=e=>e.rarity_kind===`permille`?`${e.rarity_permille}‰`:e.rarity_kind;function pi({gift:e,onClose:t,onPublished:n}){let{t:r}=U(),[i,a]=(0,g.useState)(null),[o,s]=(0,g.useState)(!0),[c,l]=(0,g.useState)(!1),[u,d]=(0,g.useState)(``),[f,p]=(0,g.useState)(null),[m,h]=(0,g.useState)(`100`),[_,v]=(0,g.useState)(`1000`),[y,b]=(0,g.useState)(`gift-${e.GiftID}`),[x,S]=(0,g.useState)(``),[C,w]=(0,g.useState)(()=>oi(`model`)),[T,E]=(0,g.useState)(()=>oi(`pattern`)),[D,A]=(0,g.useState)(si);(0,g.useEffect)(()=>{let t=!1;return k.giftCollectibles(e.GiftID).then(n=>{t||(a(n),n.found&&(h(String(n.upgrade_stars??100)),v(String(n.supply_total??1e3)),b(n.slug_prefix??`gift-${e.GiftID}`)))}).catch(e=>d(O(e))).finally(()=>{t||s(!1)}),()=>{t=!0}},[e.GiftID]);let j=(0,g.useMemo)(()=>({models:C.reduce((e,t)=>e+Number(t.rarity||0),0),patterns:T.reduce((e,t)=>e+Number(t.rarity||0),0),backdrops:D.reduce((e,t)=>e+Number(t.rarity||0),0)}),[C,T,D]),M=()=>p(null),N=(e,t,n)=>{(e===`models`?w:E)(e=>e.map(e=>e.key===t?{...e,...n}:e)),M()};async function P(e,t,n){if(N(e,t.key,{file:n,animation:null,fileError:``}),n)try{let r=await ui(n);N(e,t.key,{animation:r,fileError:``})}catch(n){N(e,t.key,{animation:null,fileError:O(n)})}}function F(e,t=``){if(!x.trim())throw Error(r(`action.reasonRequired`));if(C.length<2||T.length<2||D.length<2)throw Error(r(`collectibles.minimumAttributes`));let n=D.map(e=>Number(e.backdropID));if(new Set(n).size!==n.length)throw Error(r(`collectibles.duplicateBackdropID`));for(let e of[...C,...T])if(!e.file)throw Error(r(`collectibles.fileRequired`));let i=new FormData,a=e=>e.map(e=>({name:e.name.trim(),rarity_permille:Number(e.rarity),sort_order:Number(e.sortOrder),file_key:e.key}));i.set(`metadata`,JSON.stringify({command_id:t,reason:x.trim(),confirm:e,upgrade_stars:m,supply_total:Number(_),slug_prefix:y.trim().toLowerCase(),models:a(C),patterns:a(T),backdrops:D.map(e=>({name:e.name.trim(),backdrop_id:Number(e.backdropID),rarity_permille:Number(e.rarity),sort_order:Number(e.sortOrder),center_color:di(e.center),edge_color:di(e.edge),pattern_color:di(e.pattern),text_color:di(e.text)}))}));for(let e of[...C,...T])i.set(e.key,e.file,e.file.name);return i}async function I(){l(!0),d(``),p(null);try{p(await k.publishGiftCollectibles(e.GiftID,F(!1)))}catch(e){d(O(e))}finally{l(!1)}}async function ee(){if(f){l(!0),d(``);try{await k.publishGiftCollectibles(e.GiftID,F(!0,f.command_id)),n(),t()}catch(e){d(O(e))}finally{l(!1)}}}let R=(e,t,n)=>(0,H.jsxs)(`section`,{className:`collectible-section`,children:[(0,H.jsxs)(`div`,{className:`collectible-section-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:r(`collectibles.${e}`)}),(0,H.jsx)(`span`,{children:r(`collectibles.rarityHint`)})]}),(0,H.jsxs)(`div`,{className:`collectible-section-tools`,children:[(0,H.jsxs)(J,{tone:j[e]>0?`good`:`neutral`,children:[j[e],`‰`]}),(0,H.jsxs)(`button`,{className:`btn compact-btn`,type:`button`,onClick:()=>{n(ri([...t,ii(e===`models`?`model`:`pattern`,t.length)])),M()},children:[(0,H.jsx)(Qe,{size:13}),r(`collectibles.addAttribute`)]})]})]}),(0,H.jsx)(`div`,{className:`collectible-rows`,children:t.map((i,a)=>(0,H.jsxs)(`div`,{className:`collectible-row animated`,children:[(0,H.jsx)(`div`,{className:`collectible-row-index`,children:a+1}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`common.name`)}),(0,H.jsx)(`input`,{value:i.name,maxLength:128,onChange:t=>N(e,i.key,{name:t.target.value})})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`collectibles.rarity`)}),(0,H.jsx)(`input`,{type:`number`,min:`1`,max:`1000`,value:i.rarity,onChange:t=>N(e,i.key,{rarity:t.target.value})})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`gifts.sortOrder`)}),(0,H.jsx)(`input`,{type:`number`,value:i.sortOrder,onChange:t=>N(e,i.key,{sortOrder:t.target.value})})]}),(0,H.jsxs)(`label`,{className:`collectible-file`,children:[(0,H.jsx)(`span`,{children:r(`gifts.animation`)}),(0,H.jsx)(`input`,{type:`file`,accept:`.tgs,.json,.lottie,application/json,application/x-tgsticker`,onChange:t=>void P(e,i,t.target.files?.[0]??null)}),(0,H.jsxs)(`em`,{children:[(0,H.jsx)(Te,{size:13}),i.file?.name??r(`gifts.chooseFile`)]})]}),(0,H.jsx)(`div`,{className:`collectible-inline-preview`,children:i.animation?(0,H.jsx)(ci,{data:i.animation,compact:!0}):(0,H.jsx)(re,{size:16})}),(0,H.jsx)(`button`,{className:`icon-btn danger`,type:`button`,disabled:t.length<=2,onClick:()=>{n(ri(t.filter(e=>e.key!==i.key))),M()},"aria-label":r(`collectibles.remove`),children:(0,H.jsx)(yt,{size:14})}),i.fileError&&(0,H.jsx)(`span`,{className:`collectible-file-error`,children:i.fileError})]},i.key))})]});return(0,Ln.createPortal)((0,H.jsx)(`div`,{className:`modal-backdrop`,role:`presentation`,children:(0,H.jsxs)(`section`,{className:`modal command-modal collectible-modal`,role:`dialog`,"aria-modal":`true`,"aria-label":r(`collectibles.title`,{id:e.GiftID}),children:[(0,H.jsxs)(`div`,{className:`modal-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`eyebrow`,children:r(`collectibles.eyebrow`)}),(0,H.jsx)(`h2`,{children:r(`collectibles.title`,{id:e.GiftID})}),(0,H.jsx)(`p`,{children:e.Title||`Gift #${e.GiftID}`})]}),(0,H.jsx)(`button`,{className:`icon-btn`,type:`button`,onClick:t,disabled:c,"aria-label":r(`action.close`),children:(0,H.jsx)(Dt,{size:15})})]}),(0,H.jsxs)(`div`,{className:`command-body collectible-modal-body`,children:[o?(0,H.jsxs)(`div`,{className:`collectible-loading`,children:[(0,H.jsx)(te,{className:`spin`}),r(`common.loading`)]}):i?.found?(0,H.jsxs)(`section`,{className:`collectible-active`,children:[(0,H.jsxs)(`div`,{className:`collectible-active-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(Me,{size:18}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:r(`collectibles.activeRevision`,{revision:i.revision??0})}),(0,H.jsxs)(`span`,{children:[i.slug_prefix,` · ⭐ `,i.upgrade_stars,` · `,i.issued,` / `,i.supply_total]})]})]}),(0,H.jsx)(J,{tone:`good`,children:r(`collectibles.published`)})]}),(0,H.jsxs)(`div`,{className:`collectible-active-grid`,children:[[...i.models??[],...i.patterns??[]].map(t=>(0,H.jsxs)(`article`,{children:[(0,H.jsx)(li,{giftID:e.GiftID,attribute:t}),(0,H.jsxs)(`div`,{children:[(0,H.jsxs)(`strong`,{children:[t.name,t.crafted&&(0,H.jsx)(J,{children:`crafted`})]}),(0,H.jsxs)(`span`,{children:[r(`collectibles.${t.kind}`),` · `,fi(t)]})]})]},`${t.kind}-${t.id}`)),(i.backdrops??[]).map(e=>(0,H.jsxs)(`article`,{children:[(0,H.jsx)(`div`,{className:`collectible-backdrop-preview`,style:{background:`radial-gradient(circle, #${(e.center_color??0).toString(16).padStart(6,`0`)}, #${(e.edge_color??0).toString(16).padStart(6,`0`)})`,color:`#${(e.text_color??16777215).toString(16).padStart(6,`0`)}`},children:`Aa`}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:e.name}),(0,H.jsxs)(`span`,{children:[r(`collectibles.backdrop`),` · `,fi(e)]})]})]},`backdrop-${e.id}`))]})]}):(0,H.jsxs)(`div`,{className:`collectible-empty`,children:[(0,H.jsx)(Me,{size:22}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:r(`collectibles.noPool`)}),(0,H.jsx)(`span`,{children:r(`collectibles.noPoolHint`)})]})]}),(0,H.jsxs)(`section`,{className:`collectible-definition`,children:[(0,H.jsxs)(`div`,{className:`collectible-definition-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:r(`collectibles.publishNew`)}),(0,H.jsx)(`span`,{children:r(`collectibles.immutableHint`)})]}),(0,H.jsxs)(`div`,{className:`gift-format-chips`,children:[(0,H.jsx)(`span`,{children:`TGS`}),(0,H.jsx)(`span`,{children:`Lottie JSON`})]})]}),(0,H.jsxs)(`div`,{className:`gift-fields-grid collectible-main-fields`,children:[(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`collectibles.upgradeStars`)}),(0,H.jsx)(`input`,{type:`number`,min:`1`,value:m,onChange:e=>{h(e.target.value),M()}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`collectibles.supply`)}),(0,H.jsx)(`input`,{type:`number`,min:`1`,value:_,onChange:e=>{v(e.target.value),M()}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`collectibles.slug`)}),(0,H.jsx)(`input`,{value:y,maxLength:48,onChange:e=>{b(e.target.value.toLowerCase()),M()}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`gifts.reason`)}),(0,H.jsx)(`input`,{value:x,maxLength:1e3,placeholder:r(`gifts.reasonPlaceholder`),onChange:e=>S(e.target.value)})]})]}),R(`models`,C,w),R(`patterns`,T,E),(0,H.jsxs)(`section`,{className:`collectible-section`,children:[(0,H.jsxs)(`div`,{className:`collectible-section-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:r(`collectibles.backdrops`)}),(0,H.jsx)(`span`,{children:r(`collectibles.colorHint`)})]}),(0,H.jsxs)(`div`,{className:`collectible-section-tools`,children:[(0,H.jsxs)(J,{tone:j.backdrops>0?`good`:`neutral`,children:[j.backdrops,`‰`]}),(0,H.jsxs)(`button`,{className:`btn compact-btn`,type:`button`,onClick:()=>{A(ri([...D,ai(D)])),M()},children:[(0,H.jsx)(Qe,{size:13}),r(`collectibles.addAttribute`)]})]})]}),(0,H.jsx)(`div`,{className:`collectible-rows`,children:D.map((e,t)=>(0,H.jsxs)(`div`,{className:`collectible-row backdrop`,children:[(0,H.jsx)(`div`,{className:`collectible-row-index`,children:t+1}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`common.name`)}),(0,H.jsx)(`input`,{value:e.name,maxLength:128,onChange:t=>{A(D.map(n=>n.key===e.key?{...n,name:t.target.value}:n)),M()}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`collectibles.backdropID`)}),(0,H.jsx)(`input`,{type:`number`,min:`0`,value:e.backdropID,onChange:t=>{A(D.map(n=>n.key===e.key?{...n,backdropID:t.target.value}:n)),M()}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`collectibles.rarity`)}),(0,H.jsx)(`input`,{type:`number`,min:`1`,max:`1000`,value:e.rarity,onChange:t=>{A(D.map(n=>n.key===e.key?{...n,rarity:t.target.value}:n)),M()}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`gifts.sortOrder`)}),(0,H.jsx)(`input`,{type:`number`,value:e.sortOrder,onChange:t=>{A(D.map(n=>n.key===e.key?{...n,sortOrder:t.target.value}:n)),M()}})]}),[`center`,`edge`,`pattern`,`text`].map(t=>(0,H.jsxs)(`label`,{className:`collectible-color`,children:[(0,H.jsx)(`span`,{children:r(`collectibles.color.${t}`)}),(0,H.jsx)(`input`,{type:`color`,value:e[t],onChange:n=>{A(D.map(r=>r.key===e.key?{...r,[t]:n.target.value}:r)),M()}})]},t)),(0,H.jsx)(`div`,{className:`collectible-backdrop-preview`,style:{background:`radial-gradient(circle, ${e.center}, ${e.edge})`,color:e.text},children:`Aa`}),(0,H.jsx)(`button`,{className:`icon-btn danger`,type:`button`,disabled:D.length<=2,onClick:()=>{A(ri(D.filter(t=>t.key!==e.key))),M()},"aria-label":r(`collectibles.remove`),children:(0,H.jsx)(yt,{size:14})})]},e.key))})]})]}),u&&(0,H.jsx)(q,{children:u}),f&&(0,H.jsxs)(`div`,{className:`gift-validation`,children:[(0,H.jsxs)(`div`,{className:`gift-validation-head`,children:[(0,H.jsx)(L,{size:17}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:r(`collectibles.validationReady`)}),(0,H.jsx)(`span`,{children:r(`collectibles.validationHint`)})]})]}),(0,H.jsx)(`pre`,{children:JSON.stringify(f.details,null,2)})]})]}),(0,H.jsxs)(`div`,{className:`modal-actions`,children:[(0,H.jsx)(`button`,{className:`btn`,type:`button`,onClick:t,disabled:c,children:r(`common.close`)}),(0,H.jsxs)(`button`,{className:`btn`,type:`button`,onClick:I,disabled:c,children:[c?(0,H.jsx)(te,{className:`spin`,size:15}):(0,H.jsx)(lt,{size:15}),r(`gifts.validate`)]}),(0,H.jsxs)(`button`,{className:`btn primary`,type:`button`,onClick:ee,disabled:c||!f,children:[(0,H.jsx)(St,{size:15}),r(`collectibles.publish`)]})]})]})}),document.body)}function mi(e){return e.model_count+e.pattern_count+e.backdrop_count}function hi(e){let t=Number(e);return t<1024?`${t} B`:t<1024*1024?`${(t/1024).toFixed(1)} KB`:`${(t/(1024*1024)).toFixed(1)} MB`}function gi({giftID:e,revision:t,compact:n=!1}){let r=(0,g.useRef)(null),i=(0,g.useRef)(null),[a,o]=(0,g.useState)(!0),[s,c]=(0,g.useState)(``);(0,g.useEffect)(()=>{let t=!1;return k.giftAnimation(e).then(e=>{t||!r.current||(i.current?.destroy(),i.current=Fr.default.loadAnimation({container:r.current,renderer:`canvas`,loop:!0,autoplay:!0,animationData:structuredClone(e)}))}).catch(e=>c(O(e))),()=>{t=!0,i.current?.destroy(),i.current=null}},[e,t]);function l(){i.current&&(a?i.current.pause():i.current.play(),o(!a))}return(0,H.jsxs)(`div`,{className:`gift-animation-shell ${n?`compact`:``}`,children:[(0,H.jsx)(`div`,{className:`gift-animation`,ref:r,children:s&&(0,H.jsx)(`span`,{children:s})}),(0,H.jsx)(`button`,{className:`gift-play`,type:`button`,onClick:l,"aria-label":a?`Pause`:`Play`,children:a?(0,H.jsx)(Ye,{size:14}):(0,H.jsx)(Ze,{size:14})})]})}function _i({sourceGiftID:e}){let t=(0,g.useRef)(null);return(0,g.useEffect)(()=>{let n=!1,r=null;return k.officialGiftAnimation(e).then(e=>{n||!t.current||(r=Fr.default.loadAnimation({container:t.current,renderer:`canvas`,loop:!0,autoplay:!0,animationData:structuredClone(e)}))}).catch(()=>void 0),()=>{n=!0,r?.destroy()}},[e]),(0,H.jsx)(`div`,{className:`gift-animation-shell`,children:(0,H.jsx)(`div`,{className:`gift-animation`,ref:t})})}function vi(){let{t:e}=U(),[t,n]=(0,g.useState)([]),[r,i]=(0,g.useState)(``),[a,o]=(0,g.useState)(!1),[s,c]=(0,g.useState)(null),[l,u]=(0,g.useState)(null),[d,f]=(0,g.useState)(`official`),[p,m]=(0,g.useState)([]),[h,_]=(0,g.useState)(``),[v,y]=(0,g.useState)(`all`),[b,x]=(0,g.useState)(``),[S,C]=(0,g.useState)(!0),[w,T]=(0,g.useState)(`0`),[E,D]=(0,g.useState)(`0`),[A,j]=(0,g.useState)(``),[M,N]=(0,g.useState)(`0`),[P,F]=(0,g.useState)(``),[I,ee]=(0,g.useState)(`50`),[R,ne]=(0,g.useState)(`50`),[re,ie]=(0,g.useState)(`0`),[ae,oe]=(0,g.useState)(`regular`),[se,ce]=(0,g.useState)(``),[le,ue]=(0,g.useState)(`10`),[z,de]=(0,g.useState)(`1`),[fe,pe]=(0,g.useState)(`3600`),[me,he]=(0,g.useState)(``),[ge,_e]=(0,g.useState)(``),[ve,ye]=(0,g.useState)(!1),[be,xe]=(0,g.useState)(()=>tn(3600)),[B,Se]=(0,g.useState)(!0),[Ce,we]=(0,g.useState)(``),[Ee,De]=(0,g.useState)(null),[Oe,ke]=(0,g.useState)(!1),[Ae,je]=(0,g.useState)(``),[Ne,Pe]=(0,g.useState)(``);async function Fe(){je(``);try{n((await k.gifts()).Gifts??[])}catch(e){je(O(e))}}(0,g.useEffect)(()=>{Fe()},[]),(0,g.useEffect)(()=>{!a||d!==`official`||p.length>0||k.officialGifts().then(e=>m(e.gifts??[])).catch(e=>Pe(O(e)))},[a,d,p.length]);let Ie=(0,g.useMemo)(()=>p.find(e=>e.source_gift_id===b)??null,[p,b]),Le=(0,g.useMemo)(()=>({all:p.length,upgrade:p.filter(e=>e.can_upgrade).length,craft:p.filter(e=>e.can_craft).length,basic:p.filter(e=>!e.can_upgrade).length}),[p]),Re=(0,g.useMemo)(()=>{let e=h.trim().toLowerCase();return p.filter(t=>(v===`all`||v===`upgrade`&&t.can_upgrade||v===`craft`&&t.can_craft||v===`basic`&&!t.can_upgrade)&&(!e||t.source_gift_id.includes(e)||t.title.toLowerCase().includes(e)))},[p,h,v]),ze=(0,g.useMemo)(()=>{let e=Number(le),t=Number(z);return!(e>0)||!(t>0)?0:Math.ceil(e/t)},[le,z]),Be=(0,g.useMemo)(()=>{let e=r.trim().toLowerCase();return e?t.filter(t=>String(t.GiftID).includes(e)||t.Title.toLowerCase().includes(e)||t.SourceFormat.toLowerCase().includes(e)):t},[t,r]);function Ve(){let t=Math.floor(Date.now()/1e3);if(ae===`auction`){let n=Number(le),r=Number(z),i=Number(fe),a=en(me);if(!se.trim())throw Error(e(`gifts.lifecycle.slugRequired`));if(!(n>0))throw Error(e(`gifts.lifecycle.supplyRequired`));if(!(r>0))throw Error(e(`gifts.lifecycle.perRoundRequired`));if(r>n)throw Error(e(`gifts.lifecycle.perRoundTooLarge`));if(!(i>0))throw Error(e(`gifts.lifecycle.roundDurationRequired`));if(a!==0&&a0?t.availability_total:Math.max(t.upgrade_variants,1))),j(`official-${t.source_gift_id}`),De(null)}async function We(){ke(!0),Pe(``),De(null);try{De(d===`official`?await k.importOfficialGift(Ue(!1)):await k.importGift(He(!1)))}catch(e){Pe(O(e))}finally{ke(!1)}}async function Ge(){if(Ee){ke(!0),Pe(``);try{d===`official`?await k.importOfficialGift(Ue(!0,Ee.command_id)):await k.importGift(He(!0,Ee.command_id)),De(null),u(null),N(`0`),F(``),x(``),await Fe(),o(!1)}catch(e){Pe(O(e))}finally{ke(!1)}}}function Ke(){N(`0`),F(``),ee(`50`),ne(`50`),ie(`0`),Se(!0),we(``),u(null),De(null),Pe(``),f(`official`),x(``),_(``),y(`all`),o(!0)}function qe(e){N(e.GiftID),F(e.Title),ee(String(e.Stars)),ne(String(e.ConvertStars)),ie(String(e.SortOrder)),Se(e.Enabled),we(``),u(null),De(null),Pe(``),f(`official`),x(``),_(``),y(`all`),o(!0)}return(0,H.jsxs)(nn,{title:e(`gifts.pageTitle`),eyebrow:e(`gifts.eyebrow`),actions:(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`button`,{className:`btn`,type:`button`,onClick:()=>Fe(),disabled:Oe,children:[(0,H.jsx)(nt,{size:15}),` `,e(`common.refresh`)]}),(0,H.jsxs)(`button`,{className:`btn primary`,type:`button`,onClick:Ke,children:[(0,H.jsx)(Qe,{size:15}),` `,e(`gifts.add`)]})]}),children:[Ae&&(0,H.jsx)(q,{children:Ae}),(0,H.jsxs)(`div`,{className:`metric-row gift-metrics`,children:[(0,H.jsx)(Y,{label:e(`gifts.total`),value:String(t.length)}),(0,H.jsx)(Y,{label:e(`gifts.enabled`),value:String(t.filter(e=>e.Enabled).length),tone:`good`}),(0,H.jsx)(Y,{label:e(`gifts.received`),value:t.reduce((e,t)=>e+BigInt(t.ReceivedCount),0n).toString()}),(0,H.jsx)(Y,{label:e(`gifts.formats`),value:`TGS / Lottie`})]}),(0,H.jsx)(rn,{children:(0,H.jsxs)(`div`,{className:`toolbar`,children:[(0,H.jsxs)(`label`,{className:`searchbox`,children:[(0,H.jsx)(it,{size:15}),(0,H.jsx)(`input`,{value:r,onChange:e=>i(e.target.value),placeholder:e(`gifts.searchPlaceholder`)})]}),(0,H.jsx)(`span`,{className:`gift-list-summary`,children:e(`gifts.listSummary`,{shown:Be.length,total:t.length})})]})}),(0,H.jsx)(`div`,{className:`table-wrap gift-table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table gift-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:e(`gifts.animation`)}),(0,H.jsx)(`th`,{children:e(`gifts.idRevision`)}),(0,H.jsx)(`th`,{children:e(`gifts.title`)}),(0,H.jsx)(`th`,{children:e(`gifts.price`)}),(0,H.jsx)(`th`,{children:e(`gifts.source`)}),(0,H.jsx)(`th`,{children:e(`gifts.received`)}),(0,H.jsx)(`th`,{children:e(`common.status`)}),(0,H.jsx)(`th`,{children:e(`common.updatedAt`)}),(0,H.jsx)(`th`,{children:e(`common.actions`)})]})}),(0,H.jsxs)(`tbody`,{children:[Be.map(t=>(0,H.jsxs)(`tr`,{className:t.Enabled?``:`gift-row-disabled`,children:[(0,H.jsx)(`td`,{children:(0,H.jsx)(gi,{giftID:t.GiftID,revision:t.Revision,compact:!0})}),(0,H.jsxs)(`td`,{className:`mono`,children:[t.GiftID,` / `,t.Revision]}),(0,H.jsxs)(`td`,{children:[(0,H.jsx)(`strong`,{className:`gift-table-title`,children:t.Title||`Gift #${t.GiftID}`}),(0,H.jsxs)(`span`,{className:`gift-sort-order`,children:[e(`gifts.sortOrder`),`: `,t.SortOrder]})]}),(0,H.jsxs)(`td`,{children:[(0,H.jsxs)(`strong`,{className:`gift-table-price`,children:[`⭐ `,t.Stars]}),(0,H.jsxs)(`span`,{className:`gift-convert-price`,children:[`→ `,t.ConvertStars]})]}),(0,H.jsxs)(`td`,{children:[(0,H.jsx)(J,{children:t.SourceFormat}),(0,H.jsx)(`span`,{className:`gift-source-size`,children:hi(t.AnimationSize)})]}),(0,H.jsx)(`td`,{children:t.ReceivedCount}),(0,H.jsx)(`td`,{children:(0,H.jsx)(J,{tone:t.Enabled?`good`:`neutral`,children:t.Enabled?e(`common.enabled`):e(`common.disabled`)})}),(0,H.jsx)(`td`,{children:G(t.UpdatedAt)}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`div`,{className:`gift-table-actions`,children:[(0,H.jsxs)(`button`,{className:`btn compact-btn collectible-button`,type:`button`,onClick:()=>c(t),children:[(0,H.jsx)(Me,{size:13}),e(`collectibles.manage`)]}),(0,H.jsx)(`button`,{className:`btn compact-btn`,type:`button`,onClick:()=>qe(t),children:e(`gifts.replace`)}),(0,H.jsx)(Z,{compact:!0,tone:`neutral`,label:t.Enabled?e(`gifts.disable`):e(`gifts.enable`),path:`/api/actions/set-gift-enabled`,payload:()=>({gift_id:t.GiftID,enabled:!t.Enabled}),onDone:()=>void Fe()})]})})]},t.GiftID)),Be.length===0&&(0,H.jsx)(sn,{colSpan:9})]})]})}),a&&(0,Ln.createPortal)((0,H.jsx)(`div`,{className:`modal-backdrop`,role:`presentation`,children:(0,H.jsxs)(`section`,{className:`modal command-modal gift-import-modal`,role:`dialog`,"aria-modal":`true`,"aria-label":M===`0`?e(`gifts.importTitle`):e(`gifts.newRevision`,{id:M}),children:[(0,H.jsxs)(`div`,{className:`modal-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`eyebrow`,children:e(`gifts.importEyebrow`)}),(0,H.jsx)(`h2`,{children:M===`0`?e(`gifts.importTitle`):e(`gifts.newRevision`,{id:M})})]}),(0,H.jsx)(`button`,{className:`icon-btn`,type:`button`,onClick:()=>o(!1),disabled:Oe,"aria-label":e(`action.close`),children:(0,H.jsx)(Dt,{size:15})})]}),(0,H.jsxs)(`div`,{className:`command-body gift-import-modal-body`,children:[(0,H.jsxs)(`div`,{className:`command-steps`,children:[(0,H.jsxs)(`div`,{className:`command-step ${(d===`official`?b:l)?`done`:`active`}`,children:[(0,H.jsx)(`span`,{children:`1`}),(0,H.jsx)(`strong`,{children:e(`gifts.stepDetails`)})]}),(0,H.jsxs)(`div`,{className:`command-step ${Ee?`done`:(d===`official`?b:l)?`active`:``}`,children:[(0,H.jsx)(`span`,{children:`2`}),(0,H.jsx)(`strong`,{children:e(`gifts.stepValidate`)})]}),(0,H.jsxs)(`div`,{className:`command-step ${Ee?`active`:``}`,children:[(0,H.jsx)(`span`,{children:`3`}),(0,H.jsx)(`strong`,{children:e(`gifts.stepImport`)})]})]}),(0,H.jsxs)(`div`,{className:`gift-source-tabs`,children:[(0,H.jsx)(`button`,{className:`btn ${d===`official`?`primary`:``}`,type:`button`,onClick:()=>{f(`official`),De(null)},children:e(`gifts.officialSource`)}),(0,H.jsx)(`button`,{className:`btn ${d===`file`?`primary`:``}`,type:`button`,onClick:()=>{f(`file`),De(null)},children:e(`gifts.fileSource`)})]}),d===`official`?(0,H.jsxs)(`section`,{className:`official-gift-picker`,children:[(0,H.jsxs)(`div`,{className:`gift-import-note`,children:[(0,H.jsx)(`span`,{children:e(`gifts.officialHint`)}),(0,H.jsxs)(`div`,{className:`gift-format-chips`,children:[(0,H.jsx)(`span`,{children:p.length}),(0,H.jsx)(`span`,{children:`SHA-256`})]})]}),(0,H.jsxs)(`div`,{className:`official-gift-tools`,children:[(0,H.jsxs)(`label`,{className:`searchbox`,children:[(0,H.jsx)(it,{size:15}),(0,H.jsx)(`input`,{value:h,onChange:e=>_(e.target.value),placeholder:e(`gifts.officialSearch`)})]}),(0,H.jsx)(`span`,{children:e(`gifts.officialResults`,{shown:Re.length,total:p.length})})]}),(0,H.jsx)(`div`,{className:`official-gift-categories`,role:`group`,"aria-label":e(`gifts.officialCategoryLabel`),children:[`all`,`upgrade`,`craft`,`basic`].map(t=>(0,H.jsxs)(`button`,{className:v===t?`active`:``,type:`button`,"aria-pressed":v===t,onClick:()=>y(t),children:[e(`gifts.officialCategory.${t}`),(0,H.jsx)(`span`,{children:Le[t]})]},t))}),(0,H.jsxs)(`div`,{className:`official-gift-list`,role:`listbox`,"aria-label":e(`gifts.officialSelect`),children:[Re.map(t=>{let n=t.source_gift_id===b;return(0,H.jsxs)(`button`,{className:`official-gift-option ${n?`selected`:``}`,type:`button`,role:`option`,"aria-selected":n,onClick:()=>V(t),children:[(0,H.jsxs)(`span`,{className:`official-gift-option-head`,children:[(0,H.jsx)(`strong`,{children:t.title||e(`gifts.officialUnnamed`,{id:t.source_gift_id})}),(0,H.jsxs)(`span`,{className:`mono`,children:[`#`,t.source_gift_id]})]}),(0,H.jsxs)(`span`,{className:`official-gift-option-meta`,children:[(0,H.jsxs)(`span`,{children:[`⭐ `,t.stars]}),(0,H.jsx)(`span`,{children:e(`gifts.officialAttributes`,{count:mi(t)})})]}),(0,H.jsxs)(`span`,{className:`official-gift-capabilities`,children:[(0,H.jsx)(`span`,{className:t.can_upgrade?`yes`:`no`,children:t.can_upgrade?e(`gifts.canUpgrade`):e(`gifts.cannotUpgrade`)}),(0,H.jsx)(`span`,{className:t.can_craft?`craft`:`no`,children:t.can_craft?e(`gifts.canCraft`):e(`gifts.cannotCraft`)})]})]},t.source_gift_id)}),Re.length===0&&(0,H.jsx)(`div`,{className:`official-gift-empty`,children:e(`gifts.officialEmpty`)})]}),Ie&&(0,H.jsxs)(`div`,{className:`official-gift-selected`,children:[(0,H.jsx)(_i,{sourceGiftID:Ie.source_gift_id}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:Ie.title||e(`gifts.officialUnnamed`,{id:Ie.source_gift_id})}),(0,H.jsx)(`span`,{className:`mono`,children:Ie.source_gift_id}),(0,H.jsxs)(`small`,{children:[Ie.model_count,` `,e(`collectibles.models`),` · `,Ie.pattern_count,` `,e(`collectibles.patterns`),` · `,Ie.backdrop_count,` `,e(`collectibles.backdrops`)]}),(0,H.jsxs)(`span`,{className:`official-gift-capabilities`,children:[(0,H.jsx)(`span`,{className:Ie.can_upgrade?`yes`:`no`,children:Ie.can_upgrade?e(`gifts.canUpgrade`):e(`gifts.cannotUpgrade`)}),(0,H.jsx)(`span`,{className:Ie.can_craft?`craft`:`no`,children:Ie.can_craft?e(`gifts.canCraft`):e(`gifts.cannotCraft`)})]})]})]}),Ie?.can_upgrade&&(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`label`,{className:`gift-switch`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:S,onChange:e=>{C(e.target.checked),De(null)}}),(0,H.jsx)(`span`,{className:`gift-switch-track`,"aria-hidden":`true`,children:(0,H.jsx)(`span`,{})}),(0,H.jsx)(`span`,{children:e(`gifts.includeCollectible`)})]}),S&&(0,H.jsxs)(`div`,{className:`gift-fields-grid`,children:[(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:e(`collectibles.upgradeStars`)}),(0,H.jsx)(`input`,{type:`number`,min:`1`,value:w,onChange:e=>{T(e.target.value),De(null)}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:e(`collectibles.supply`)}),(0,H.jsx)(`input`,{type:`number`,min:`1`,value:E,onChange:e=>{D(e.target.value),De(null)}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:e(`collectibles.slug`)}),(0,H.jsx)(`input`,{value:A,maxLength:48,onChange:e=>{j(e.target.value.toLowerCase()),De(null)}})]})]})]})]}):(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`div`,{className:`gift-import-note`,children:[(0,H.jsx)(`span`,{children:e(`gifts.importHint`)}),(0,H.jsxs)(`div`,{className:`gift-format-chips`,"aria-label":e(`gifts.formats`),children:[(0,H.jsx)(`span`,{children:`TGS`}),(0,H.jsx)(`span`,{children:`Lottie JSON`})]})]}),(0,H.jsxs)(`label`,{className:`gift-file-picker ${l?`has-file`:``}`,children:[(0,H.jsx)(`input`,{type:`file`,accept:`.tgs,.json,.lottie,application/json,application/x-tgsticker`,onChange:e=>{u(e.target.files?.[0]??null),De(null)}}),(0,H.jsx)(`span`,{className:`gift-file-icon`,children:(0,H.jsx)(Te,{size:22})}),(0,H.jsxs)(`span`,{className:`gift-file-copy`,children:[(0,H.jsx)(`span`,{className:`gift-field-label`,children:e(`gifts.animation`)}),(0,H.jsx)(`strong`,{children:l?l.name:e(`gifts.filePrompt`)}),(0,H.jsx)(`small`,{children:l?hi(l.size):e(`gifts.fileHint`)})]}),(0,H.jsx)(`span`,{className:`gift-file-action`,children:e(l?`gifts.changeFile`:`gifts.chooseFile`)})]})]}),(0,H.jsxs)(`div`,{className:`gift-fields-grid`,children:[(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:e(`gifts.title`)}),(0,H.jsx)(`input`,{value:P,maxLength:128,placeholder:e(`gifts.titlePlaceholder`),onChange:e=>{F(e.target.value),De(null)}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:e(`gifts.stars`)}),(0,H.jsx)(`input`,{type:`number`,min:`1`,value:I,onChange:e=>{ee(e.target.value),De(null)}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:e(`gifts.convertStars`)}),(0,H.jsx)(`input`,{type:`number`,min:`0`,value:R,onChange:e=>{ne(e.target.value),De(null)}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:e(`gifts.sortOrder`)}),(0,H.jsx)(`input`,{type:`number`,value:re,onChange:e=>{ie(e.target.value),De(null)}})]})]}),d===`file`?(0,H.jsxs)(`section`,{className:`gift-lifecycle`,children:[(0,H.jsx)(`span`,{className:`gift-field-label`,children:e(`gifts.lifecycle.label`)}),(0,H.jsx)(`div`,{className:`gift-source-tabs`,role:`group`,"aria-label":e(`gifts.lifecycle.label`),children:[`regular`,`auction`,`drop`].map(t=>(0,H.jsx)(`button`,{className:`btn ${ae===t?`primary`:``}`,type:`button`,"aria-pressed":ae===t,onClick:()=>{oe(t),De(null)},children:e(`gifts.lifecycle.${t}`)},t))}),(0,H.jsx)(`div`,{className:`gift-import-note`,children:(0,H.jsx)(`span`,{children:e(ae===`auction`?`gifts.lifecycle.hintAuction`:ae===`drop`?`gifts.lifecycle.hintDrop`:`gifts.lifecycle.hintRegular`)})}),ae===`auction`&&(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`div`,{className:`gift-fields-grid`,children:[(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:e(`gifts.auction.slug`)}),(0,H.jsx)(`input`,{value:se,maxLength:48,placeholder:e(`gifts.auction.slugPlaceholder`),onChange:e=>{ce(e.target.value.toLowerCase()),De(null)}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:e(`gifts.auction.supply`)}),(0,H.jsx)(`input`,{type:`number`,min:`1`,value:le,onChange:e=>{ue(e.target.value),De(null)}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:e(`gifts.auction.perRound`)}),(0,H.jsx)(`input`,{type:`number`,min:`1`,value:z,onChange:e=>{de(e.target.value),De(null)}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:e(`gifts.auction.roundDuration`)}),(0,H.jsx)(`input`,{type:`number`,min:`1`,value:fe,onChange:e=>{pe(e.target.value),De(null)}})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:e(`gifts.auction.startAt`)}),(0,H.jsx)(`input`,{type:`datetime-local`,value:me,onChange:e=>{he(e.target.value),De(null)}})]})]}),ze>0&&(0,H.jsx)(`div`,{className:`gift-import-note`,children:(0,H.jsx)(`span`,{children:e(`gifts.auction.plan`,{rounds:ze,perRound:Number(z),minutes:Math.max(1,Math.round(Number(fe)/60))})})})]}),ae===`drop`&&(0,H.jsx)(`div`,{className:`gift-fields-grid`,children:(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:e(`gifts.drop.unlockAt`)}),(0,H.jsx)(`input`,{type:`datetime-local`,value:ge,onChange:e=>{_e(e.target.value),De(null)}})]})})]}):(0,H.jsxs)(`section`,{className:`gift-lifecycle`,children:[(0,H.jsx)(`span`,{className:`gift-field-label`,children:e(`gifts.lifecycle.label`)}),(0,H.jsx)(`div`,{className:`gift-import-note`,children:(0,H.jsx)(`span`,{children:e(`gifts.lifecycle.auctionFileOnly`)})}),(0,H.jsxs)(`label`,{className:`gift-switch`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:ve,onChange:e=>{ye(e.target.checked),De(null)}}),(0,H.jsx)(`span`,{className:`gift-switch-track`,"aria-hidden":`true`,children:(0,H.jsx)(`span`,{})}),(0,H.jsx)(`span`,{children:e(`gifts.drop.schedule`)})]}),ve&&(0,H.jsxs)(H.Fragment,{children:[(0,H.jsx)(`div`,{className:`gift-import-note`,children:(0,H.jsx)(`span`,{children:e(`gifts.lifecycle.hintDrop`)})}),(0,H.jsx)(`div`,{className:`gift-fields-grid`,children:(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:e(`gifts.drop.unlockAt`)}),(0,H.jsx)(`input`,{type:`datetime-local`,value:be,onChange:e=>{xe(e.target.value),De(null)}})]})})]})]}),(0,H.jsxs)(`label`,{className:`gift-reason-field`,children:[(0,H.jsx)(`span`,{children:e(`gifts.reason`)}),(0,H.jsx)(`input`,{value:Ce,placeholder:e(`gifts.reasonPlaceholder`),onChange:e=>we(e.target.value)})]}),(0,H.jsxs)(`label`,{className:`gift-switch`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:B,onChange:e=>{Se(e.target.checked),De(null)}}),(0,H.jsx)(`span`,{className:`gift-switch-track`,"aria-hidden":`true`,children:(0,H.jsx)(`span`,{})}),(0,H.jsx)(`span`,{children:e(`gifts.enableAfterImport`)})]}),Ne&&(0,H.jsx)(q,{children:Ne}),Ee&&(0,H.jsxs)(`div`,{className:`gift-validation`,children:[(0,H.jsxs)(`div`,{className:`gift-validation-head`,children:[(0,H.jsx)(L,{size:17}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:e(`gifts.validationReady`)}),(0,H.jsx)(`span`,{children:e(`gifts.validationHint`)})]})]}),(0,H.jsx)(`pre`,{children:JSON.stringify(Ee.details,null,2)})]})]}),(0,H.jsxs)(`div`,{className:`modal-actions`,children:[(0,H.jsx)(`button`,{className:`btn`,type:`button`,onClick:()=>o(!1),disabled:Oe,children:e(`common.close`)}),(0,H.jsxs)(`button`,{className:`btn`,type:`button`,onClick:We,disabled:Oe,children:[Oe?(0,H.jsx)(te,{className:`spin`,size:15}):(0,H.jsx)(lt,{size:15}),e(`gifts.validate`)]}),(0,H.jsxs)(`button`,{className:`btn primary`,type:`button`,onClick:Ge,disabled:Oe||!Ee,children:[(0,H.jsx)(St,{size:15}),e(`gifts.confirmImport`)]})]})]})}),document.body),s&&(0,H.jsx)(pi,{gift:s,onClose:()=>c(null),onPublished:()=>void Fe()})]})}function yi(e){let[t,n]=(0,g.useState)(()=>Math.floor(Date.now()/1e3));return(0,g.useEffect)(()=>{if(!e)return;let t=window.setInterval(()=>n(Math.floor(Date.now()/1e3)),1e3);return()=>window.clearInterval(t)},[e]),t}function bi(e,t){if(e<=0)return t(`auctions.now`);let n=Math.floor(e/86400),r=Math.floor(e%86400/3600),i=Math.floor(e%3600/60),a=e%60;return n>0?t(`auctions.durationDH`,{days:n,hours:r}):r>0?t(`auctions.durationHM`,{hours:r,minutes:i}):i>0?t(`auctions.durationMS`,{minutes:i,seconds:a}):t(`auctions.durationS`,{seconds:a})}function xi(e,t){if(!e.IsAuction)return e.LockedUntilDate>t?{key:`auctions.status.scheduled`,tone:`warn`}:{key:`auctions.status.released`,tone:`good`};if(!e.Materialized)return e.AuctionStartDate>t?{key:`auctions.status.pending`,tone:`warn`}:{key:`auctions.status.awaiting`,tone:`warn`};switch(e.Status){case`active`:return{key:`auctions.status.active`,tone:`good`};case`completed`:return{key:`auctions.status.completed`,tone:`neutral`};case`cancelled`:return{key:`auctions.status.cancelled`,tone:`danger`};default:return{key:`auctions.status.pending`,tone:`warn`}}}function Si(){let{t:e}=U(),[t,n]=(0,g.useState)([]),[r,i]=(0,g.useState)(!1),[a,o]=(0,g.useState)(``),[s,c]=(0,g.useState)(!1),[l,u]=(0,g.useState)(`auction`),d=yi(t.length>0);async function f(){i(!0),o(``);try{n((await k.auctions()).Auctions??[])}catch(e){o(O(e))}finally{i(!1)}}(0,g.useEffect)(()=>{f()},[]);let p=(0,g.useMemo)(()=>{let e=t.filter(e=>e.IsAuction),n=t.filter(e=>!e.IsAuction);return{auctions:e.length,live:e.filter(e=>e.Materialized&&e.Status===`active`).length,drops:n.length,pendingDrops:n.filter(e=>e.LockedUntilDate>d).length,committed:e.reduce((e,t)=>e+Number(t.BidTotal||0),0)}},[t,d]);function m(e){u(e),c(!0)}return(0,H.jsxs)(nn,{title:e(`auctions.title`),eyebrow:e(`auctions.eyebrow`),actions:(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`button`,{className:`btn`,type:`button`,disabled:r,onClick:()=>void f(),children:[(0,H.jsx)(nt,{size:15}),e(`common.refresh`)]}),(0,H.jsxs)(`button`,{className:`btn`,type:`button`,onClick:()=>m(`drop`),children:[(0,H.jsx)(vt,{size:15}),e(`auctions.newDrop`)]}),(0,H.jsxs)(`button`,{className:`btn primary`,type:`button`,onClick:()=>m(`auction`),children:[(0,H.jsx)(je,{size:15}),e(`auctions.newAuction`)]})]}),children:[a&&(0,H.jsx)(q,{children:a}),(0,H.jsxs)(`div`,{className:`metric-row`,children:[(0,H.jsx)(Y,{label:e(`auctions.metricAuctions`),value:String(p.auctions)}),(0,H.jsx)(Y,{label:e(`auctions.metricLive`),value:String(p.live),tone:p.live>0?`good`:`neutral`}),(0,H.jsx)(Y,{label:e(`auctions.metricDrops`),value:String(p.drops)}),(0,H.jsx)(Y,{label:e(`auctions.metricPendingDrops`),value:String(p.pendingDrops),tone:p.pendingDrops>0?`warn`:`neutral`}),(0,H.jsx)(Y,{label:e(`auctions.metricCommitted`),value:p.committed.toLocaleString(),mono:!0})]}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:e(`common.preview`)}),(0,H.jsx)(`th`,{children:e(`auctions.colGift`)}),(0,H.jsx)(`th`,{children:e(`auctions.colKind`)}),(0,H.jsx)(`th`,{children:e(`common.status`)}),(0,H.jsx)(`th`,{children:e(`auctions.colRounds`)}),(0,H.jsx)(`th`,{children:e(`auctions.colSupply`)}),(0,H.jsx)(`th`,{children:e(`auctions.colMinBid`)}),(0,H.jsx)(`th`,{children:e(`auctions.colBids`)}),(0,H.jsx)(`th`,{children:e(`auctions.colNext`)})]})}),(0,H.jsxs)(`tbody`,{children:[t.map(e=>(0,H.jsx)(Ci,{row:e,now:d},e.GiftID)),t.length===0&&!r&&(0,H.jsx)(sn,{colSpan:9})]})]})}),(0,H.jsx)(`div`,{className:`gift-import-note`,children:(0,H.jsx)(`span`,{children:e(`auctions.lazyNote`)})}),s&&(0,H.jsx)(wi,{mode:l,onClose:()=>c(!1),onCreated:()=>void f()})]})}function Ci({row:e,now:t}){let{t:n}=U(),r=xi(e,t),i=e.GiftsPerRound>0?Math.ceil(e.AvailabilityTotal/e.GiftsPerRound):0,a=e.Materialized?e.TotalRounds:i,o=e.Materialized?e.RoundDuration:e.AuctionRoundDuration,s=e.IsAuction?e.Materialized&&e.Status===`active`?e.NextRoundAt:e.Materialized?e.StartDate:e.AuctionStartDate:e.LockedUntilDate,c=e.Materialized&&(e.Status===`completed`||e.Status===`cancelled`);return(0,H.jsxs)(`tr`,{className:e.Enabled?``:`gift-row-disabled`,children:[(0,H.jsx)(`td`,{children:(0,H.jsx)(gi,{giftID:e.GiftID,revision:0,compact:!0})}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`div`,{className:`auction-cell`,children:[(0,H.jsx)(`strong`,{children:e.Title||n(`auctions.untitled`)}),(0,H.jsxs)(`small`,{className:`mono`,children:[`#`,e.GiftID,e.Slug?` · ${e.Slug}`:``]})]})}),(0,H.jsx)(`td`,{children:e.IsAuction?(0,H.jsx)(J,{tone:`neutral`,children:n(`auctions.kindAuction`)}):(0,H.jsx)(J,{tone:`neutral`,children:n(`auctions.kindDrop`)})}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`div`,{className:`auction-cell`,children:[(0,H.jsx)(J,{tone:r.tone,children:n(r.key)}),!e.Enabled&&(0,H.jsx)(`small`,{children:n(`common.disabled`)})]})}),(0,H.jsx)(`td`,{children:e.IsAuction&&a>0?(0,H.jsxs)(`div`,{className:`auction-cell`,children:[(0,H.jsx)(`span`,{children:n(`auctions.roundOf`,{current:e.Materialized?e.CurrentRound:0,total:a})}),o>0&&(0,H.jsx)(`small`,{children:n(`auctions.perRoundShort`,{minutes:Math.max(1,Math.round(o/60))})})]}):(0,H.jsx)(`small`,{children:`—`})}),(0,H.jsx)(`td`,{children:e.IsAuction?(0,H.jsxs)(`div`,{className:`auction-cell`,children:[(0,H.jsxs)(`span`,{className:`mono`,children:[e.Materialized?e.GiftsLeft:e.AvailabilityTotal,` / `,e.AvailabilityTotal]}),(0,H.jsx)(`small`,{children:n(`auctions.awarded`,{count:e.LastGiftNum})})]}):(0,H.jsx)(`small`,{children:n(`auctions.unlimited`)})}),(0,H.jsx)(`td`,{className:`mono`,children:e.IsAuction?Number(e.Materialized?e.MinBidAmount:e.Stars).toLocaleString():Number(e.Stars).toLocaleString()}),(0,H.jsx)(`td`,{children:e.IsAuction?(0,H.jsxs)(`div`,{className:`auction-cell`,children:[(0,H.jsx)(`span`,{children:n(`auctions.bidCount`,{count:e.ActiveBids})}),e.ActiveBids>0&&(0,H.jsx)(`small`,{className:`mono`,children:n(`auctions.topBid`,{amount:Number(e.TopBid).toLocaleString()})})]}):(0,H.jsx)(`small`,{children:`—`})}),(0,H.jsx)(`td`,{children:c||s<=0?(0,H.jsx)(`small`,{children:`—`}):(0,H.jsxs)(`div`,{className:`auction-cell`,children:[(0,H.jsx)(`strong`,{children:bi(s-t,n)}),(0,H.jsx)(`small`,{children:Bt(s)})]})})]})}function wi({mode:e,onClose:t,onCreated:n}){let{t:r}=U(),[i,a]=(0,g.useState)(null),[o,s]=(0,g.useState)(``),[c,l]=(0,g.useState)(`50`),[u,d]=(0,g.useState)(`50`),[f,p]=(0,g.useState)(`0`),[m,h]=(0,g.useState)(``),[_,v]=(0,g.useState)(`10`),[y,b]=(0,g.useState)(`2`),[x,S]=(0,g.useState)(`3600`),[C,w]=(0,g.useState)(()=>tn(600)),[T,E]=(0,g.useState)(()=>tn(3600)),[D,A]=(0,g.useState)(``),[j,M]=(0,g.useState)(!0),[N,P]=(0,g.useState)(null),[F,I]=(0,g.useState)(!1),[ee,R]=(0,g.useState)(``);function ne(e){return t=>{e(t),P(null)}}let re=(0,g.useMemo)(()=>{let e=Number(_),t=Number(y);return e>0&&t>0?Math.ceil(e/t):0},[_,y]);function ie(){let t=Math.floor(Date.now()/1e3);if(e===`auction`){let e=Number(_),n=Number(y),i=Number(x),a=en(C);if(!m.trim())throw Error(r(`gifts.lifecycle.slugRequired`));if(!(e>0))throw Error(r(`gifts.lifecycle.supplyRequired`));if(!(n>0))throw Error(r(`gifts.lifecycle.perRoundRequired`));if(n>e)throw Error(r(`gifts.lifecycle.perRoundTooLarge`));if(!(i>0))throw Error(r(`gifts.lifecycle.roundDurationRequired`));if(a!==0&&a{a(e.target.files?.[0]??null),P(null)}}),(0,H.jsx)(`span`,{className:`gift-file-copy`,children:(0,H.jsx)(`strong`,{children:i?.name??r(`gifts.chooseFile`)})}),(0,H.jsx)(`span`,{className:`gift-file-action`,children:r(i?`gifts.changeFile`:`common.choose`)})]}),(0,H.jsxs)(`div`,{className:`gift-fields-grid`,children:[(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`gifts.title`)}),(0,H.jsx)(`input`,{value:o,maxLength:128,placeholder:r(`gifts.titlePlaceholder`),onChange:e=>ne(s)(e.target.value)})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(e===`auction`?`auctions.minBid`:`gifts.stars`)}),(0,H.jsx)(`input`,{type:`number`,min:`1`,value:c,onChange:e=>ne(l)(e.target.value)})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`gifts.convertStars`)}),(0,H.jsx)(`input`,{type:`number`,min:`0`,value:u,onChange:e=>ne(d)(e.target.value)})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`gifts.sortOrder`)}),(0,H.jsx)(`input`,{type:`number`,value:f,onChange:e=>ne(p)(e.target.value)})]})]}),e===`auction`?(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`div`,{className:`gift-fields-grid`,children:[(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`gifts.auction.slug`)}),(0,H.jsx)(`input`,{value:m,maxLength:48,placeholder:r(`gifts.auction.slugPlaceholder`),onChange:e=>ne(h)(e.target.value.toLowerCase())})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`gifts.auction.supply`)}),(0,H.jsx)(`input`,{type:`number`,min:`1`,value:_,onChange:e=>ne(v)(e.target.value)})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`gifts.auction.perRound`)}),(0,H.jsx)(`input`,{type:`number`,min:`1`,value:y,onChange:e=>ne(b)(e.target.value)})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`gifts.auction.roundDuration`)}),(0,H.jsx)(`input`,{type:`number`,min:`1`,value:x,onChange:e=>ne(S)(e.target.value)})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`gifts.auction.startAt`)}),(0,H.jsx)(`input`,{type:`datetime-local`,value:C,onChange:e=>ne(w)(e.target.value)})]})]}),re>0&&(0,H.jsx)(`div`,{className:`gift-import-note`,children:(0,H.jsx)(`span`,{children:r(`gifts.auction.plan`,{rounds:re,perRound:Number(y),minutes:Math.max(1,Math.round(Number(x)/60))})})})]}):(0,H.jsx)(`div`,{className:`gift-fields-grid`,children:(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`gifts.drop.unlockAt`)}),(0,H.jsx)(`input`,{type:`datetime-local`,value:T,onChange:e=>ne(E)(e.target.value)})]})}),(0,H.jsxs)(`label`,{className:`gift-reason-field`,children:[(0,H.jsx)(`span`,{children:r(`gifts.reason`)}),(0,H.jsx)(`input`,{value:D,placeholder:r(`gifts.reasonPlaceholder`),onChange:e=>A(e.target.value)})]}),(0,H.jsxs)(`label`,{className:`gift-switch`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:j,onChange:e=>ne(M)(e.target.checked)}),(0,H.jsx)(`span`,{className:`gift-switch-track`,"aria-hidden":`true`,children:(0,H.jsx)(`span`,{})}),(0,H.jsx)(`span`,{children:r(`auctions.enableHint`)})]}),ee&&(0,H.jsx)(q,{children:ee}),N&&(0,H.jsxs)(`div`,{className:`gift-validation`,children:[(0,H.jsxs)(`div`,{className:`gift-validation-head`,children:[(0,H.jsx)(L,{size:17}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:r(`gifts.validationReady`)}),(0,H.jsx)(`span`,{children:r(`gifts.validationHint`)})]})]}),(0,H.jsx)(`pre`,{children:JSON.stringify(N.details,null,2)})]})]}),(0,H.jsxs)(`div`,{className:`modal-actions`,children:[(0,H.jsx)(`button`,{className:`btn`,type:`button`,onClick:t,disabled:F,children:r(`common.close`)}),(0,H.jsxs)(`button`,{className:`btn`,type:`button`,onClick:()=>void oe(),disabled:F,children:[F?(0,H.jsx)(te,{className:`spin`,size:15}):(0,H.jsx)(lt,{size:15}),r(`gifts.validate`)]}),(0,H.jsxs)(`button`,{className:`btn primary`,type:`button`,onClick:()=>void se(),disabled:F||!N,children:[(0,H.jsx)(St,{size:15}),r(`gifts.confirmImport`)]})]})]})}),document.body)}var Ti=`777000`;function Ei(e){let t=e.rarity_permille>0?` · ${(e.rarity_permille/10).toFixed(1)}%`:``;return`${e.name||`#${e.id}`}${t}`}function Di({gift:e,onDone:t}){let{t:n}=U(),[r,i]=(0,g.useState)(`user`),[a,o]=(0,g.useState)(null),[s,c]=(0,g.useState)(null),[l,u]=(0,g.useState)(``),[d,f]=(0,g.useState)(!1),[p,m]=(0,g.useState)(!1),[h,_]=(0,g.useState)(null),[v,y]=(0,g.useState)(``),[b,x]=(0,g.useState)(`0`),[S,C]=(0,g.useState)(`0`),[w,T]=(0,g.useState)(`0`),[E,D]=(0,g.useState)(``),[A,j]=(0,g.useState)(null),[M,N]=(0,g.useState)(``),[P,F]=(0,g.useState)(!1),ee=r===`user`?a?.ID??0:s?.ID??0,R=r===`user`&&p;(0,g.useEffect)(()=>{m(!1),_(null),y(``),x(`0`),C(`0`),T(`0`),j(null),N(``)},[e.GiftID]),(0,g.useEffect)(()=>{if(!R||h)return;let t=!1;return y(``),k.giftCollectibles(e.GiftID).then(e=>{t||_(e)}).catch(e=>{t||y(O(e))}),()=>{t=!0}},[R,h,e.GiftID]);function ne(t){return{gift_id:e.GiftID,sender_user_id:Number(Ti),user_id:r===`user`?ee:0,channel_id:r===`channel`?ee:0,hide_name:d,message:l.trim(),upgrade:R,model_attribute_id:R?b:`0`,pattern_attribute_id:R?S:`0`,backdrop_attribute_id:R?w:`0`,reason:E.trim(),confirm:t}}let re=(0,g.useMemo)(()=>ne(!1),[e.GiftID,r,ee,l,d,p,b,S,w,E]),ie=A?.dry_run&&!A.error;async function ae(e){if(ee<=0){N(n(`giveGift.recipientRequired`));return}if(!E.trim()){N(n(`action.reasonRequired`));return}F(!0),N(``);try{let n=await k.action(`/api/actions/give-gift`,ne(e));j(n),e&&!n.error&&t?.()}catch(e){N(O(e))}finally{F(!1)}}return(0,H.jsxs)(`div`,{className:`give-gift-form`,children:[(0,H.jsxs)(`div`,{className:`give-gift-summary`,children:[(0,H.jsx)(Ne,{size:16}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`strong`,{children:e.Title||`Gift #${e.GiftID}`}),(0,H.jsxs)(`span`,{className:`mono`,children:[`#`,e.GiftID,` · ⭐ `,e.Stars]})]})]}),(0,H.jsxs)(`div`,{className:`give-gift-tabs`,role:`group`,"aria-label":n(`giveGift.recipientKind`),children:[(0,H.jsxs)(`button`,{type:`button`,className:`btn ${r===`user`?`primary`:``}`,onClick:()=>{i(`user`),j(null)},children:[(0,H.jsx)(wt,{size:15}),` `,n(`giveGift.recipientUser`)]}),(0,H.jsxs)(`button`,{type:`button`,className:`btn ${r===`channel`?`primary`:``}`,onClick:()=>{i(`channel`),m(!1),j(null)},children:[(0,H.jsx)(Tt,{size:15}),` `,n(`giveGift.recipientChannel`)]})]}),r===`user`?(0,H.jsx)(fr,{label:n(`giveGift.pickUser`),value:a,onChange:e=>{o(e),j(null)}}):(0,H.jsx)(mr,{label:n(`giveGift.pickChannel`),value:s,onChange:e=>{c(e),j(null)}}),(0,H.jsxs)(`label`,{className:`form-field`,children:[(0,H.jsx)(`span`,{children:n(`giveGift.sender`)}),(0,H.jsx)(`input`,{value:Ti,disabled:!0,readOnly:!0}),(0,H.jsx)(`small`,{className:`field-hint`,children:n(`giveGift.senderHint`)})]}),(0,H.jsxs)(`label`,{className:`form-field`,children:[(0,H.jsx)(`span`,{children:n(`giveGift.message`)}),(0,H.jsx)(`textarea`,{value:l,rows:2,maxLength:128,onChange:e=>{u(e.target.value),j(null)},placeholder:n(`giveGift.messagePlaceholder`)})]}),(0,H.jsxs)(`label`,{className:`gift-switch`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:d,onChange:e=>{f(e.target.checked),j(null)}}),(0,H.jsx)(`span`,{className:`gift-switch-track`,"aria-hidden":`true`,children:(0,H.jsx)(`span`,{})}),(0,H.jsx)(`span`,{children:n(`giveGift.hideName`)})]}),r===`user`&&(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`label`,{className:`gift-switch`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:p,onChange:e=>{m(e.target.checked),e.target.checked||(x(`0`),C(`0`),T(`0`)),j(null)}}),(0,H.jsx)(`span`,{className:`gift-switch-track`,"aria-hidden":`true`,children:(0,H.jsx)(`span`,{})}),(0,H.jsx)(`span`,{children:n(`giveGift.upgrade`)})]}),p&&(0,H.jsx)(`p`,{className:`give-gift-upgrade-note`,children:n(`giveGift.upgradeNote`)}),p&&v&&(0,H.jsx)(q,{children:v}),p&&h&&(0,H.jsxs)(`div`,{className:`gift-fields-grid give-gift-attrs`,children:[(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:n(`giveGift.model`)}),(0,H.jsxs)(`select`,{value:b,onChange:e=>{x(e.target.value),j(null)},children:[(0,H.jsx)(`option`,{value:`0`,children:n(`giveGift.random`)}),(h.models??[]).map(e=>(0,H.jsx)(`option`,{value:e.id,children:Ei(e)},e.id))]})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:n(`giveGift.pattern`)}),(0,H.jsxs)(`select`,{value:S,onChange:e=>{C(e.target.value),j(null)},children:[(0,H.jsx)(`option`,{value:`0`,children:n(`giveGift.random`)}),(h.patterns??[]).map(e=>(0,H.jsx)(`option`,{value:e.id,children:Ei(e)},e.id))]})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:n(`giveGift.backdrop`)}),(0,H.jsxs)(`select`,{value:w,onChange:e=>{T(e.target.value),j(null)},children:[(0,H.jsx)(`option`,{value:`0`,children:n(`giveGift.random`)}),(h.backdrops??[]).map(e=>(0,H.jsx)(`option`,{value:e.id,children:Ei(e)},e.id))]})]})]})]}),(0,H.jsxs)(`label`,{className:`form-field`,children:[(0,H.jsx)(`span`,{children:n(`action.reason`)}),(0,H.jsx)(`textarea`,{value:E,rows:2,onChange:e=>D(e.target.value),placeholder:n(`action.reasonPlaceholder`)})]}),(0,H.jsxs)(`div`,{className:`command-preview`,children:[(0,H.jsx)(`div`,{className:`preview-head`,children:n(`action.requestPreview`)}),(0,H.jsx)(ln,{value:JSON.stringify(re,null,2)})]}),M&&(0,H.jsx)(q,{children:M}),A&&(0,H.jsxs)(`div`,{className:`result-box`,children:[(0,H.jsxs)(`div`,{className:`result-title`,children:[A.error?(0,H.jsx)(I,{size:16}):(0,H.jsx)(L,{size:16}),(0,H.jsx)(`strong`,{children:A.message||A.error||n(`action.result`)})]}),(0,H.jsxs)(`div`,{className:`result-line`,children:[(0,H.jsx)(`span`,{children:n(`action.commandID`)}),(0,H.jsx)(`strong`,{children:A.command_id})]}),(0,H.jsxs)(`div`,{className:`result-line`,children:[(0,H.jsx)(`span`,{children:n(`action.status`)}),(0,H.jsx)(`strong`,{children:A.status})]}),(0,H.jsxs)(`div`,{className:`result-line`,children:[(0,H.jsx)(`span`,{children:n(`action.dryRun`)}),(0,H.jsx)(`strong`,{children:A.dry_run?n(`common.yes`):n(`common.no`)})]}),A.details&&(0,H.jsx)(ln,{value:JSON.stringify(A.details,null,2)})]}),(0,H.jsxs)(`div`,{className:`give-gift-form-actions`,children:[(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>ae(!1),disabled:P,children:[P?(0,H.jsx)(te,{size:15,className:`spin`}):(0,H.jsx)(Ze,{size:15}),n(A?`action.runAgain`:`action.runDry`)]}),(0,H.jsxs)(`button`,{className:`btn primary icon-text`,type:`button`,onClick:()=>ae(!0),disabled:P||!ie,children:[(0,H.jsx)(Ne,{size:15}),n(`giveGift.confirm`)]})]})]})}function Oi(){let{t:e}=U(),[t,n]=(0,g.useState)([]),[r,i]=(0,g.useState)(``),[a,o]=(0,g.useState)(null),[s,c]=(0,g.useState)(``),[l,u]=(0,g.useState)(!1);async function d(){u(!0),c(``);try{let e=(await k.gifts()).Gifts??[];n(e),o(t=>t??e[0]??null)}catch(e){c(O(e))}finally{u(!1)}}(0,g.useEffect)(()=>{d()},[]);let f=(0,g.useMemo)(()=>{let e=r.trim().toLowerCase();return e?t.filter(t=>String(t.GiftID).includes(e)||t.Title.toLowerCase().includes(e)):t},[t,r]);return(0,H.jsxs)(nn,{title:e(`giveGifts.pageTitle`),eyebrow:e(`giveGifts.eyebrow`),actions:(0,H.jsxs)(`button`,{className:`btn`,type:`button`,onClick:()=>d(),disabled:l,children:[(0,H.jsx)(nt,{size:15}),` `,e(`common.refresh`)]}),children:[s&&(0,H.jsx)(q,{children:s}),(0,H.jsx)(`p`,{className:`give-gift-upgrade-note`,children:e(`giveGifts.hint`)}),(0,H.jsxs)(`div`,{className:`give-gift-layout`,children:[(0,H.jsxs)(`section`,{className:`give-gift-picker`,children:[(0,H.jsxs)(`div`,{className:`give-gift-picker-head`,children:[(0,H.jsxs)(`label`,{className:`searchbox`,children:[(0,H.jsx)(it,{size:15}),(0,H.jsx)(`input`,{value:r,onChange:e=>i(e.target.value),placeholder:e(`giveGifts.searchPlaceholder`)})]}),(0,H.jsx)(`span`,{className:`gift-list-summary`,children:e(`gifts.listSummary`,{shown:f.length,total:t.length})})]}),(0,H.jsxs)(`div`,{className:`give-gift-picker-list`,role:`listbox`,"aria-label":e(`giveGifts.pickGift`),children:[f.map(t=>{let n=a?.GiftID===t.GiftID;return(0,H.jsxs)(`button`,{type:`button`,role:`option`,"aria-selected":n,className:`give-gift-option ${n?`selected`:``} ${t.Enabled?``:`gift-row-disabled`}`,onClick:()=>o(t),children:[(0,H.jsx)(Ir,{className:`give-gift-thumb`,cacheKey:`${t.GiftID}:${t.Revision}`,loader:()=>k.giftAnimation(t.GiftID)}),(0,H.jsxs)(`span`,{className:`give-gift-option-info`,children:[(0,H.jsx)(`strong`,{children:t.Title||`Gift #${t.GiftID}`}),(0,H.jsxs)(`span`,{className:`mono`,children:[`#`,t.GiftID]})]}),(0,H.jsx)(`span`,{className:`give-gift-option-price`,children:t.Enabled?(0,H.jsxs)(J,{children:[`⭐ `,t.Stars]}):(0,H.jsx)(J,{tone:`neutral`,children:e(`common.disabled`)})})]},t.GiftID)}),f.length===0&&!l&&(0,H.jsx)(`div`,{className:`official-gift-empty`,children:e(`common.noResults`)})]})]}),(0,H.jsx)(`section`,{className:`give-gift-panel`,children:a?(0,H.jsx)(Di,{gift:a,onDone:()=>void d()},a.GiftID):(0,H.jsxs)(`div`,{className:`give-gift-empty-panel`,children:[(0,H.jsx)(Ne,{size:26}),(0,H.jsx)(`p`,{children:e(`giveGifts.selectPrompt`)})]})})]})]})}var ki=`open,in_review,action_pending,action_failed,appeal_review`,Ai=[{value:ki,labelKey:`moderation.statusFilter.active`},{value:`open,in_review,action_pending,action_failed,resolved,dismissed,appeal_review`,labelKey:`moderation.statusFilter.all`},{value:`open`,labelKey:`moderation.status.open`},{value:`in_review`,labelKey:`moderation.status.in_review`},{value:`action_pending`,labelKey:`moderation.status.action_pending`},{value:`action_failed`,labelKey:`moderation.status.action_failed`},{value:`appeal_review`,labelKey:`moderation.status.appeal_review`},{value:`resolved`,labelKey:`moderation.status.resolved`},{value:`dismissed`,labelKey:`moderation.status.dismissed`}];function ji({navigate:e}){let{t}=U(),[n,r]=(0,g.useState)(ki),[i,a]=(0,g.useState)(``),[o,s]=(0,g.useState)([]),[c,l]=(0,g.useState)(!1),[u,d]=(0,g.useState)(``);async function f(){l(!0),d(``);try{let e=new URLSearchParams({statuses:n,limit:`100`});i.trim()&&e.set(`assigned_to`,i.trim()),s((await k.moderationCases(e)).cases)}catch(e){d(O(e))}finally{l(!1)}}(0,g.useEffect)(()=>{f()},[]);let p=o.filter(e=>e.Status===`action_pending`||e.Status===`action_failed`).length,m=o.filter(e=>e.Severity===4).length;return(0,H.jsxs)(nn,{title:t(`route.moderation`),eyebrow:t(`moderation.casesEyebrow`),actions:(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:f,disabled:c,children:[(0,H.jsx)(nt,{size:15,className:c?`spin`:``}),` `,t(`common.refresh`)]}),children:[u&&(0,H.jsx)(q,{children:u}),(0,H.jsxs)(`div`,{className:`metric-row`,children:[(0,H.jsx)(Y,{label:t(`moderation.currentQueue`),value:String(o.length)}),(0,H.jsx)(Y,{label:t(`moderation.criticalCases`),value:String(m),tone:m?`danger`:`neutral`}),(0,H.jsx)(Y,{label:t(`moderation.pendingOrFailed`),value:String(p),tone:p?`warn`:`good`})]}),(0,H.jsx)(rn,{children:(0,H.jsxs)(`form`,{className:`toolbar`,onSubmit:e=>{e.preventDefault(),f()},children:[(0,H.jsxs)(`label`,{className:`field-inline`,children:[(0,H.jsx)(`span`,{children:t(`common.status`)}),(0,H.jsx)(`select`,{"aria-label":t(`moderation.statusFilter`),value:n,onChange:e=>r(e.target.value),children:Ai.map(e=>(0,H.jsx)(`option`,{value:e.value,children:t(e.labelKey)},e.value))})]}),(0,H.jsxs)(`label`,{className:`field-inline`,children:[(0,H.jsx)(`span`,{children:t(`moderation.assignee`)}),(0,H.jsx)(`input`,{value:i,onChange:e=>a(e.target.value),placeholder:t(`moderation.allAssignees`)})]}),(0,H.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,disabled:c,children:[(0,H.jsx)(ct,{size:15}),` `,t(`common.search`)]})]})}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:t(`moderation.case`)}),(0,H.jsx)(`th`,{children:t(`moderation.target`)}),(0,H.jsx)(`th`,{children:t(`common.status`)}),(0,H.jsx)(`th`,{children:t(`moderation.severity`)}),(0,H.jsx)(`th`,{children:t(`moderation.reportsAndReporters`)}),(0,H.jsx)(`th`,{children:t(`moderation.assignee`)}),(0,H.jsx)(`th`,{children:t(`moderation.latestReport`)}),(0,H.jsx)(`th`,{})]})}),(0,H.jsxs)(`tbody`,{children:[o.map(n=>(0,H.jsxs)(`tr`,{children:[(0,H.jsxs)(`td`,{className:`mono`,children:[`#`,n.ID]}),(0,H.jsx)(`td`,{className:`mono`,children:Fi(t,n.Target.Type,n.Target.ID)}),(0,H.jsx)(`td`,{children:(0,H.jsx)(Mi,{status:n.Status})}),(0,H.jsx)(`td`,{children:(0,H.jsx)(Ni,{value:n.Severity})}),(0,H.jsxs)(`td`,{children:[n.ReportCount,` / `,n.DistinctReporterCount]}),(0,H.jsx)(`td`,{children:n.AssignedTo||`-`}),(0,H.jsx)(`td`,{children:G(n.LastReportAt)}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`button`,{className:`row-link`,onClick:()=>e(`/moderation/${n.ID}`),children:[t(`moderation.review`),` `,(0,H.jsx)(ve,{size:14})]})})]},n.ID)),o.length===0&&(0,H.jsx)(sn,{colSpan:8})]})]})})]})}function Mi({status:e}){let{t}=U();return(0,H.jsx)(J,{tone:e===`resolved`||e===`dismissed`?`good`:e===`action_failed`?`danger`:e===`action_pending`?`warn`:`neutral`,children:Pi(t,`status`,e)})}function Ni({value:e}){let{t}=U(),n=[``,`low`,`medium`,`high`,`critical`][e];return(0,H.jsx)(J,{tone:e>=4?`danger`:e>=3?`warn`:`neutral`,children:n?t(`moderation.severity.${n}`):e})}function Pi(e,t,n){let r=`moderation.${t}.${n}`,i=e(r);return i===r?n:i}function Fi(e,t,n){return`${Pi(e,`targetType`,t)} #${n}`}function Ii({id:e,navigate:t}){let{t:n}=U(),[r,i]=(0,g.useState)(null),[a,o]=(0,g.useState)(null),[s,c]=(0,g.useState)(``),[l,u]=(0,g.useState)(`no_violation`),[d,f]=(0,g.useState)(``),[p,m]=(0,g.useState)(``),[h,_]=(0,g.useState)(!0),[v,y]=(0,g.useState)(!1),[b,x]=(0,g.useState)(``);function S(e){o(e),e&&(f(e.Items.filter(e=>e.Kind===`message`).map(e=>Number(e.ItemID)).filter(e=>Number.isSafeInteger(e)&&e>0).join(`, `)),m(String(e.ReporterUserID)))}async function C(){x(``);try{let t=await k.moderationCase(e);i(t);let n=t.ReportIDs[0];S(n?await k.moderationReport(n):null)}catch(e){x(O(e))}}(0,g.useEffect)(()=>{C()},[e]);let w=(0,g.useMemo)(()=>Li(l,r?.Case.Target.Type,Ri(d),Number(p),h),[l,r?.Case.Target.Type,d,p,h]),T=(0,g.useMemo)(()=>r?zi(r,n):{actions:[],label:n(`common.none`),blocked:!1},[r,n]);async function E(){if(r){y(!0),x(``);try{await k.claimModerationCase(e,r.Case.Version),await C()}catch(e){x(O(e))}finally{y(!1)}}}async function D(){if(!r||!s.trim()){x(n(`moderation.reasonRequired`));return}if(l===`delete_messages`&&w.length===0){x(r.Case.Target.Type===`user`?n(`moderation.privateDeleteValidation`):n(`moderation.channelDeleteValidation`));return}if(window.confirm(n(`moderation.confirmDecision`,{decision:Bi(n,l)}))){y(!0),x(``);try{i((await k.decideModerationCase(e,{expected_version:r.Case.Version,reason:s.trim(),kind:l===`no_violation`?`no_violation`:`violation`,actions:w})).case),c(``)}catch(e){x(O(e))}finally{y(!1)}}}async function A(t,a){if(!r||!s.trim()){x(n(`moderation.appealReasonRequired`));return}if(window.confirm(n(a?`moderation.confirmGrantAppeal`:`moderation.confirmDenyAppeal`))){y(!0);try{i((await k.reviewModerationAppeal(e,t,{expected_version:r.Case.Version,reason:s.trim(),granted:a,actions:a?T.actions:[]})).case),c(``)}catch(e){x(O(e))}finally{y(!1)}}}if(b&&!r)return(0,H.jsx)(q,{children:b});if(!r)return(0,H.jsx)(cn,{label:n(`moderation.loadingCase`)});let j=r.Case,M=j.Status===`open`||j.Status===`in_review`||j.Status===`appeal_review`,N=(j.Status===`in_review`||j.Status===`action_failed`)&&!!j.AssignedTo,P=N&&(j.Status!==`action_failed`||l!==`no_violation`),F=r.Appeals.find(e=>e.Status===`pending`);return(0,H.jsxs)(nn,{title:n(`moderation.caseDetailTitle`,{id:j.ID}),eyebrow:n(`moderation.caseDetailEyebrow`),actions:(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`button`,{className:`btn icon-text`,onClick:()=>t(`/moderation`),children:[(0,H.jsx)(ce,{size:15}),` `,n(`moderation.backToQueue`)]}),(0,H.jsxs)(`button`,{className:`btn icon-text`,onClick:C,children:[(0,H.jsx)(nt,{size:15}),` `,n(`common.refresh`)]})]}),children:[b&&(0,H.jsx)(q,{children:b}),(0,H.jsx)(an,{main:(0,H.jsxs)(`div`,{className:`stacked-sections`,children:[(0,H.jsxs)(`section`,{className:`entity-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`entity-title`,children:Fi(n,j.Target.Type,j.Target.ID)}),(0,H.jsx)(`div`,{className:`entity-subtitle`,children:n(`moderation.versionAndUpdated`,{version:j.Version,time:G(j.UpdatedAt)})})]}),(0,H.jsxs)(`div`,{className:`entity-badges`,children:[(0,H.jsx)(Mi,{status:j.Status}),(0,H.jsx)(Ni,{value:j.Severity})]})]}),(0,H.jsxs)(`div`,{className:`summary-grid`,children:[(0,H.jsx)(X,{label:n(`moderation.target`),value:Fi(n,j.Target.Type,j.Target.ID),mono:!0}),(0,H.jsx)(X,{label:n(`moderation.reportCount`),value:n(`moderation.reportCountValue`,{reports:j.ReportCount,reporters:j.DistinctReporterCount})}),(0,H.jsx)(X,{label:n(`moderation.assignee`),value:j.AssignedTo||`-`}),(0,H.jsx)(X,{label:n(`moderation.firstAndLatestReport`),value:`${G(j.FirstReportAt)} / ${G(j.LastReportAt)}`})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:n(`moderation.evidence`),text:n(`moderation.evidenceHint`)}),(0,H.jsx)(`div`,{className:`toolbar`,children:r.ReportIDs.map(e=>(0,H.jsxs)(`button`,{className:`btn`,onClick:async()=>S(await k.moderationReport(e)),children:[`#`,e]},e))}),a&&(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`div`,{className:`summary-grid`,children:[(0,H.jsx)(X,{label:n(`moderation.sourceAndReason`),value:`${Pi(n,`source`,a.Source)} / ${Pi(n,`reason`,a.Reason)}`}),(0,H.jsx)(X,{label:n(`moderation.reporter`),value:String(a.ReporterUserID),mono:!0}),(0,H.jsx)(X,{label:n(`moderation.option`),value:a.Option,mono:!0}),(0,H.jsx)(X,{label:n(`common.time`),value:G(a.CreatedAt)})]}),a.Comment&&(0,H.jsx)(`p`,{className:`about-text`,children:a.Comment}),(0,H.jsx)(ln,{value:JSON.stringify(a,null,2)})]})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:n(`moderation.decisionAudit`),text:n(`moderation.decisionAuditHint`)}),(0,H.jsx)(ln,{value:JSON.stringify({decisions:r.Decisions,actions:r.Actions},null,2)})]}),r.Appeals.length>0&&(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:n(`moderation.appeals`)}),(0,H.jsx)(ln,{value:JSON.stringify(r.Appeals,null,2)})]})]}),side:(0,H.jsxs)(`section`,{className:`action-dock`,children:[(0,H.jsx)(`div`,{className:`dock-title`,children:n(`moderation.caseActions`)}),M&&(0,H.jsxs)(`button`,{className:`btn primary icon-text`,disabled:v,onClick:E,children:[(0,H.jsx)(lt,{size:15}),` `,j.AssignedTo?n(`moderation.renewClaim`):n(`moderation.claimCase`)]}),(0,H.jsxs)(`label`,{className:`field`,children:[(0,H.jsx)(`span`,{children:n(`moderation.reviewReason`)}),(0,H.jsx)(`textarea`,{value:s,onChange:e=>c(e.target.value),rows:5})]}),(0,H.jsxs)(`label`,{className:`field`,children:[(0,H.jsx)(`span`,{children:n(`moderation.decisionPreset`)}),(0,H.jsxs)(`select`,{value:l,onChange:e=>u(e.target.value),children:[(0,H.jsx)(`option`,{value:`no_violation`,children:n(`moderation.preset.noViolation`)}),(0,H.jsx)(`option`,{value:`scam`,children:n(`moderation.preset.scam`)}),(0,H.jsx)(`option`,{value:`fake`,children:n(`moderation.preset.fake`)}),(0,H.jsx)(`option`,{value:`freeze`,children:n(`moderation.preset.freeze`)}),(0,H.jsx)(`option`,{value:`scam_freeze`,children:n(`moderation.preset.scamFreeze`)}),(0,H.jsx)(`option`,{value:`fake_freeze`,children:n(`moderation.preset.fakeFreeze`)}),(0,H.jsx)(`option`,{value:`delete_messages`,children:n(`moderation.preset.deleteMessages`)}),(0,H.jsx)(`option`,{value:`delete_account`,children:n(`moderation.preset.deleteAccount`)})]})]}),l===`delete_messages`&&(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`label`,{className:`field`,children:[(0,H.jsx)(`span`,{children:n(`moderation.evidenceMessageIDs`)}),(0,H.jsx)(`input`,{value:d,onChange:e=>f(e.target.value),placeholder:`101, 102`})]}),j.Target.Type===`user`&&(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`label`,{className:`field`,children:[(0,H.jsx)(`span`,{children:n(`moderation.privateOwnerUserID`)}),(0,H.jsx)(`input`,{value:p,onChange:e=>m(e.target.value),inputMode:`numeric`})]}),(0,H.jsxs)(`label`,{className:`field checkbox-field`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:h,onChange:e=>_(e.target.checked)}),(0,H.jsx)(`span`,{children:n(`moderation.revokeForBoth`)})]})]}),(0,H.jsx)(q,{children:n(`moderation.evidenceValidationHint`)})]}),j.Status===`action_failed`&&l===`no_violation`&&(0,H.jsx)(q,{children:n(`moderation.failedActionHint`)}),N&&(0,H.jsxs)(`button`,{className:`btn danger icon-text`,disabled:v||!P,onClick:D,children:[(0,H.jsx)(L,{size:15}),` `,j.Status===`action_failed`?n(`moderation.retryAction`):n(`moderation.submitDecision`)]}),F&&j.AssignedTo&&(0,H.jsxs)(H.Fragment,{children:[(0,H.jsx)(`div`,{className:`dock-title`,children:n(`moderation.appealReviewTitle`,{id:F.ID})}),(0,H.jsx)(X,{label:n(`moderation.automaticRemedy`),value:T.label}),T.blocked&&(0,H.jsx)(q,{children:n(`moderation.irreversibleAppealHint`)}),(0,H.jsx)(`button`,{className:`btn`,disabled:v,onClick:()=>A(F.ID,!1),children:n(`moderation.denyAppeal`)}),(0,H.jsx)(`button`,{className:`btn primary`,disabled:v||T.blocked,onClick:()=>A(F.ID,!0),children:n(`moderation.grantAppeal`)})]})]})})]})}function Li(e,t,n,r,i){switch(e){case`scam`:return[{kind:`mark_scam`,payload:{}}];case`fake`:return[{kind:`mark_fake`,payload:{}}];case`freeze`:return[{kind:`freeze_account`,payload:{}}];case`scam_freeze`:return[{kind:`mark_scam`,payload:{}},{kind:`freeze_account`,payload:{}}];case`fake_freeze`:return[{kind:`mark_fake`,payload:{}},{kind:`freeze_account`,payload:{}}];case`delete_messages`:return n.length===0?[]:t===`channel`?[{kind:`delete_channel_message`,payload:{ids:n}}]:t===`user`&&Number.isSafeInteger(r)&&r>0?[{kind:`delete_private_message`,payload:{owner_user_id:r,ids:n,revoke:i}}]:[];case`delete_account`:return[{kind:`delete_account`,payload:{}}];default:return[]}}function Ri(e){let t=e.split(/[,\s]+/).filter(Boolean).map(Number);return t.length===0||t.some(e=>!Number.isSafeInteger(e)||e<=0)?[]:[...new Set(t)]}function zi(e,t){let n=!1,r=!1,i=!1;for(let t of[...e.Actions].sort((e,t)=>e.ID-t.ID))if(t.Status===`succeeded`)switch(t.Kind){case`mark_scam`:case`mark_fake`:n=!0;break;case`clear_peer_flags`:n=!1;break;case`freeze_account`:r=!0;break;case`unfreeze_account`:r=!1;break;case`delete_private_message`:case`delete_channel_message`:case`delete_account`:i=!0;break}let a=[],o=[];return n&&(a.push({kind:`clear_peer_flags`,payload:{}}),o.push(t(`moderation.remedy.clearFlags`))),r&&(a.push({kind:`unfreeze_account`,payload:{}}),o.push(t(`moderation.remedy.unfreeze`))),{actions:a,label:o.join(` + `)||t(`moderation.remedy.none`),blocked:i}}function Bi(e,t){return e(`moderation.preset.${{no_violation:`noViolation`,scam:`scam`,fake:`fake`,freeze:`freeze`,scam_freeze:`scamFreeze`,fake_freeze:`fakeFreeze`,delete_messages:`deleteMessages`,delete_account:`deleteAccount`}[t]}`)}function Vi(e){return{Months:1,DurationDays:30,AmountStars:100,FiatCurrency:`USD`,FiatAmount:100,StoreProduct:``,StoreQuantity:0,Enabled:!0,SortOrder:40,Label:e,ManagedBy:`admin`,Version:0,UpdatedAt:0}}function Hi(e){let t=e.StoreProduct.trim().length>0;return Number.isInteger(e.Months)&&e.Months>0&&e.Months<=120&&Number.isInteger(e.DurationDays)&&e.DurationDays>0&&e.DurationDays<=36500&&Number.isSafeInteger(e.AmountStars)&&e.AmountStars>0&&Number.isSafeInteger(e.FiatAmount)&&e.FiatAmount>0&&/^[A-Z]{3}$/.test(e.FiatCurrency.trim().toUpperCase())&&e.StoreProduct.trim().length<=256&&(!t&&e.StoreQuantity===0||t&&Number.isInteger(e.StoreQuantity)&&e.StoreQuantity>0)&&Number.isInteger(e.SortOrder)&&e.Label.trim().length>0}function Ui(){let{t:e}=U(),[t,n]=(0,g.useState)([]),[r,i]=(0,g.useState)(null),[a,o]=(0,g.useState)(null),[s,c]=(0,g.useState)(`1000`),[l,u]=(0,g.useState)(`1`),[d,f]=(0,g.useState)(!0),[p,m]=(0,g.useState)(``),h=Number(s),_=Number(l),v=a?.ID??0,y=t.filter(e=>e.Enabled).length;async function b(){f(!0),m(``);try{n((await k.premiumPlans()).plans??[])}catch(e){m(O(e))}finally{f(!1)}}(0,g.useEffect)(()=>{b()},[]);function x(e,t){n(n=>n.map(n=>n.Months===e?{...n,...t}:n))}function S(e){return{months:e.Months,duration_days:e.DurationDays,amount_stars:e.AmountStars,fiat_currency:e.FiatCurrency.trim().toUpperCase(),fiat_amount:e.FiatAmount,store_product:e.StoreProduct.trim(),store_quantity:e.StoreProduct.trim()?e.StoreQuantity:0,enabled:e.Enabled,sort_order:e.SortOrder,label:e.Label.trim(),expected_version:e.Version}}function C({label:e,children:t,wide:n=!1}){return(0,H.jsxs)(`label`,{className:`premium-plan-field ${n?`wide`:``}`,children:[(0,H.jsx)(`span`,{children:e}),t]})}function w({plan:t,onChange:n,create:r=!1}){return(0,H.jsxs)(`div`,{className:`premium-plan-groups`,children:[(0,H.jsxs)(`section`,{className:`premium-plan-group`,children:[(0,H.jsxs)(`div`,{className:`premium-plan-group-title`,children:[(0,H.jsx)(B,{size:15}),(0,H.jsx)(`strong`,{children:e(`premium.planDetails`)})]}),(0,H.jsxs)(`div`,{className:`premium-plan-field-grid`,children:[(0,H.jsx)(C,{label:e(`premium.label`),wide:!0,children:(0,H.jsx)(`input`,{value:t.Label,onChange:e=>n({Label:e.target.value})})}),(0,H.jsx)(C,{label:e(`premium.months`),children:(0,H.jsx)(`input`,{type:`number`,min:1,max:120,value:t.Months,disabled:!r,onChange:e=>n({Months:Number(e.target.value)})})}),(0,H.jsx)(C,{label:e(`premium.durationDays`),children:(0,H.jsx)(`input`,{type:`number`,min:1,max:36500,value:t.DurationDays,onChange:e=>n({DurationDays:Number(e.target.value)})})}),(0,H.jsx)(C,{label:e(`premium.sortOrder`),children:(0,H.jsx)(`input`,{type:`number`,value:t.SortOrder,onChange:e=>n({SortOrder:Number(e.target.value)})})})]})]}),(0,H.jsxs)(`section`,{className:`premium-plan-group`,children:[(0,H.jsxs)(`div`,{className:`premium-plan-group-title`,children:[(0,H.jsx)(ye,{size:15}),(0,H.jsx)(`strong`,{children:e(`premium.pricing`)})]}),(0,H.jsxs)(`div`,{className:`premium-plan-field-grid`,children:[(0,H.jsx)(C,{label:e(`premium.amountStars`),wide:!0,children:(0,H.jsxs)(`div`,{className:`premium-input-affix`,children:[(0,H.jsx)(`span`,{children:`★`}),(0,H.jsx)(`input`,{type:`number`,min:1,value:t.AmountStars,onChange:e=>n({AmountStars:Number(e.target.value)})})]})}),(0,H.jsx)(C,{label:e(`premium.fiatCurrency`),children:(0,H.jsx)(`input`,{maxLength:3,value:t.FiatCurrency,onChange:e=>n({FiatCurrency:e.target.value.toUpperCase()})})}),(0,H.jsx)(C,{label:e(`premium.fiatAmount`),children:(0,H.jsx)(`input`,{type:`number`,min:1,value:t.FiatAmount,onChange:e=>n({FiatAmount:Number(e.target.value)})})})]})]}),(0,H.jsxs)(`section`,{className:`premium-plan-group`,children:[(0,H.jsxs)(`div`,{className:`premium-plan-group-title`,children:[(0,H.jsx)(qe,{size:15}),(0,H.jsx)(`strong`,{children:e(`premium.storeSettings`)})]}),(0,H.jsxs)(`div`,{className:`premium-plan-field-grid`,children:[(0,H.jsx)(C,{label:e(`premium.storeProduct`),wide:!0,children:(0,H.jsx)(`input`,{value:t.StoreProduct,placeholder:e(`premium.storeProductPlaceholder`),onChange:e=>{let t=e.target.value;n({StoreProduct:t,...t.trim()?{}:{StoreQuantity:0}})}})}),(0,H.jsx)(C,{label:e(`premium.storeQuantity`),children:(0,H.jsx)(`input`,{type:`number`,min:0,value:t.StoreQuantity,disabled:!t.StoreProduct.trim(),onChange:e=>n({StoreQuantity:Number(e.target.value)})})}),(0,H.jsx)(`p`,{className:`premium-store-hint`,children:e(`premium.storeSettingsHint`)})]})]})]})}return(0,H.jsxs)(nn,{title:e(`premium.title`),eyebrow:e(`premium.eyebrow`),actions:(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>void b(),disabled:d,children:[(0,H.jsx)(nt,{size:15}),` `,e(`common.refresh`)]}),children:[(0,H.jsxs)(`section`,{className:`surface premium-operations-compact`,children:[(0,H.jsx)(K,{title:e(`premium.operations`),text:e(`premium.operationsHint`)}),(0,H.jsxs)(`div`,{className:`premium-operation-bar`,children:[(0,H.jsx)(`div`,{className:`premium-account-picker`,children:(0,H.jsx)(fr,{variant:`dropdown`,label:e(`premium.userID`),value:a,onChange:o})}),(0,H.jsxs)(`div`,{className:`premium-quick-action stars`,children:[(0,H.jsxs)(`div`,{className:`premium-quick-action-head`,children:[(0,H.jsx)(`span`,{children:(0,H.jsx)(ye,{size:14})}),(0,H.jsx)(`strong`,{children:e(`premium.starsAmount`)})]}),(0,H.jsxs)(`div`,{className:`premium-quick-action-control`,children:[(0,H.jsx)(`input`,{"aria-label":e(`premium.starsAmount`),type:`number`,min:1,value:s,onChange:e=>c(e.target.value)}),(0,H.jsx)(`div`,{className:`premium-action-submit`,children:(0,H.jsx)(Z,{compact:!0,tone:`neutral`,icon:(0,H.jsx)(ye,{size:14}),label:e(`premium.grantStars`),path:`/api/actions/grant-stars`,disabled:!v||!Number.isSafeInteger(h)||h<=0,payload:()=>({user_id:v,amount:h})})})]})]}),(0,H.jsxs)(`div`,{className:`premium-quick-action premium`,children:[(0,H.jsxs)(`div`,{className:`premium-quick-action-head`,children:[(0,H.jsx)(`span`,{children:(0,H.jsx)(B,{size:14})}),(0,H.jsx)(`strong`,{children:e(`premium.months`)})]}),(0,H.jsxs)(`div`,{className:`premium-quick-action-control`,children:[(0,H.jsx)(`input`,{"aria-label":e(`premium.months`),type:`number`,min:1,max:120,value:l,onChange:e=>u(e.target.value)}),(0,H.jsx)(`div`,{className:`premium-action-submit`,children:(0,H.jsx)(Z,{compact:!0,tone:`neutral`,icon:(0,H.jsx)(B,{size:14}),label:e(`premium.grantPremium`),path:`/api/actions/grant-premium`,disabled:!v||!Number.isInteger(_)||_<=0||_>120,payload:()=>({user_id:v,months:_})})})]})]})]})]}),(0,H.jsxs)(`section`,{className:`surface premium-catalog-compact`,children:[(0,H.jsx)(K,{title:e(`premium.catalog`),text:e(`premium.catalogHint`),action:(0,H.jsxs)(`div`,{className:`premium-catalog-actions`,children:[(0,H.jsx)(`span`,{className:`premium-catalog-count`,children:e(`premium.catalogSummary`,{total:t.length,enabled:y})}),(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>i(Vi(e(`premium.defaultPlanLabel`))),children:[(0,H.jsx)(Qe,{size:15}),` `,e(`premium.addPlan`)]})]})}),p&&(0,H.jsx)(q,{children:p}),(0,H.jsxs)(`div`,{className:`premium-plan-list`,children:[t.map(t=>(0,H.jsxs)(`details`,{className:`premium-plan-card`,children:[(0,H.jsxs)(`summary`,{children:[(0,H.jsx)(`span`,{className:`premium-plan-chevron`,children:(0,H.jsx)(ge,{size:16})}),(0,H.jsxs)(`span`,{className:`premium-plan-identity`,children:[(0,H.jsx)(`strong`,{children:t.Label}),(0,H.jsx)(`span`,{children:e(`premium.planTerm`,{months:t.Months,days:t.DurationDays})})]}),(0,H.jsxs)(`span`,{className:`premium-plan-summary-value`,children:[(0,H.jsx)(`small`,{children:e(`premium.amountStars`)}),(0,H.jsxs)(`strong`,{children:[`★ `,t.AmountStars]})]}),(0,H.jsxs)(`span`,{className:`premium-plan-summary-value`,children:[(0,H.jsx)(`small`,{children:e(`premium.fiatPrice`)}),(0,H.jsxs)(`strong`,{children:[t.FiatCurrency,` `,t.FiatAmount]})]}),(0,H.jsx)(J,{tone:t.ManagedBy===`admin`?`good`:`neutral`,children:t.ManagedBy===`admin`?e(`premium.sourceAdmin`):e(`premium.sourceConfig`)}),(0,H.jsxs)(`span`,{className:`premium-plan-state ${t.Enabled?`enabled`:``}`,children:[(0,H.jsx)(`i`,{}),t.Enabled?e(`common.enabled`):e(`common.disabled`)]}),(0,H.jsxs)(`span`,{className:`premium-plan-edit`,children:[(0,H.jsx)(st,{size:14}),e(`premium.editPlan`)]})]}),(0,H.jsxs)(`div`,{className:`premium-plan-editor`,children:[(0,H.jsx)(w,{plan:t,onChange:e=>x(t.Months,e)}),(0,H.jsxs)(`footer`,{className:`premium-plan-footer`,children:[(0,H.jsxs)(`div`,{className:`premium-plan-meta`,children:[(0,H.jsxs)(`span`,{children:[e(`premium.source`),`: `,(0,H.jsx)(`strong`,{children:t.ManagedBy===`admin`?e(`premium.sourceAdmin`):e(`premium.sourceConfig`)})]}),(0,H.jsxs)(`span`,{children:[e(`premium.version`),`: `,(0,H.jsx)(`strong`,{children:t.Version})]})]}),(0,H.jsxs)(`label`,{className:`gift-switch`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:t.Enabled,onChange:e=>x(t.Months,{Enabled:e.target.checked})}),(0,H.jsx)(`span`,{className:`gift-switch-track`,children:(0,H.jsx)(`span`,{})}),t.Enabled?e(`common.enabled`):e(`common.disabled`)]}),(0,H.jsx)(Z,{compact:!0,tone:`neutral`,icon:(0,H.jsx)(rt,{size:13}),label:e(`premium.save`),path:`/api/actions/upsert-premium-plan`,disabled:!Hi(t),payload:()=>S(t),onDone:()=>void b()})]})]})]},t.Months)),!d&&t.length===0&&(0,H.jsx)(`table`,{children:(0,H.jsx)(`tbody`,{children:(0,H.jsx)(sn,{colSpan:1})})}),d&&(0,H.jsx)(`div`,{className:`loading-line`,children:e(`common.loading`)})]})]}),r&&(0,Ln.createPortal)((0,H.jsx)(`div`,{className:`modal-backdrop`,role:`presentation`,children:(0,H.jsxs)(`section`,{className:`modal command-modal premium-plan-modal`,role:`dialog`,"aria-modal":`true`,"aria-label":e(`premium.newPlan`),children:[(0,H.jsxs)(`div`,{className:`modal-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`eyebrow`,children:e(`premium.eyebrow`)}),(0,H.jsx)(`h2`,{children:e(`premium.newPlan`)}),(0,H.jsx)(`p`,{children:e(`premium.newPlanHint`)})]}),(0,H.jsx)(`button`,{className:`icon-btn`,type:`button`,onClick:()=>i(null),"aria-label":e(`common.close`),children:(0,H.jsx)(Dt,{size:15})})]}),(0,H.jsx)(`div`,{className:`command-body premium-plan-modal-body`,children:(0,H.jsx)(w,{plan:r,create:!0,onChange:e=>i({...r,...e})})}),(0,H.jsxs)(`div`,{className:`modal-actions`,children:[(0,H.jsx)(`button`,{className:`btn`,type:`button`,onClick:()=>i(null),children:e(`common.close`)}),(0,H.jsx)(Z,{compact:!0,tone:`neutral`,icon:(0,H.jsx)(Qe,{size:14}),label:e(`premium.create`),path:`/api/actions/upsert-premium-plan`,disabled:!Hi(r),payload:()=>S(r),onDone:()=>{i(null),b()}})]})]})}),document.body)]})}var Wi=[`pending`,`approved`,`rejected`,`revoked`],Gi=[`user`,`channel`];function Ki({navigate:e}){let{t}=U(),{can:n}=vn(),r=n(hn),i=n(pn),[a,o]=(0,g.useState)(`requests`),[s,c]=(0,g.useState)([]),[l,u]=(0,g.useState)([]),[d,f]=(0,g.useState)(``),[p,m]=(0,g.useState)(!1);async function h(){f(``),m(!1);try{let[e,t]=await Promise.all([k.botVerifiers(new URLSearchParams({limit:`200`})),k.verificationIcons(new URLSearchParams({limit:`200`}))]);c(e.rows??[]),u(t.rows??[])}catch(e){if(e instanceof v&&e.status===403){c([]),u([]),m(!0);return}f(O(e))}}(0,g.useEffect)(()=>{h()},[]);let _=[{key:`requests`,label:t(`botverification.tabRequests`),icon:(0,H.jsx)(mt,{size:15})},{key:`verifiers`,label:t(`botverification.tabVerifiers`),icon:(0,H.jsx)(fe,{size:15})},{key:`icons`,label:t(`botverification.tabIcons`),icon:(0,H.jsx)(gt,{size:15})},{key:`marks`,label:t(`botverification.tabMarks`),icon:(0,H.jsx)(F,{size:15})}];return(0,H.jsxs)(nn,{title:t(`botverification.pageTitle`),eyebrow:t(`botverification.eyebrow`),actions:i?(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>e(`/verification`),children:[(0,H.jsx)(Ce,{size:15}),` `,t(`botverification.openOfficial`)]}):void 0,children:[d&&(0,H.jsx)(q,{children:d}),p&&(0,H.jsx)(q,{children:t(`botverification.rosterDenied`)}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:t(`botverification.explainTitle`),text:t(`botverification.explainText`)}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:t(`botverification.explainIcon`)}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:t(`botverification.explainOfficial`)}),!r&&(0,H.jsx)(`p`,{className:`bot-create-note`,children:t(`botverification.manageMissing`)})]}),(0,H.jsx)(`div`,{className:`toolbar`,role:`group`,"aria-label":t(`botverification.pageTitle`),children:_.map(e=>(0,H.jsxs)(`button`,{className:`btn icon-text ${a===e.key?`primary`:``}`,type:`button`,"aria-pressed":a===e.key,onClick:()=>o(e.key),children:[e.icon,` `,e.label]},e.key))}),a===`requests`&&(0,H.jsx)(qi,{navigate:e,verifiers:s}),a===`verifiers`&&(0,H.jsx)(Ji,{verifiers:s,icons:l,canManage:r,onChanged:h,navigate:e}),a===`icons`&&(0,H.jsx)(Yi,{icons:l,verifiers:s,canManage:r,onChanged:h}),a===`marks`&&(0,H.jsx)(Xi,{verifiers:s,canManage:r,navigate:e})]})}function qi({navigate:e,verifiers:t}){let{t:n}=U(),[r,i]=(0,g.useState)(`pending`),[a,o]=(0,g.useState)(``),[s,c]=(0,g.useState)(`all`),[l,u]=(0,g.useState)(``),[d,f]=(0,g.useState)(`50`),[p,m]=(0,g.useState)([]),[h,_]=(0,g.useState)({}),[v,y]=(0,g.useState)(!1),[b,x]=(0,g.useState)(``),[S,C]=(0,g.useState)(!1),[w,T]=(0,g.useState)(``);async function E(e=!1){C(!0),T(``);let t=new URLSearchParams({limit:d});r!==`all`&&t.set(`status`,r),a&&t.set(`verifier_bot_id`,a),s!==`all`&&t.set(`peer_type`,s),l.trim()&&t.set(`q`,l.trim().replace(/^@/,``)),e&&b&&t.set(`before_id`,b);try{let n=await k.customVerificationRequests(t),r=n.rows??[];m(t=>e?[...t,...r]:r),x(n.next_before_id??``),y(!!n.has_more)}catch(e){T(O(e))}finally{C(!1)}}async function D(){try{_((await k.botVerificationCounts()).counts??{})}catch(e){T(O(e))}}(0,g.useEffect)(()=>{E(!1),D()},[]);function A(){E(!1),D()}return(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:n(`botverification.queueTitle`),text:n(`botverification.queueHint`),action:(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:A,disabled:S,children:[(0,H.jsx)(nt,{size:15,className:S?`spin`:``}),` `,n(`common.refresh`)]})}),w&&(0,H.jsx)(q,{children:w}),(0,H.jsx)(`div`,{className:`metric-row`,children:Wi.map(e=>(0,H.jsx)(Y,{label:n(`botverification.status.${e}`),value:h[e]??`0`,mono:!0,tone:ea(e,h[e]??`0`)},e))})]}),(0,H.jsx)(rn,{children:(0,H.jsxs)(`form`,{className:`toolbar`,onSubmit:e=>{e.preventDefault(),E(!1)},children:[(0,H.jsxs)(`label`,{className:`searchbox`,children:[(0,H.jsx)(it,{size:15}),(0,H.jsx)(`input`,{value:l,onChange:e=>u(e.target.value),placeholder:n(`botverification.searchPlaceholder`)})]}),(0,H.jsxs)(`label`,{className:`field-inline`,children:[(0,H.jsx)(`span`,{children:n(`common.status`)}),(0,H.jsxs)(`select`,{value:r,onChange:e=>i(e.target.value),children:[(0,H.jsx)(`option`,{value:`all`,children:n(`botverification.statusAll`)}),Wi.map(e=>(0,H.jsx)(`option`,{value:e,children:n(`botverification.status.${e}`)},e))]})]}),(0,H.jsxs)(`label`,{className:`field-inline`,children:[(0,H.jsx)(`span`,{children:n(`botverification.verifier`)}),(0,H.jsx)(Zi,{value:a,verifiers:t,onChange:o})]}),(0,H.jsxs)(`label`,{className:`field-inline`,children:[(0,H.jsx)(`span`,{children:n(`botverification.peerType`)}),(0,H.jsxs)(`select`,{value:s,onChange:e=>c(e.target.value),children:[(0,H.jsx)(`option`,{value:`all`,children:n(`botverification.peerTypeAll`)}),Gi.map(e=>(0,H.jsx)(`option`,{value:e,children:n(`botverification.peer.${e}`)},e))]})]}),(0,H.jsxs)(`label`,{className:`field-inline`,children:[(0,H.jsx)(`span`,{children:n(`common.limit`)}),(0,H.jsx)(`input`,{className:`small-input`,value:d,onChange:e=>f(e.target.value),type:`number`,min:`1`,max:`200`})]}),(0,H.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,disabled:S,children:[S?(0,H.jsx)(te,{size:15,className:`spin`}):(0,H.jsx)(it,{size:15}),` `,n(`common.search`)]})]})}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:n(`common.id`)}),(0,H.jsx)(`th`,{children:n(`botverification.verifier`)}),(0,H.jsx)(`th`,{children:n(`botverification.target`)}),(0,H.jsx)(`th`,{children:n(`botverification.applicant`)}),(0,H.jsx)(`th`,{children:n(`botverification.reason`)}),(0,H.jsx)(`th`,{children:n(`common.status`)}),(0,H.jsx)(`th`,{children:n(`botverification.createdAt`)}),(0,H.jsx)(`th`,{})]})}),(0,H.jsxs)(`tbody`,{children:[p.map(t=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{className:`mono`,children:(0,H.jsxs)(`button`,{className:`row-link`,type:`button`,onClick:()=>e(`/bot-verification/${t.ID}`),children:[`#`,t.ID]})}),(0,H.jsxs)(`td`,{children:[(0,H.jsx)(`strong`,{children:W(t.VerifierBotUsername)||t.VerifierBotID}),(0,H.jsx)(`div`,{className:`entity-subtitle mono`,children:t.VerifierBotID})]}),(0,H.jsxs)(`td`,{children:[(0,H.jsx)(`strong`,{children:ta(t)}),(0,H.jsxs)(`div`,{className:`entity-subtitle mono`,children:[n(`botverification.peer.${t.PeerType}`),` · `,t.PeerID]})]}),(0,H.jsxs)(`td`,{children:[W(t.ApplicantUsername)||`-`,(0,H.jsx)(`div`,{className:`entity-subtitle mono`,children:t.ApplicantUserID})]}),(0,H.jsx)(`td`,{className:`truncate`,children:t.Reason||`-`}),(0,H.jsx)(`td`,{children:(0,H.jsx)(Qi,{status:t.Status})}),(0,H.jsx)(`td`,{children:G(t.CreatedAt)||`-`}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`button`,{className:`row-link`,type:`button`,onClick:()=>e(`/bot-verification/${t.ID}`),children:[(0,H.jsx)(mt,{size:14}),` `,n(`common.detail`),` `,(0,H.jsx)(ve,{size:14})]})})]},t.ID)),p.length===0&&(0,H.jsx)(sn,{colSpan:8})]})]})}),v&&(0,H.jsx)(`div`,{className:`toolbar`,children:(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>E(!0),disabled:S,children:[S?(0,H.jsx)(te,{size:15,className:`spin`}):(0,H.jsx)(ge,{size:15}),` `,n(`common.loadMore`)]})})]})}function Ji({verifiers:e,icons:t,canManage:n,onChanged:r,navigate:i}){let{t:a}=U(),[o,s]=(0,g.useState)(null),[c,l]=(0,g.useState)(null),[u,d]=(0,g.useState)(``),[f,p]=(0,g.useState)(``),[m,h]=(0,g.useState)(``),[_,v]=(0,g.useState)(!1),y=t.filter(e=>e.Active),b=y.map(e=>({value:e.DocumentID,label:`${e.Name} · ${e.DocumentID}`}));if(u&&!b.some(e=>e.value===u)){let e=t.find(e=>e.DocumentID===u);b.unshift({value:u,label:`${e?.Name??u} · ${u} (${a(`botverification.iconInactive`)})`})}function x(e){l(e),s(null),d(e.IconDocumentID),p(e.CompanyName),h(e.DefaultDescription),v(e.CanModifyCustomDescription)}function S(){l(null),s(null),d(``),p(``),h(``),v(!1)}function C(){return{bot_id:c?c.BotID:o?String(o.ID):`0`,icon_document_id:u||`0`,company_name:f.trim(),default_description:m.trim(),can_modify_custom_description:_,version:c?c.Version:`0`}}return(0,H.jsxs)(H.Fragment,{children:[n&&(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:a(c?`botverification.updateTitle`:`botverification.grantTitle`),text:a(`botverification.grantHint`),action:c?(0,H.jsx)(`button`,{className:`btn icon-text`,type:`button`,onClick:S,children:a(`botverification.cancelEdit`)}):void 0}),c?(0,H.jsx)(`p`,{className:`bot-create-note`,children:a(`botverification.editing`,{bot:W(c.BotUsername)||c.BotID,version:c.Version})}):(0,H.jsx)(pr,{label:a(`botverification.grantBot`),value:o,onChange:s}),(0,H.jsxs)(`div`,{className:`bot-create-fields`,children:[(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:a(`botverification.grantIcon`)}),(0,H.jsxs)(`select`,{value:u,onChange:e=>d(e.target.value),children:[(0,H.jsx)(`option`,{value:``,children:a(`botverification.grantIconPick`)}),b.map(e=>(0,H.jsx)(`option`,{value:e.value,children:e.label},e.value))]})]}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:a(`botverification.company`)}),(0,H.jsx)(`input`,{value:f,onChange:e=>p(e.target.value),placeholder:a(`botverification.companyPlaceholder`)})]}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:a(`botverification.defaultDescription`)}),(0,H.jsx)(`input`,{value:m,onChange:e=>h(e.target.value),placeholder:a(`botverification.defaultDescriptionPlaceholder`)})]})]}),(0,H.jsxs)(`label`,{className:`checkline`,children:[(0,H.jsx)(`input`,{type:`checkbox`,checked:_,onChange:e=>v(e.target.checked)}),a(`botverification.canModify`)]}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:a(`botverification.canModifyHint`)}),y.length===0&&(0,H.jsx)(q,{children:a(`botverification.noActiveIcons`)}),(0,H.jsxs)(`div`,{className:`bot-create-actions`,children:[(0,H.jsx)(`span`,{className:`bot-create-note`,children:a(`botverification.grantNote`)}),(0,H.jsx)(Z,{label:a(c?`botverification.update`:`botverification.grant`),icon:(0,H.jsx)(Qe,{size:15}),tone:`neutral`,path:`/api/actions/grant-bot-verifier`,payload:C,onDone:()=>{S(),r()}})]})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:a(`botverification.verifiersTitle`),text:a(`botverification.verifiersHint`),action:(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:r,children:[(0,H.jsx)(nt,{size:15}),` `,a(`common.refresh`)]})}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:a(`botverification.bot`)}),(0,H.jsx)(`th`,{children:a(`botverification.company`)}),(0,H.jsx)(`th`,{children:a(`botverification.icon`)}),(0,H.jsx)(`th`,{children:a(`botverification.canModifyShort`)}),(0,H.jsx)(`th`,{children:a(`common.status`)}),(0,H.jsx)(`th`,{children:a(`botverification.markCount`)}),(0,H.jsx)(`th`,{children:a(`botverification.grantedBy`)}),(0,H.jsx)(`th`,{children:a(`common.updatedAt`)}),n&&(0,H.jsx)(`th`,{})]})}),(0,H.jsxs)(`tbody`,{children:[e.map(e=>(0,H.jsxs)(`tr`,{children:[(0,H.jsxs)(`td`,{children:[(0,H.jsx)(`button`,{className:`row-link`,type:`button`,onClick:()=>i(`/bots/${e.BotID}`),children:(0,H.jsx)(`strong`,{children:W(e.BotUsername)||e.BotName||e.BotID})}),(0,H.jsx)(`div`,{className:`entity-subtitle mono`,children:e.BotID})]}),(0,H.jsxs)(`td`,{children:[(0,H.jsx)(`strong`,{children:e.CompanyName||`-`}),(0,H.jsx)(`div`,{className:`entity-subtitle truncate`,children:e.DefaultDescription||a(`botverification.notProvided`)})]}),(0,H.jsxs)(`td`,{children:[e.IconName||`-`,(0,H.jsx)(`div`,{className:`entity-subtitle mono`,children:e.IconDocumentID})]}),(0,H.jsx)(`td`,{children:e.CanModifyCustomDescription?a(`common.yes`):a(`common.no`)}),(0,H.jsx)(`td`,{children:e.Enabled?(0,H.jsx)(J,{tone:`good`,children:a(`botverification.enabled`)}):(0,H.jsx)(J,{tone:`warn`,children:a(`botverification.disabled`)})}),(0,H.jsx)(`td`,{className:`mono`,children:String(e.MarkCount??`0`)}),(0,H.jsxs)(`td`,{children:[e.GrantedBy||`-`,(0,H.jsx)(`div`,{className:`entity-subtitle truncate`,children:e.GrantReason||`-`})]}),(0,H.jsx)(`td`,{children:G(e.UpdatedAt)||`-`}),n&&(0,H.jsx)(`td`,{children:(0,H.jsxs)(`div`,{className:`row-actions`,children:[(0,H.jsx)(`button`,{className:`btn compact-btn`,type:`button`,onClick:()=>x(e),children:a(`botverification.edit`)}),(0,H.jsx)(Z,{label:e.Enabled?a(`botverification.disable`):a(`botverification.enable`),icon:e.Enabled?(0,H.jsx)($e,{size:14}):(0,H.jsx)(et,{size:14}),tone:e.Enabled?`warn`:`neutral`,compact:!0,path:`/api/actions/set-bot-verifier-enabled`,payload:()=>({bot_id:e.BotID,enabled:!e.Enabled}),onDone:r}),(0,H.jsx)(Z,{label:a(`botverification.revokeVerifier`),icon:(0,H.jsx)(yt,{size:14}),tone:`danger`,compact:!0,path:`/api/actions/revoke-bot-verifier`,payload:()=>({bot_id:e.BotID}),onDone:r})]})})]},e.BotID)),e.length===0&&(0,H.jsx)(sn,{colSpan:n?9:8})]})]})}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:a(`botverification.disableHint`)}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:a(`botverification.revokeVerifierHint`)})]})]})}function Yi({icons:e,verifiers:t,canManage:n,onChanged:r}){let{t:i}=U(),[a,o]=(0,g.useState)(``),[s,c]=(0,g.useState)(``),[l,u]=(0,g.useState)(``);function d(){let e={document_id:a.trim()||`0`,name:s.trim()};return l&&(e.owner_bot_id=l),e}return(0,H.jsxs)(H.Fragment,{children:[n&&(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:i(`botverification.addIconTitle`),text:i(`botverification.addIconHint`)}),(0,H.jsxs)(`div`,{className:`bot-create-fields`,children:[(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:i(`botverification.iconDocument`)}),(0,H.jsx)(`input`,{value:a,onChange:e=>o(e.target.value),inputMode:`numeric`,placeholder:`5361371319611781774`})]}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:i(`botverification.iconName`)}),(0,H.jsx)(`input`,{value:s,onChange:e=>c(e.target.value),placeholder:i(`botverification.iconNamePlaceholder`)})]}),(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:i(`botverification.iconOwner`)}),(0,H.jsxs)(`select`,{value:l,onChange:e=>u(e.target.value),children:[(0,H.jsx)(`option`,{value:``,children:i(`botverification.iconOwnerShared`)}),t.map(e=>(0,H.jsx)(`option`,{value:e.BotID,children:`${e.CompanyName||e.BotID} · ${W(e.BotUsername)||e.BotID}`},e.BotID))]})]})]}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:i(`botverification.iconDocumentHint`)}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:i(`botverification.iconOwnerHint`)}),(0,H.jsxs)(`div`,{className:`bot-create-actions`,children:[(0,H.jsx)(`span`,{className:`bot-create-note`,children:i(`botverification.addIconNote`)}),(0,H.jsx)(Z,{label:i(`botverification.addIcon`),icon:(0,H.jsx)(Qe,{size:15}),tone:`neutral`,path:`/api/actions/upsert-verification-icon`,payload:d,onDone:()=>{o(``),c(``),u(``),r()}})]})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:i(`botverification.iconsTitle`),text:i(`botverification.iconsHint`),action:(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:r,children:[(0,H.jsx)(nt,{size:15}),` `,i(`common.refresh`)]})}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:i(`botverification.iconDocument`)}),(0,H.jsx)(`th`,{children:i(`botverification.iconName`)}),(0,H.jsx)(`th`,{children:i(`botverification.iconOwner`)}),(0,H.jsx)(`th`,{children:i(`common.status`)}),(0,H.jsx)(`th`,{children:i(`botverification.usedBy`)}),(0,H.jsx)(`th`,{children:i(`botverification.createdAt`)}),n&&(0,H.jsx)(`th`,{})]})}),(0,H.jsxs)(`tbody`,{children:[e.map(e=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{className:`mono`,children:e.DocumentID}),(0,H.jsx)(`td`,{children:(0,H.jsx)(`strong`,{children:e.Name||`-`})}),(0,H.jsx)(`td`,{children:e.OwnerBotID&&e.OwnerBotID!==`0`?(0,H.jsxs)(H.Fragment,{children:[W(e.OwnerBotUsername)||e.OwnerBotID,(0,H.jsx)(`div`,{className:`entity-subtitle mono`,children:e.OwnerBotID})]}):(0,H.jsx)(J,{children:i(`botverification.iconOwnerShared`)})}),(0,H.jsx)(`td`,{children:e.Active?(0,H.jsx)(J,{tone:`good`,children:i(`botverification.iconActive`)}):(0,H.jsx)(J,{tone:`warn`,children:i(`botverification.iconInactive`)})}),(0,H.jsx)(`td`,{className:`mono`,children:String(e.UsedByVerifiers??`0`)}),(0,H.jsx)(`td`,{children:G(e.CreatedAt)||`-`}),n&&(0,H.jsx)(`td`,{children:(0,H.jsx)(`div`,{className:`row-actions`,children:(0,H.jsx)(Z,{label:e.Active?i(`botverification.deactivateIcon`):i(`botverification.activateIcon`),icon:e.Active?(0,H.jsx)($e,{size:14}):(0,H.jsx)(et,{size:14}),tone:e.Active?`warn`:`neutral`,compact:!0,path:`/api/actions/set-verification-icon-active`,payload:()=>({icon_id:e.ID,active:!e.Active}),onDone:r})})})]},e.ID)),e.length===0&&(0,H.jsx)(sn,{colSpan:n?7:6})]})]})}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:i(`botverification.deactivateIconHint`)})]})]})}function Xi({verifiers:e,canManage:t,navigate:n}){let{t:r}=U(),[i,a]=(0,g.useState)(``),[o,s]=(0,g.useState)(`all`),[c,l]=(0,g.useState)(``),[u,d]=(0,g.useState)(`50`),[f,p]=(0,g.useState)([]),[m,h]=(0,g.useState)(!1),[_,v]=(0,g.useState)(``),[y,b]=(0,g.useState)(!1),[x,S]=(0,g.useState)(``);async function C(e=!1){b(!0),S(``);let t=new URLSearchParams({limit:u});i&&t.set(`verifier_bot_id`,i),o!==`all`&&t.set(`peer_type`,o),c.trim()&&t.set(`q`,c.trim().replace(/^@/,``)),e&&_&&t.set(`before_id`,_);try{let n=await k.customVerifications(t),r=n.rows??[];p(t=>e?[...t,...r]:r),v(n.next_before_id??``),h(!!n.has_more)}catch(e){S(O(e))}finally{b(!1)}}return(0,g.useEffect)(()=>{C(!1)},[]),(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:r(`botverification.marksTitle`),text:r(`botverification.marksHint`),action:(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>C(!1),disabled:y,children:[(0,H.jsx)(nt,{size:15,className:y?`spin`:``}),` `,r(`common.refresh`)]})}),x&&(0,H.jsx)(q,{children:x})]}),(0,H.jsx)(rn,{children:(0,H.jsxs)(`form`,{className:`toolbar`,onSubmit:e=>{e.preventDefault(),C(!1)},children:[(0,H.jsxs)(`label`,{className:`searchbox`,children:[(0,H.jsx)(it,{size:15}),(0,H.jsx)(`input`,{value:c,onChange:e=>l(e.target.value),placeholder:r(`botverification.markSearchPlaceholder`)})]}),(0,H.jsxs)(`label`,{className:`field-inline`,children:[(0,H.jsx)(`span`,{children:r(`botverification.verifier`)}),(0,H.jsx)(Zi,{value:i,verifiers:e,onChange:a})]}),(0,H.jsxs)(`label`,{className:`field-inline`,children:[(0,H.jsx)(`span`,{children:r(`botverification.peerType`)}),(0,H.jsxs)(`select`,{value:o,onChange:e=>s(e.target.value),children:[(0,H.jsx)(`option`,{value:`all`,children:r(`botverification.peerTypeAll`)}),Gi.map(e=>(0,H.jsx)(`option`,{value:e,children:r(`botverification.peer.${e}`)},e))]})]}),(0,H.jsxs)(`label`,{className:`field-inline`,children:[(0,H.jsx)(`span`,{children:r(`common.limit`)}),(0,H.jsx)(`input`,{className:`small-input`,value:u,onChange:e=>d(e.target.value),type:`number`,min:`1`,max:`200`})]}),(0,H.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,disabled:y,children:[y?(0,H.jsx)(te,{size:15,className:`spin`}):(0,H.jsx)(it,{size:15}),` `,r(`common.search`)]})]})}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:r(`common.id`)}),(0,H.jsx)(`th`,{children:r(`botverification.verifier`)}),(0,H.jsx)(`th`,{children:r(`botverification.target`)}),(0,H.jsx)(`th`,{children:r(`botverification.description`)}),(0,H.jsx)(`th`,{children:r(`botverification.icon`)}),(0,H.jsx)(`th`,{children:r(`botverification.createdAt`)}),t&&(0,H.jsx)(`th`,{})]})}),(0,H.jsxs)(`tbody`,{children:[f.map(e=>(0,H.jsxs)(`tr`,{children:[(0,H.jsxs)(`td`,{className:`mono`,children:[`#`,e.ID]}),(0,H.jsxs)(`td`,{children:[(0,H.jsx)(`strong`,{children:e.CompanyName||W(e.VerifierBotUsername)||e.VerifierBotID}),(0,H.jsx)(`div`,{className:`entity-subtitle mono`,children:W(e.VerifierBotUsername)||e.VerifierBotID})]}),(0,H.jsxs)(`td`,{children:[(0,H.jsx)(`button`,{className:`row-link`,type:`button`,onClick:()=>n(na(e.PeerType,e.PeerID)),children:(0,H.jsx)(`strong`,{children:ta(e)})}),(0,H.jsxs)(`div`,{className:`entity-subtitle mono`,children:[r(`botverification.peer.${e.PeerType}`),` · `,e.PeerID]})]}),(0,H.jsx)(`td`,{className:`truncate`,children:e.Description||r(`botverification.notProvided`)}),(0,H.jsx)(`td`,{className:`mono`,children:e.IconDocumentID}),(0,H.jsx)(`td`,{children:G(e.CreatedAt)||`-`}),t&&(0,H.jsx)(`td`,{children:(0,H.jsx)(`div`,{className:`row-actions`,children:(0,H.jsx)(Z,{label:r(`botverification.revokeMark`),icon:(0,H.jsx)(z,{size:14}),tone:`danger`,compact:!0,path:`/api/actions/revoke-custom-verification`,payload:()=>({verifier_bot_id:e.VerifierBotID,peer_type:e.PeerType,peer_id:e.PeerID}),onDone:()=>C(!1)})})})]},e.ID)),f.length===0&&(0,H.jsx)(sn,{colSpan:t?7:6})]})]})}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:r(`botverification.revokeMarkHint`)}),m&&(0,H.jsx)(`div`,{className:`toolbar`,children:(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>C(!0),disabled:y,children:[y?(0,H.jsx)(te,{size:15,className:`spin`}):(0,H.jsx)(ge,{size:15}),` `,r(`common.loadMore`)]})})]})}function Zi({value:e,verifiers:t,onChange:n}){let{t:r}=U();return(0,H.jsxs)(`select`,{value:e,onChange:e=>n(e.target.value),children:[(0,H.jsx)(`option`,{value:``,children:r(`botverification.verifierAll`)}),t.map(e=>(0,H.jsx)(`option`,{value:e.BotID,children:`${e.CompanyName||e.BotID} · ${W(e.BotUsername)||e.BotID}`+(e.Enabled?``:` (${r(`botverification.disabled`)})`)},e.BotID))]})}function Qi({status:e}){let{t}=U();return(0,H.jsx)(J,{tone:$i(e),children:t(`botverification.status.${e}`)})}function $i(e){return e===`approved`?`good`:e===`pending`?`warn`:e===`rejected`?`danger`:`neutral`}function ea(e,t){return e===`pending`?t!==`0`&&t!==``?`warn`:`neutral`:e===`approved`?`good`:`neutral`}function ta(e){return W(e.PeerUsername)||e.PeerTitle||`#${e.PeerID}`}function na(e,t){return e===`channel`?`/channels/${t}`:`/accounts/${t}`}function ra({id:e,navigate:t}){let{t:n}=U(),[r,i]=(0,g.useState)(null),[a,o]=(0,g.useState)(``),[s,c]=(0,g.useState)(!1),[l,u]=(0,g.useState)(!1),[d,f]=(0,g.useState)(``);async function p(){u(!0),f(``);try{i(await k.customVerificationRequest(e))}catch(e){f(O(e))}finally{u(!1)}}function m(){c(!1),p()}(0,g.useEffect)(()=>{p()},[e]);function h(e){if(e instanceof v&&e.status===409)return c(!0),p(),n(`botverification.conflict`)}if(d&&!r)return(0,H.jsx)(q,{children:d});if(!r)return(0,H.jsx)(cn,{label:n(`botverification.loadingDetail`)});let _=r.request,y=aa(r.verifier),b=r.mark_active,x=_.Status===`pending`,S=_.Status===`approved`,C=a.trim(),w=_.RequestedDescription.trim(),T=!!y?.CanModifyCustomDescription&&w!==``,E=T?w:(y?.DefaultDescription??``).trim();function D(){let e={version:_.Version};return C&&(e.internal_note=C),e}function A(){o(``),c(!1),p()}return(0,H.jsxs)(nn,{title:n(`botverification.detailTitle`,{id:_.ID}),eyebrow:n(`botverification.detailEyebrow`),actions:(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>t(`/bot-verification`),children:[(0,H.jsx)(ce,{size:15}),` `,n(`common.backToList`)]}),(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:m,disabled:l,children:[(0,H.jsx)(nt,{size:15,className:l?`spin`:``}),` `,n(`common.refresh`)]})]}),children:[d&&(0,H.jsx)(q,{children:d}),s&&(0,H.jsx)(q,{children:n(`botverification.conflict`)}),(0,H.jsx)(an,{main:(0,H.jsxs)(`div`,{className:`stacked-sections`,children:[(0,H.jsxs)(`section`,{className:`entity-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`entity-title`,children:ta(_)}),(0,H.jsxs)(`div`,{className:`entity-subtitle mono`,children:[`#`,_.ID,` · `,n(`botverification.peer.${_.PeerType}`),`:`,_.PeerID,` · v`,_.Version]})]}),(0,H.jsxs)(`div`,{className:`entity-badges`,children:[(0,H.jsx)(Qi,{status:_.Status}),b?(0,H.jsxs)(J,{tone:`good`,children:[(0,H.jsx)(F,{size:12}),` `,n(`botverification.markActive`)]}):(0,H.jsx)(J,{tone:`neutral`,children:n(`botverification.markInactive`)})]})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:n(`botverification.explainTitle`),text:n(`botverification.explainText`)}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`botverification.explainIcon`)})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:n(`botverification.verifierSection`),text:n(`botverification.verifierHint`),action:(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>t(`/bots/${_.VerifierBotID}`),children:[(0,H.jsx)(fe,{size:15}),` `,n(`botverification.openVerifier`)]})}),(0,H.jsxs)(`div`,{className:`summary-grid`,children:[(0,H.jsx)(X,{label:n(`botverification.company`),value:y?.CompanyName||`-`}),(0,H.jsx)(X,{label:n(`botverification.bot`),value:W(_.VerifierBotUsername)||`-`}),(0,H.jsx)(X,{label:n(`botverification.verifierID`),value:_.VerifierBotID,mono:!0}),(0,H.jsx)(X,{label:n(`botverification.iconDocument`),value:y?.IconDocumentID||`-`,mono:!0}),(0,H.jsx)(X,{label:n(`botverification.iconName`),value:y?.IconName||`-`}),(0,H.jsx)(X,{label:n(`botverification.canModifyShort`),value:y?.CanModifyCustomDescription?n(`common.yes`):n(`common.no`)})]}),(0,H.jsx)(ia,{label:n(`botverification.defaultDescription`),children:y?.DefaultDescription?(0,H.jsx)(`p`,{className:`about-text`,children:y.DefaultDescription}):(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`botverification.notProvided`)})}),!y&&(0,H.jsx)(q,{children:n(`botverification.verifierMissing`)}),y&&!y.Enabled&&(0,H.jsx)(q,{children:n(`botverification.verifierDisabledHint`)})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:n(`botverification.targetSection`),text:n(`botverification.targetHint`),action:(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>t(na(_.PeerType,_.PeerID)),children:[(0,H.jsx)(Ce,{size:15}),` `,n(`botverification.openTarget`)]})}),(0,H.jsxs)(`div`,{className:`summary-grid`,children:[(0,H.jsx)(X,{label:n(`common.type`),value:n(`botverification.peer.${_.PeerType}`)}),(0,H.jsx)(X,{label:n(`common.username`),value:W(_.PeerUsername)||`-`}),(0,H.jsx)(X,{label:n(`botverification.targetTitle`),value:_.PeerTitle||`-`}),(0,H.jsx)(X,{label:n(`botverification.targetID`),value:_.PeerID,mono:!0})]})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:n(`botverification.applicantSection`),text:n(`botverification.applicantHint`),action:(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>t(`/accounts/${_.ApplicantUserID}`),children:[(0,H.jsx)(wt,{size:15}),` `,n(`botverification.openApplicant`)]})}),(0,H.jsxs)(`div`,{className:`summary-grid`,children:[(0,H.jsx)(X,{label:n(`common.username`),value:W(_.ApplicantUsername)||`-`}),(0,H.jsx)(X,{label:n(`botverification.applicantID`),value:_.ApplicantUserID,mono:!0}),(0,H.jsx)(X,{label:n(`botverification.createdAt`),value:G(_.CreatedAt)||`-`}),(0,H.jsx)(X,{label:n(`common.updatedAt`),value:G(_.UpdatedAt)||`-`})]})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:n(`botverification.requestSection`),text:n(`botverification.requestHint`)}),(0,H.jsxs)(`div`,{className:`stacked-sections`,children:[(0,H.jsxs)(`div`,{className:`summary-grid`,children:[(0,H.jsx)(X,{label:n(`botverification.correlationID`),value:_.CorrelationID||`-`,mono:!0}),(0,H.jsx)(X,{label:n(`common.status`),value:n(`botverification.status.${_.Status}`)})]}),(0,H.jsx)(ia,{label:n(`botverification.reason`),children:_.Reason?(0,H.jsx)(`p`,{className:`about-text`,children:_.Reason}):(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`botverification.notProvided`)})}),(0,H.jsx)(ia,{label:n(`botverification.requestedDescription`),children:w?(0,H.jsx)(`p`,{className:`about-text`,children:w}):(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`botverification.notProvided`)})}),(0,H.jsx)(ia,{label:n(`botverification.markPreview`),children:E?(0,H.jsx)(`p`,{className:`about-text`,children:E}):(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`botverification.notProvided`)})}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`botverification.markPreviewHint`)}),w!==``&&!T&&(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`botverification.descriptionIgnoredHint`)})]})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:n(`botverification.decisionSection`),text:n(`botverification.decisionHint`)}),(0,H.jsxs)(`div`,{className:`stacked-sections`,children:[(0,H.jsxs)(`div`,{className:`summary-grid`,children:[(0,H.jsx)(X,{label:n(`botverification.decidedBy`),value:_.DecidedBy||`-`}),(0,H.jsx)(X,{label:n(`botverification.approvedAt`),value:G(_.ApprovedAt)||`-`}),(0,H.jsx)(X,{label:n(`botverification.rejectedAt`),value:G(_.RejectedAt)||`-`}),(0,H.jsx)(X,{label:n(`botverification.version`),value:_.Version,mono:!0})]}),(0,H.jsx)(ia,{label:n(`botverification.decisionReason`),children:_.DecisionReason?(0,H.jsx)(`p`,{className:`about-text`,children:_.DecisionReason}):(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`botverification.noDecision`)})}),(0,H.jsx)(ia,{label:`${n(`botverification.internalNote`)} · ${n(`botverification.adminOnly`)}`,children:_.InternalNote?(0,H.jsx)(`p`,{className:`about-text`,children:_.InternalNote}):(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`botverification.notProvided`)})})]})]})]}),side:(0,H.jsxs)(`section`,{className:`action-dock`,children:[(0,H.jsxs)(`div`,{className:`dock-title`,children:[(0,H.jsx)(mt,{size:14}),` `,n(`botverification.actionDock`)]}),!x&&!S&&(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`botverification.noActions`)}),(x||S)&&(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:n(`botverification.internalNote`)}),(0,H.jsx)(`textarea`,{value:a,onChange:e=>o(e.target.value),rows:3,placeholder:n(`botverification.internalNotePlaceholder`)})]}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`botverification.internalNoteHint`)})]}),x&&(0,H.jsxs)(H.Fragment,{children:[!y&&(0,H.jsx)(q,{children:n(`botverification.verifierMissing`)}),y&&!y.Enabled&&(0,H.jsx)(q,{children:n(`botverification.verifierDisabledHint`)}),b&&(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`botverification.markActiveHint`)}),(0,H.jsxs)(`div`,{className:`action-stack`,children:[(0,H.jsx)(Z,{label:n(`botverification.approve`),icon:(0,H.jsx)(L,{size:15}),tone:`neutral`,path:`/api/botverification/requests/${_.ID}/approve`,payload:D,onDone:A,onError:h}),(0,H.jsx)(Z,{label:n(`botverification.reject`),icon:(0,H.jsx)(ee,{size:15}),tone:`warn`,path:`/api/botverification/requests/${_.ID}/reject`,payload:D,onDone:A,onError:h})]}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`botverification.approveHint`)}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`botverification.rejectHint`)})]}),S&&(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`div`,{className:`dock-title`,children:[(0,H.jsx)(ut,{size:14}),` `,n(`botverification.dangerZone`)]}),(0,H.jsxs)(`div`,{className:`danger-zone`,children:[(0,H.jsx)(Z,{label:n(`botverification.revokeRequest`),icon:(0,H.jsx)(z,{size:15}),tone:`danger`,path:`/api/botverification/requests/${_.ID}/revoke`,payload:D,onDone:A,onError:h}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`botverification.revokeRequestHint`)}),!b&&(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`botverification.revokeNoMark`)})]})]})]})})]})}function ia({label:e,children:t}){return(0,H.jsxs)(`div`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:e}),t]})}function aa(e){return!e||!e.BotID||e.BotID===`0`?null:e}var oa=[`draft`,`submitted`,`in_review`,`approved`,`rejected`,`cancelled`],sa=[`bot`,`channel`,`supergroup`,`user`];function ca({navigate:e}){let{t}=U(),[n,r]=(0,g.useState)(`all`),[i,a]=(0,g.useState)(`all`),[o,s]=(0,g.useState)(``),[c,l]=(0,g.useState)(``),[u,d]=(0,g.useState)(`50`),[f,p]=(0,g.useState)([]),[m,h]=(0,g.useState)({}),[_,v]=(0,g.useState)(!1),[y,b]=(0,g.useState)(``),[x,S]=(0,g.useState)(!1),[C,w]=(0,g.useState)(``);async function T(e=!1){S(!0),w(``);let t=new URLSearchParams({limit:u});n!==`all`&&t.set(`status`,n),i!==`all`&&t.set(`target_type`,i),o.trim()&&t.set(`reviewer`,o.trim()),c.trim()&&t.set(`q`,c.trim().replace(/^@/,``)),e&&y&&t.set(`before_id`,y);try{let n=await k.verificationApplications(t),r=n.rows??[];p(t=>e?[...t,...r]:r),b(n.next_before_id??``),v(!!n.has_more)}catch(e){w(O(e))}finally{S(!1)}}async function E(){try{h((await k.verificationCounts()).counts??{})}catch(e){w(O(e))}}(0,g.useEffect)(()=>{T(!1),E()},[]);function D(){T(!1),E()}return(0,H.jsxs)(nn,{title:t(`verification.pageTitle`),eyebrow:t(`verification.eyebrow`),actions:(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:D,disabled:x,children:[(0,H.jsx)(nt,{size:15,className:x?`spin`:``}),` `,t(`common.refresh`)]}),children:[C&&(0,H.jsx)(q,{children:C}),(0,H.jsx)(`div`,{className:`metric-row`,children:oa.map(e=>(0,H.jsx)(Y,{label:t(`verification.status.${e}`),value:m[e]??`0`,mono:!0,tone:da(e,m[e]??`0`)},e))}),(0,H.jsx)(rn,{children:(0,H.jsxs)(`form`,{className:`toolbar`,onSubmit:e=>{e.preventDefault(),T(!1)},children:[(0,H.jsxs)(`label`,{className:`searchbox`,children:[(0,H.jsx)(it,{size:15}),(0,H.jsx)(`input`,{value:c,onChange:e=>l(e.target.value),placeholder:t(`verification.searchPlaceholder`)})]}),(0,H.jsxs)(`label`,{className:`field-inline`,children:[(0,H.jsx)(`span`,{children:t(`common.status`)}),(0,H.jsxs)(`select`,{value:n,onChange:e=>r(e.target.value),children:[(0,H.jsx)(`option`,{value:`all`,children:t(`verification.statusAll`)}),oa.map(e=>(0,H.jsx)(`option`,{value:e,children:t(`verification.status.${e}`)},e))]})]}),(0,H.jsxs)(`label`,{className:`field-inline`,children:[(0,H.jsx)(`span`,{children:t(`verification.targetType`)}),(0,H.jsxs)(`select`,{value:i,onChange:e=>a(e.target.value),children:[(0,H.jsx)(`option`,{value:`all`,children:t(`verification.targetTypeAll`)}),sa.map(e=>(0,H.jsx)(`option`,{value:e,children:t(`verification.type.${e}`)},e))]})]}),(0,H.jsxs)(`label`,{className:`field-inline`,children:[(0,H.jsx)(`span`,{children:t(`verification.reviewer`)}),(0,H.jsx)(`input`,{value:o,onChange:e=>s(e.target.value),placeholder:t(`verification.reviewerPlaceholder`)})]}),(0,H.jsxs)(`label`,{className:`field-inline`,children:[(0,H.jsx)(`span`,{children:t(`common.limit`)}),(0,H.jsx)(`input`,{className:`small-input`,value:u,onChange:e=>d(e.target.value),type:`number`,min:`1`,max:`200`})]}),(0,H.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,disabled:x,children:[x?(0,H.jsx)(te,{size:15,className:`spin`}):(0,H.jsx)(it,{size:15}),` `,t(`common.search`)]})]})}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:t(`common.id`)}),(0,H.jsx)(`th`,{children:t(`verification.target`)}),(0,H.jsx)(`th`,{children:t(`verification.applicant`)}),(0,H.jsx)(`th`,{children:t(`verification.category`)}),(0,H.jsx)(`th`,{children:t(`common.status`)}),(0,H.jsx)(`th`,{children:t(`verification.submittedAt`)}),(0,H.jsx)(`th`,{children:t(`verification.reviewer`)}),(0,H.jsx)(`th`,{})]})}),(0,H.jsxs)(`tbody`,{children:[f.map(n=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{className:`mono`,children:(0,H.jsxs)(`button`,{className:`row-link`,type:`button`,onClick:()=>e(`/verification/${n.ID}`),children:[`#`,n.ID]})}),(0,H.jsxs)(`td`,{children:[(0,H.jsx)(`strong`,{children:fa(n)}),(0,H.jsxs)(`div`,{className:`entity-subtitle mono`,children:[t(`verification.type.${n.TargetType}`),` · `,n.TargetID]}),n.TargetVerified&&(0,H.jsxs)(J,{tone:`good`,children:[(0,H.jsx)(F,{size:12}),` `,t(`verification.alreadyVerified`)]})]}),(0,H.jsxs)(`td`,{children:[W(n.ApplicantUsername)||n.ApplicantName||`-`,(0,H.jsx)(`div`,{className:`entity-subtitle mono`,children:n.ApplicantUserID})]}),(0,H.jsx)(`td`,{children:n.Category||`-`}),(0,H.jsx)(`td`,{children:(0,H.jsx)(la,{status:n.Status})}),(0,H.jsx)(`td`,{children:G(n.SubmittedAt)||`-`}),(0,H.jsx)(`td`,{children:n.ReviewerAdminID||`-`}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`button`,{className:`row-link`,type:`button`,onClick:()=>e(`/verification/${n.ID}`),children:[(0,H.jsx)(lt,{size:14}),` `,t(`common.detail`),` `,(0,H.jsx)(ve,{size:14})]})})]},n.ID)),f.length===0&&(0,H.jsx)(sn,{colSpan:8})]})]})}),_&&(0,H.jsx)(`div`,{className:`toolbar`,children:(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>T(!0),disabled:x,children:[x?(0,H.jsx)(te,{size:15,className:`spin`}):(0,H.jsx)(ge,{size:15}),` `,t(`common.loadMore`)]})})]})}function la({status:e}){let{t}=U();return(0,H.jsx)(J,{tone:ua(e),children:t(`verification.status.${e}`)})}function ua(e){return e===`approved`?`good`:e===`submitted`||e===`in_review`?`warn`:e===`rejected`?`danger`:`neutral`}function da(e,t){return e===`submitted`||e===`in_review`?t!==`0`&&t!==``?`warn`:`neutral`:e===`approved`?`good`:`neutral`}function fa(e){return W(e.TargetUsername)||e.TargetTitle||`#${e.TargetID}`}function pa(e){return e.TargetType===`bot`?`/bots/${e.TargetID}`:e.TargetType===`user`?`/accounts/${e.TargetID}`:`/channels/${e.TargetID}`}function ma({id:e,navigate:t}){let{t:n}=U(),{can:r}=vn(),[i,a]=(0,g.useState)(null),[o,s]=(0,g.useState)(``),[c,l]=(0,g.useState)(!1),[u,d]=(0,g.useState)(!1),[f,p]=(0,g.useState)(``);async function m(){d(!0),p(``);try{a(await k.verificationApplication(e))}catch(e){p(O(e))}finally{d(!1)}}function h(){l(!1),m()}(0,g.useEffect)(()=>{m()},[e]);function _(e){if(e instanceof v&&e.status===409)return l(!0),m(),n(`verification.conflict`)}if(f&&!i)return(0,H.jsx)(q,{children:f});if(!i)return(0,H.jsx)(cn,{label:n(`verification.loadingDetail`)});let y=i.application,b=i.events??[],x=i.applicant_controls_target,S=i.target_verified,C=y.Status===`submitted`,w=y.Status===`submitted`||y.Status===`in_review`,T=y.Status===`approved`&&r(`verification.revoke`),E=o.trim();function D(){let e={version:y.Version};return E&&(e.internal_note=E),e}function A(){s(``),l(!1),m()}return(0,H.jsxs)(nn,{title:n(`verification.detailTitle`,{id:y.ID}),eyebrow:n(`verification.detailEyebrow`),actions:(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>t(`/verification`),children:[(0,H.jsx)(ce,{size:15}),` `,n(`common.backToList`)]}),(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:h,disabled:u,children:[(0,H.jsx)(nt,{size:15,className:u?`spin`:``}),` `,n(`common.refresh`)]})]}),children:[f&&(0,H.jsx)(q,{children:f}),c&&(0,H.jsx)(q,{children:n(`verification.conflict`)}),(0,H.jsx)(an,{main:(0,H.jsxs)(`div`,{className:`stacked-sections`,children:[(0,H.jsxs)(`section`,{className:`entity-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`entity-title`,children:fa(y)}),(0,H.jsxs)(`div`,{className:`entity-subtitle mono`,children:[`#`,y.ID,` · `,n(`verification.type.${y.TargetType}`),`:`,y.TargetID,` · v`,y.Version]})]}),(0,H.jsxs)(`div`,{className:`entity-badges`,children:[(0,H.jsx)(la,{status:y.Status}),S&&(0,H.jsxs)(J,{tone:`good`,children:[(0,H.jsx)(F,{size:12}),` `,n(`verification.alreadyVerified`)]}),(0,H.jsx)(J,{tone:x?`good`:`danger`,children:n(x?`verification.controlsOk`:`verification.controlsLost`)})]})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:n(`verification.targetSection`),text:n(`verification.targetHint`),action:(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>t(pa(y)),children:[(0,H.jsx)(Ce,{size:15}),` `,n(`verification.openTarget`)]})}),(0,H.jsxs)(`div`,{className:`summary-grid`,children:[(0,H.jsx)(X,{label:n(`common.type`),value:n(`verification.type.${y.TargetType}`)}),(0,H.jsx)(X,{label:n(`common.username`),value:W(y.TargetUsername)||`-`}),(0,H.jsx)(X,{label:n(`verification.targetTitle`),value:y.TargetTitle||`-`}),(0,H.jsx)(X,{label:n(`verification.targetID`),value:y.TargetID,mono:!0})]})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:n(`verification.applicantSection`),text:n(`verification.applicantHint`),action:(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>t(`/accounts/${y.ApplicantUserID}`),children:[(0,H.jsx)(wt,{size:15}),` `,n(`verification.openApplicant`)]})}),(0,H.jsxs)(`div`,{className:`summary-grid`,children:[(0,H.jsx)(X,{label:n(`common.username`),value:W(y.ApplicantUsername)||`-`}),(0,H.jsx)(X,{label:n(`common.name`),value:y.ApplicantName||`-`}),(0,H.jsx)(X,{label:n(`verification.applicantID`),value:y.ApplicantUserID,mono:!0}),(0,H.jsx)(X,{label:n(`verification.submittedAt`),value:G(y.SubmittedAt)||`-`})]}),x?(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`verification.controlsOkHint`)}):(0,H.jsx)(q,{children:n(`verification.controlsLostHint`)})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:n(`verification.applicationSection`),text:n(`verification.applicationHint`)}),(0,H.jsxs)(`div`,{className:`stacked-sections`,children:[(0,H.jsxs)(`div`,{className:`summary-grid`,children:[(0,H.jsx)(X,{label:n(`verification.category`),value:y.Category||`-`}),(0,H.jsx)(X,{label:n(`verification.correlationID`),value:y.CorrelationID||`-`,mono:!0}),(0,H.jsx)(X,{label:n(`verification.createdAt`),value:G(y.CreatedAt)||`-`}),(0,H.jsx)(X,{label:n(`common.updatedAt`),value:G(y.UpdatedAt)||`-`})]}),(0,H.jsx)(ha,{label:n(`verification.description`),children:y.Description?(0,H.jsx)(`p`,{className:`about-text`,children:y.Description}):(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`verification.notProvided`)})}),(0,H.jsx)(ha,{label:n(`verification.officialWebsite`),children:y.OfficialWebsite?(0,H.jsx)(`div`,{className:`about-text`,children:(0,H.jsx)(ga,{value:y.OfficialWebsite})}):(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`verification.notProvided`)})}),(0,H.jsx)(ha,{label:n(`verification.socialLinks`),children:(0,H.jsx)(_a,{values:y.SocialLinks})}),(0,H.jsx)(ha,{label:n(`verification.pressLinks`),children:(0,H.jsx)(_a,{values:y.PressLinks})}),(0,H.jsx)(ha,{label:n(`verification.additionalNote`),children:y.AdditionalNote?(0,H.jsx)(`p`,{className:`about-text`,children:y.AdditionalNote}):(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`verification.notProvided`)})}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`verification.linkSafetyHint`)})]})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:n(`verification.decisionSection`),text:n(`verification.decisionHint`)}),(0,H.jsxs)(`div`,{className:`stacked-sections`,children:[(0,H.jsxs)(`div`,{className:`summary-grid`,children:[(0,H.jsx)(X,{label:n(`verification.reviewer`),value:y.ReviewerAdminID||`-`}),(0,H.jsx)(X,{label:n(`verification.reviewedAt`),value:G(y.ReviewedAt)||`-`}),(0,H.jsx)(X,{label:n(`common.status`),value:n(`verification.status.${y.Status}`)}),(0,H.jsx)(X,{label:n(`verification.version`),value:y.Version,mono:!0})]}),(0,H.jsx)(ha,{label:n(`verification.decisionReason`),children:y.DecisionReason?(0,H.jsx)(`p`,{className:`about-text`,children:y.DecisionReason}):(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`verification.noDecision`)})}),(0,H.jsx)(ha,{label:`${n(`verification.internalNote`)} · ${n(`verification.adminOnly`)}`,children:y.InternalNote?(0,H.jsx)(`p`,{className:`about-text`,children:y.InternalNote}):(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`verification.notProvided`)})})]})]}),(0,H.jsxs)(`section`,{className:`section-block`,children:[(0,H.jsx)(K,{title:n(`verification.eventsSection`),text:n(`verification.eventsHint`)}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:n(`verification.eventKind`)}),(0,H.jsx)(`th`,{children:n(`verification.transition`)}),(0,H.jsx)(`th`,{children:n(`audit.actor`)}),(0,H.jsx)(`th`,{children:n(`audit.reason`)}),(0,H.jsx)(`th`,{children:n(`verification.eventNote`)}),(0,H.jsx)(`th`,{children:n(`common.time`)})]})}),(0,H.jsxs)(`tbody`,{children:[b.map(e=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{children:(0,H.jsx)(va,{kind:e.Kind})}),(0,H.jsxs)(`td`,{className:`mono`,children:[e.FromStatus||`-`,` → `,e.ToStatus||`-`]}),(0,H.jsx)(`td`,{children:e.Actor||`-`}),(0,H.jsx)(`td`,{className:`truncate`,children:e.Reason||`-`}),(0,H.jsx)(`td`,{className:`truncate`,children:e.Note||`-`}),(0,H.jsx)(`td`,{children:G(e.CreatedAt)||`-`})]},e.ID)),b.length===0&&(0,H.jsx)(sn,{colSpan:6})]})]})})]})]}),side:(0,H.jsxs)(`section`,{className:`action-dock`,children:[(0,H.jsx)(`div`,{className:`dock-title`,children:n(`verification.actionDock`)}),!C&&!w&&!T&&(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`verification.noActions`)}),C&&(0,H.jsxs)(H.Fragment,{children:[(0,H.jsx)(`div`,{className:`action-stack`,children:(0,H.jsx)(Z,{label:n(`verification.claim`),icon:(0,H.jsx)(Pe,{size:15}),tone:`neutral`,path:`/api/verification/applications/${y.ID}/claim`,payload:()=>({version:y.Version}),onDone:A,onError:_})}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`verification.claimHint`)})]}),(w||T)&&(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`label`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:n(`verification.internalNote`)}),(0,H.jsx)(`textarea`,{value:o,onChange:e=>s(e.target.value),rows:3,placeholder:n(`verification.internalNotePlaceholder`)})]}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`verification.internalNoteHint`)})]}),w&&(0,H.jsxs)(H.Fragment,{children:[!x&&(0,H.jsx)(q,{children:n(`verification.controlsLostHint`)}),S&&(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`verification.alreadyVerifiedHint`)}),(0,H.jsxs)(`div`,{className:`action-stack`,children:[(0,H.jsx)(Z,{label:n(`verification.approve`),icon:(0,H.jsx)(L,{size:15}),tone:`neutral`,path:`/api/verification/applications/${y.ID}/approve`,payload:D,onDone:A,onError:_}),(0,H.jsx)(Z,{label:n(`verification.reject`),icon:(0,H.jsx)(ee,{size:15}),tone:`warn`,path:`/api/verification/applications/${y.ID}/reject`,payload:D,onDone:A,onError:_})]}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`verification.approveHint`)}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`verification.rejectHint`)})]}),T&&(0,H.jsxs)(H.Fragment,{children:[(0,H.jsxs)(`div`,{className:`dock-title`,children:[(0,H.jsx)(ut,{size:14}),` `,n(`verification.dangerZone`)]}),(0,H.jsxs)(`div`,{className:`danger-zone`,children:[(0,H.jsx)(Z,{label:n(`verification.revoke`),icon:(0,H.jsx)(z,{size:15}),tone:`danger`,path:`/api/actions/revoke-verification`,payload:()=>{let e={target_type:y.TargetType,target_id:y.TargetID};return E&&(e.internal_note=E),e},onDone:A,onError:_}),(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`verification.revokeHint`)}),!S&&(0,H.jsx)(`p`,{className:`bot-create-note`,children:n(`verification.revokeNotVerified`)})]})]})]})})]})}function ha({label:e,children:t}){return(0,H.jsxs)(`div`,{className:`duration-field`,children:[(0,H.jsx)(`span`,{children:e}),t]})}function ga({value:e}){let t=Vt(e);return t?(0,H.jsxs)(`a`,{className:`row-link`,href:t,target:`_blank`,rel:`noopener noreferrer`,children:[e,` `,(0,H.jsx)(Ce,{size:13})]}):(0,H.jsx)(`span`,{className:`mono`,children:e})}function _a({values:e}){let{t}=U(),n=(e??[]).filter(e=>e.trim()!==``);return n.length===0?(0,H.jsx)(`p`,{className:`bot-create-note`,children:t(`verification.notProvided`)}):(0,H.jsx)(`div`,{className:`about-text`,children:n.map((e,t)=>(0,H.jsx)(`div`,{children:(0,H.jsx)(ga,{value:e})},`${t}-${e}`))})}function va({kind:e}){let{t}=U();return(0,H.jsx)(J,{tone:e===`approved`?`good`:e===`rejected`||e===`revoked`||e===`cancelled`?`danger`:e===`submitted`||e===`claimed`?`warn`:`neutral`,children:t(`verification.kind.${e}`)})}function ya(){let{t:e}=U(),[t,n]=(0,g.useState)(null),[r,i]=(0,g.useState)(!1),[a,o]=(0,g.useState)(``);async function s(){i(!0),o(``);try{n(await k.storageStats())}catch(e){o(O(e))}finally{i(!1)}}(0,g.useEffect)(()=>{s()},[]);let c=t?(BigInt(t.LogicalBytes||`0`)-BigInt(t.PhysicalBytes||`0`)).toString():`0`;return(0,H.jsxs)(nn,{title:e(`storage.title`),eyebrow:e(`storage.eyebrow`),actions:(0,H.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>void s(),disabled:r,children:[(0,H.jsx)(nt,{size:15,className:r?`spin`:``}),` `,e(`common.refresh`)]}),children:[a&&(0,H.jsx)(q,{children:a}),(0,H.jsxs)(`div`,{className:`metric-row`,children:[(0,H.jsx)(Y,{label:e(`storage.physical`),value:t?Gt(t.PhysicalBytes):`-`}),(0,H.jsx)(Y,{label:e(`storage.logical`),value:t?Gt(t.LogicalBytes):`-`}),(0,H.jsx)(Y,{label:e(`storage.dedupSaved`),value:Gt(c),tone:BigInt(c)>0n?`good`:`neutral`}),(0,H.jsx)(Y,{label:e(`storage.objects`),value:t?Wt(t.ObjectCount):`-`})]}),(0,H.jsxs)(`div`,{className:`metric-row`,children:[(0,H.jsx)(Y,{label:e(`storage.references`),value:t?Wt(t.ReferenceCount):`-`}),(0,H.jsx)(Y,{label:e(`storage.documents`),value:t?Wt(t.DocumentCount):`-`}),(0,H.jsx)(Y,{label:e(`storage.photos`),value:t?Wt(t.PhotoCount):`-`})]}),(0,H.jsx)(q,{children:e(`storage.retentionBlocked`)}),(0,H.jsx)(`div`,{className:`table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:e(`storage.backend`)}),(0,H.jsx)(`th`,{children:e(`storage.physical`)}),(0,H.jsx)(`th`,{children:e(`storage.logical`)}),(0,H.jsx)(`th`,{children:e(`storage.objects`)}),(0,H.jsx)(`th`,{children:e(`storage.references`)})]})}),(0,H.jsxs)(`tbody`,{children:[(t?.Backends??[]).map(e=>(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`td`,{className:`mono`,children:e.Backend}),(0,H.jsx)(`td`,{className:`mono`,children:Gt(e.PhysicalBytes)}),(0,H.jsx)(`td`,{className:`mono`,children:Gt(e.LogicalBytes)}),(0,H.jsx)(`td`,{className:`mono`,children:Wt(e.ObjectCount)}),(0,H.jsx)(`td`,{className:`mono`,children:Wt(e.ReferenceCount)})]},e.Backend)),(t?.Backends??[]).length===0&&(0,H.jsx)(sn,{colSpan:5})]})]})})]})}function ba({documentID:e,className:t=``,showError:n=!0}){let r=(0,g.useRef)(null),i=(0,g.useRef)(null),[a,o]=(0,g.useState)(``),[s,c]=(0,g.useState)(null);return(0,g.useEffect)(()=>{let t=!1,n=null;return o(``),c(null),fetch(k.stickerDocumentAnimationURL(e),{credentials:`same-origin`}).then(async e=>{if(!e.ok){let t=await e.json().catch(()=>null);throw Error(t?.error||e.statusText)}if((e.headers.get(`content-type`)??``).includes(`json`)){let n=await e.json();if(t||!r.current)return;i.current?.destroy(),i.current=Fr.default.loadAnimation({container:r.current,renderer:`canvas`,loop:!0,autoplay:!0,animationData:n});return}let a=await e.blob();t||(n=URL.createObjectURL(a),c(n))}).catch(e=>{t||o(O(e))}),()=>{t=!0,i.current?.destroy(),i.current=null,n&&URL.revokeObjectURL(n)}},[e]),(0,H.jsxs)(`div`,{className:`sticker-doc-cell ${t}`.trim(),children:[s?(0,H.jsx)(`img`,{className:`sticker-doc-image`,src:s,alt:``}):(0,H.jsx)(`div`,{className:`sticker-doc-canvas`,ref:r}),a&&n&&(0,H.jsx)(`span`,{className:`sticker-doc-error`,children:a})]})}function xa({kind:e,onClose:t,onCreated:n}){let{t:r}=U(),[i,a]=(0,g.useState)(``),[o,s]=(0,g.useState)(``),[c,l]=(0,g.useState)(``),[u,d]=(0,g.useState)(null),[f,p]=(0,g.useState)(``),[m,h]=(0,g.useState)(!1),[_,v]=(0,g.useState)(``);async function y(){if(!i.trim()||!o.trim()||!c.trim()||!u){v(r(e===`emoji`?`stickers.emojiRequired`:`stickers.stickerRequired`));return}if(!f.trim()){v(r(`action.reasonRequired`));return}h(!0),v(``);try{let r=new FormData;r.set(`metadata`,JSON.stringify({command_id:``,reason:f.trim(),confirm:!0,title:i.trim(),short_name:o.trim().toLowerCase(),kind:e,emoji:c.trim()})),r.set(`file`,u,u.name),await k.createStickerSet(r),n(),t()}catch(e){v(O(e))}finally{h(!1)}}return(0,Ln.createPortal)((0,H.jsx)(`div`,{className:`modal-backdrop`,role:`presentation`,children:(0,H.jsxs)(`section`,{className:`modal command-modal`,role:`dialog`,"aria-modal":`true`,"aria-label":r(e===`emoji`?`stickers.createEmojiPack`:`stickers.createStickerPack`),children:[(0,H.jsxs)(`div`,{className:`modal-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`eyebrow`,children:r(`stickers.newSet`)}),(0,H.jsx)(`h2`,{children:r(e===`emoji`?`stickers.createEmojiPack`:`stickers.createStickerPack`)})]}),(0,H.jsx)(`button`,{className:`icon-btn`,type:`button`,onClick:t,disabled:m,"aria-label":r(`common.close`),children:(0,H.jsx)(Dt,{size:15})})]}),(0,H.jsxs)(`div`,{className:`command-body`,children:[(0,H.jsxs)(`div`,{className:`gift-fields-grid`,children:[(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`common.title`)}),(0,H.jsx)(`input`,{value:i,maxLength:64,onChange:e=>a(e.target.value)})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`common.shortName`)}),(0,H.jsx)(`input`,{value:o,maxLength:32,onChange:e=>s(e.target.value),placeholder:`lowercase_short_name`})]}),(0,H.jsxs)(`label`,{children:[(0,H.jsx)(`span`,{children:r(`route.emoji`)}),(0,H.jsx)(`input`,{value:c,onChange:e=>l(e.target.value),placeholder:`e.g. 😀`})]})]}),(0,H.jsxs)(`label`,{className:`gift-file-picker ${u?`has-file`:``}`,children:[(0,H.jsx)(`input`,{type:`file`,accept:`.tgs,.json,.webp,application/json,application/x-tgsticker,image/webp`,onChange:e=>d(e.target.files?.[0]??null)}),(0,H.jsxs)(`span`,{className:`gift-file-copy`,children:[(0,H.jsx)(`span`,{className:`gift-field-label`,children:r(e===`emoji`?`stickers.firstEmoji`:`stickers.firstSticker`)}),(0,H.jsx)(`strong`,{children:u?u.name:r(`stickers.chooseMedia`)})]}),(0,H.jsx)(`span`,{className:`gift-file-action`,children:r(u?`common.changeFile`:`common.chooseFile`)})]}),(0,H.jsxs)(`label`,{className:`gift-reason-field`,children:[(0,H.jsx)(`span`,{children:r(`action.reason`)}),(0,H.jsx)(`input`,{value:f,placeholder:r(`stickers.createReasonPlaceholder`),onChange:e=>p(e.target.value)})]}),_&&(0,H.jsx)(q,{children:_})]}),(0,H.jsxs)(`div`,{className:`modal-actions`,children:[(0,H.jsx)(`button`,{className:`btn`,type:`button`,onClick:t,disabled:m,children:r(`common.close`)}),(0,H.jsxs)(`button`,{className:`btn primary`,type:`button`,onClick:y,disabled:m,children:[m?(0,H.jsx)(te,{className:`spin`,size:15}):(0,H.jsx)(St,{size:15}),r(e===`emoji`?`stickers.createEmojiPack`:`stickers.createStickerPack`)]})]})]})}),document.body)}var Sa=24;function Ca({set:e,maxItems:t,onClose:n}){let{t:r}=U(),[i,a]=(0,g.useState)(null),[o,s]=(0,g.useState)(``),[c,l]=(0,g.useState)(1),u=(0,g.useCallback)(()=>{let t=!1;return s(``),k.stickerSetDocuments(e.ID).then(e=>{t||a(e.document_ids??[])}).catch(e=>{t||s(O(e))}),()=>{t=!0}},[e.ID]);(0,g.useEffect)(()=>(a(null),l(1),u()),[u]);let d=i?.length??0,f=Math.max(1,Math.ceil(d/Sa)),p=Math.min(c,f),m=(p-1)*Sa,h=i?.slice(m,m+Sa)??[],_=h.length===0?0:m+1,v=_===0?0:_+h.length-1,y=i?.length??e.Count,b=t>0&&y>=t;return(0,Ln.createPortal)((0,H.jsx)(`div`,{className:`modal-backdrop`,role:`presentation`,children:(0,H.jsxs)(`section`,{className:`modal command-modal sticker-preview-modal`,role:`dialog`,"aria-modal":`true`,"aria-label":e.Title||`#${e.ID}`,children:[(0,H.jsxs)(`div`,{className:`modal-head`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`eyebrow`,children:r(`stickers.setContents`)}),(0,H.jsx)(`h2`,{children:e.Title||`#${e.ID}`})]}),(0,H.jsx)(`button`,{className:`icon-btn`,type:`button`,onClick:n,"aria-label":r(`common.close`),children:(0,H.jsx)(Dt,{size:15})})]}),(0,H.jsxs)(`div`,{className:`command-body`,children:[b?(0,H.jsx)(q,{children:r(`stickers.packFull`,{count:y,max:t})}):(0,H.jsx)(Ta,{setID:e.ID,kind:e.Kind===`emoji`?`emoji`:`stickers`,onAdded:u}),o&&(0,H.jsx)(q,{children:o}),!o&&i===null&&(0,H.jsxs)(`div`,{className:`loading-line`,children:[(0,H.jsx)(te,{className:`spin`,size:18}),` `,r(`common.loading`)]}),i!==null&&d===0&&!o&&(0,H.jsx)(`div`,{className:`empty-panel`,children:r(`stickers.emptySet`)}),h.length>0&&(0,H.jsx)(`div`,{className:`sticker-doc-grid`,children:h.map(t=>(0,H.jsxs)(`div`,{className:`sticker-doc-grid-cell`,children:[(0,H.jsx)(ba,{documentID:t}),(0,H.jsx)(wa,{documentID:t}),(0,H.jsx)(Z,{compact:!0,tone:`danger`,label:r(`common.remove`),icon:(0,H.jsx)(yt,{size:12}),path:`/api/actions/remove-sticker-from-set`,payload:()=>({set_id:e.ID,document_id:t}),onDone:u})]},t))},p),d>Sa&&(0,H.jsxs)(`div`,{className:`gift-pager`,children:[(0,H.jsx)(`span`,{className:`gift-pager-range`,children:r(`common.showingRange`,{start:_,end:v,total:d})}),(0,H.jsxs)(`div`,{className:`gift-pager-controls`,children:[(0,H.jsxs)(`button`,{className:`btn compact-btn`,type:`button`,onClick:()=>l(e=>Math.max(1,e-1)),disabled:p<=1,children:[(0,H.jsx)(_e,{size:14}),` `,r(`common.previous`)]}),(0,H.jsx)(`span`,{className:`gift-pager-page`,children:r(`common.pageOf`,{current:p,total:f})}),(0,H.jsxs)(`button`,{className:`btn compact-btn`,type:`button`,onClick:()=>l(e=>Math.min(f,e+1)),disabled:p>=f,children:[r(`common.next`),` `,(0,H.jsx)(ve,{size:14})]})]})]})]})]})}),document.body)}function wa({documentID:e}){let{t}=U(),[n,r]=(0,g.useState)(!1);async function i(){try{await navigator.clipboard.writeText(e),r(!0),setTimeout(()=>r(!1),1200)}catch{}}return(0,H.jsxs)(`button`,{className:`emoji-id`,type:`button`,onClick:i,title:t(`emoji.copyID`),children:[(0,H.jsx)(`span`,{className:`mono`,children:e}),n?(0,H.jsx)(he,{size:12}):(0,H.jsx)(be,{size:12})]})}function Ta({setID:e,kind:t,onAdded:n}){let{t:r}=U(),[i,a]=(0,g.useState)(null),[o,s]=(0,g.useState)(``),[c,l]=(0,g.useState)(``),[u,d]=(0,g.useState)(!1),[f,p]=(0,g.useState)(``);async function m(){if(!i){p(r(t===`emoji`?`stickers.chooseEmojiFirst`:`stickers.chooseStickerFirst`));return}if(!o.trim()){p(r(`stickers.emojiValueRequired`));return}if(!c.trim()){p(r(`action.reasonRequired`));return}d(!0),p(``);try{let t=new FormData;t.set(`metadata`,JSON.stringify({command_id:``,reason:c.trim(),confirm:!0,set_id:e,emoji:o.trim()})),t.set(`file`,i,i.name),await k.addStickerToSet(t),a(null),s(``),l(``),n()}catch(e){p(O(e))}finally{d(!1)}}return(0,H.jsxs)(`div`,{className:`sticker-add-form`,children:[(0,H.jsxs)(`label`,{className:`gift-file-picker compact ${i?`has-file`:``}`,children:[(0,H.jsx)(`input`,{type:`file`,accept:`.tgs,.json,.webp,application/json,application/x-tgsticker,image/webp`,onChange:e=>a(e.target.files?.[0]??null)}),(0,H.jsx)(`span`,{className:`gift-file-copy`,children:(0,H.jsx)(`strong`,{children:i?i.name:r(`stickers.chooseMedia`)})})]}),(0,H.jsx)(`input`,{className:`small-input`,value:o,onChange:e=>s(e.target.value),placeholder:`e.g. 😀`}),(0,H.jsx)(`input`,{className:`small-input`,value:c,onChange:e=>l(e.target.value),placeholder:r(`action.reasonPlaceholder`)}),(0,H.jsxs)(`button`,{className:`btn primary compact-btn`,type:`button`,onClick:m,disabled:u,children:[u?(0,H.jsx)(te,{className:`spin`,size:14}):(0,H.jsx)(Qe,{size:14}),` `,r(t===`emoji`?`stickers.addEmoji`:`stickers.addSticker`)]}),f&&(0,H.jsx)(`span`,{className:`sticker-add-form-error`,children:f})]})}function Ea({kind:e,navigate:t}){let{t:n}=U(),[r,i]=(0,g.useState)([]),[a,o]=(0,g.useState)(``),[s,c]=(0,g.useState)(!1),[l,u]=(0,g.useState)(``),[d,f]=(0,g.useState)(10),[p,m]=(0,g.useState)(1),[h,_]=(0,g.useState)({}),[v,y]=(0,g.useState)({}),[b,x]=(0,g.useState)(null),[S,C]=(0,g.useState)(!1),[w,T]=(0,g.useState)(0),E=n(e===`emoji`?`emoji.pageTitle`:`route.stickers`),D=n(e===`emoji`?`stickers.emojiEyebrow`:`stickers.stickerEyebrow`);async function A(){c(!0),u(``);try{let t=await k.stickerSets(e);i(t.rows??[]),T(t.max_items??0)}catch(e){u(O(e))}finally{c(!1)}}(0,g.useEffect)(()=>{A()},[e]);let j=(0,g.useMemo)(()=>{let e=a.trim().toLowerCase();return e?r.filter(t=>String(t.ID).includes(e)||t.ShortName.toLowerCase().includes(e)||t.Title.toLowerCase().includes(e)):r},[r,a]);(0,g.useEffect)(()=>{m(1)},[a,d,e]);let M=d===`all`?1:Math.max(1,Math.ceil(j.length/d)),N=Math.min(p,M),P=(0,g.useMemo)(()=>{if(d===`all`)return j;let e=(N-1)*d;return j.slice(e,e+d)},[j,N,d]),F=P.length===0?0:d===`all`?1:(N-1)*d+1,I=F===0?0:F+P.length-1,L=(0,g.useMemo)(()=>({total:r.length,official:r.filter(e=>e.Official).length,archived:r.filter(e=>e.Archived).length}),[r]);return(0,H.jsxs)(nn,{title:E,eyebrow:D,actions:(0,H.jsxs)(H.Fragment,{children:[e===`emoji`&&(0,H.jsxs)(`button`,{className:`btn`,type:`button`,onClick:()=>t(`/emoji/documents`),children:[(0,H.jsx)(De,{size:15}),` `,n(`emoji.documentCatalog`)]}),(0,H.jsxs)(`button`,{className:`btn`,type:`button`,onClick:()=>A(),disabled:s,children:[(0,H.jsx)(nt,{size:15}),` `,n(`common.refresh`)]}),(0,H.jsxs)(`button`,{className:`btn primary`,type:`button`,onClick:()=>C(!0),children:[(0,H.jsx)(Qe,{size:15}),` `,n(e===`emoji`?`stickers.createEmojiPack`:`stickers.createStickerPack`)]})]}),children:[l&&(0,H.jsx)(q,{children:l}),(0,H.jsxs)(`div`,{className:`metric-row`,children:[(0,H.jsx)(Y,{label:n(`stickers.totalSets`),value:String(L.total)}),(0,H.jsx)(Y,{label:n(`common.official`),value:String(L.official),tone:`good`}),(0,H.jsx)(Y,{label:n(`common.archived`),value:String(L.archived),tone:L.archived>0?`warn`:`neutral`})]}),(0,H.jsx)(rn,{children:(0,H.jsxs)(`div`,{className:`toolbar`,children:[(0,H.jsxs)(`label`,{className:`searchbox`,children:[(0,H.jsx)(it,{size:15}),(0,H.jsx)(`input`,{value:a,onChange:e=>o(e.target.value),placeholder:n(`stickers.searchPlaceholder`)})]}),(0,H.jsxs)(`label`,{className:`gift-page-size`,children:[(0,H.jsx)(`span`,{children:n(`common.perPage`)}),(0,H.jsxs)(`select`,{value:String(d),onChange:e=>f(e.target.value===`all`?`all`:Number(e.target.value)),children:[(0,H.jsx)(`option`,{value:`10`,children:`10`}),(0,H.jsx)(`option`,{value:`20`,children:`20`}),(0,H.jsx)(`option`,{value:`50`,children:`50`}),(0,H.jsx)(`option`,{value:`100`,children:`100`}),(0,H.jsx)(`option`,{value:`all`,children:n(`common.all`)})]})]}),(0,H.jsx)(`span`,{className:`gift-list-summary`,children:n(`common.showing`,{visible:j.length,total:r.length})})]})}),(0,H.jsx)(`div`,{className:`table-wrap gift-table-wrap`,children:(0,H.jsxs)(`table`,{className:`data-table`,children:[(0,H.jsx)(`thead`,{children:(0,H.jsxs)(`tr`,{children:[(0,H.jsx)(`th`,{children:n(`common.logo`)}),(0,H.jsx)(`th`,{children:`ID`}),(0,H.jsx)(`th`,{children:n(`common.shortName`)}),(0,H.jsx)(`th`,{children:n(`common.title`)}),(0,H.jsx)(`th`,{children:n(`common.documents`)}),(0,H.jsx)(`th`,{children:n(`common.official`)}),(0,H.jsx)(`th`,{children:n(`common.status`)}),(0,H.jsx)(`th`,{children:n(`common.sortOrder`)}),(0,H.jsx)(`th`,{children:n(`common.actions`)})]})}),(0,H.jsxs)(`tbody`,{children:[P.map(e=>(0,H.jsxs)(`tr`,{className:e.Archived?`gift-row-disabled`:``,children:[(0,H.jsx)(`td`,{children:e.CoverDocumentID?(0,H.jsx)(ba,{documentID:e.CoverDocumentID,className:`list-thumb`,showError:!1}):(0,H.jsx)(`div`,{className:`sticker-list-thumb-empty`,children:(0,H.jsx)(Le,{size:14})})}),(0,H.jsx)(`td`,{className:`mono`,children:e.ID}),(0,H.jsx)(`td`,{className:`mono`,children:e.ShortName||(0,H.jsx)(`span`,{className:`muted-cell`,children:n(`common.none`)})}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`div`,{className:`sort-order-editor`,children:[(0,H.jsx)(`input`,{className:`small-input title-input`,value:v[e.ID]??e.Title,onChange:t=>y(n=>({...n,[e.ID]:t.target.value}))}),(0,H.jsx)(Z,{compact:!0,tone:`neutral`,label:n(`common.save`),path:`/api/actions/rename-sticker-set`,payload:()=>({set_id:e.ID,title:(v[e.ID]??e.Title).trim()}),onDone:()=>void A()})]})}),(0,H.jsx)(`td`,{children:e.Count}),(0,H.jsx)(`td`,{children:e.Official?(0,H.jsx)(J,{tone:`good`,children:n(`common.yes`)}):(0,H.jsx)(J,{children:n(`common.no`)})}),(0,H.jsx)(`td`,{children:e.Archived?(0,H.jsx)(J,{tone:`danger`,children:n(`common.archived`)}):(0,H.jsx)(J,{tone:`good`,children:n(`common.enabled`)})}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`div`,{className:`sort-order-editor`,children:[(0,H.jsx)(`input`,{type:`number`,className:`small-input`,value:h[e.ID]??String(e.SortOrder),onChange:t=>_(n=>({...n,[e.ID]:t.target.value}))}),(0,H.jsx)(Z,{compact:!0,tone:`neutral`,label:n(`common.save`),path:`/api/actions/set-sticker-set-sort-order`,payload:()=>({set_id:e.ID,sort_order:Number(h[e.ID]??e.SortOrder)}),onDone:()=>void A()})]})}),(0,H.jsx)(`td`,{children:(0,H.jsxs)(`div`,{className:`gift-table-actions`,children:[(0,H.jsxs)(`button`,{className:`btn compact-btn`,type:`button`,onClick:()=>x(e),children:[(0,H.jsx)(we,{size:13}),` `,n(`common.view`)]}),(0,H.jsx)(Z,{compact:!0,tone:`neutral`,label:e.Archived?n(`common.unarchive`):n(`common.archive`),path:`/api/actions/set-sticker-set-archived`,payload:()=>({set_id:e.ID,archived:!e.Archived}),onDone:()=>void A()}),(0,H.jsx)(Z,{compact:!0,tone:`danger`,label:n(`common.delete`),path:`/api/actions/delete-sticker-set`,payload:()=>({set_id:e.ID}),onDone:()=>void A()})]})})]},e.ID)),P.length===0&&(0,H.jsx)(sn,{colSpan:9})]})]})}),d!==`all`&&j.length>0&&(0,H.jsxs)(`div`,{className:`gift-pager`,children:[(0,H.jsx)(`span`,{className:`gift-pager-range`,children:n(`common.showingRange`,{start:F,end:I,total:j.length})}),(0,H.jsxs)(`div`,{className:`gift-pager-controls`,children:[(0,H.jsxs)(`button`,{className:`btn compact-btn`,type:`button`,onClick:()=>m(e=>Math.max(1,e-1)),disabled:N<=1,children:[(0,H.jsx)(_e,{size:14}),` `,n(`common.previous`)]}),(0,H.jsx)(`span`,{className:`gift-pager-page`,children:n(`common.pageOf`,{current:N,total:M})}),(0,H.jsxs)(`button`,{className:`btn compact-btn`,type:`button`,onClick:()=>m(e=>Math.min(M,e+1)),disabled:N>=M,children:[n(`common.next`),` `,(0,H.jsx)(ve,{size:14})]})]})]}),b&&(0,H.jsx)(Ca,{set:b,maxItems:w,onClose:()=>x(null)}),S&&(0,H.jsx)(xa,{kind:e,onClose:()=>C(!1),onCreated:()=>void A()})]})}function Da({route:e,navigate:t}){let n=e.path.match(/^\/accounts\/(\d+)$/)?.[1],r=e.path.match(/^\/channels\/(\d+)$/)?.[1],i=e.path.match(/^\/bots\/(\d+)$/)?.[1],a=e.path.match(/^\/moderation\/(\d+)$/)?.[1],o=e.path.match(/^\/collectible-usernames\/(\d+)$/)?.[1],s=e.path.match(/^\/account-ratings\/(\d+)$/)?.[1],c=e.path.match(/^\/verification\/(\d+)$/)?.[1],l=e.path.match(/^\/bot-verification\/(\d+)$/)?.[1];return l?(0,H.jsx)(bn,{permission:mn,children:(0,H.jsx)(ra,{id:l,navigate:t})}):e.path===`/bot-verification`?(0,H.jsx)(bn,{permission:mn,children:(0,H.jsx)(Ki,{navigate:t})}):c?(0,H.jsx)(bn,{permission:pn,children:(0,H.jsx)(ma,{id:c,navigate:t})}):e.path===`/verification`?(0,H.jsx)(bn,{permission:pn,children:(0,H.jsx)(ca,{navigate:t})}):o?(0,H.jsx)(Q,{id:o,navigate:t}):s?(0,H.jsx)(ar,{userID:s,navigate:t}):e.path===`/collectible-usernames`?(0,H.jsx)(hr,{navigate:t}):e.path===`/collectible-phones`?(0,H.jsx)(Sr,{}):e.path===`/account-ratings`?(0,H.jsx)(tr,{navigate:t}):e.path===`/storage`?(0,H.jsx)(ya,{}):e.path===`/monetization`||e.path===`/premium`?(0,H.jsx)(bn,{permission:dn,children:(0,H.jsx)(Ui,{})}):n?(0,H.jsx)($n,{id:Number(n),navigate:t}):r?(0,H.jsx)(wr,{id:Number(r),navigate:t}):i?(0,H.jsx)(Or,{id:Number(i),navigate:t}):a?(0,H.jsx)(Ii,{id:Number(a),navigate:t}):e.path===`/accounts`?(0,H.jsx)(dr,{navigate:t}):e.path===`/channels`?(0,H.jsx)(Er,{navigate:t}):e.path===`/bots`?(0,H.jsx)(Ar,{navigate:t}):e.path===`/broadcasts`?(0,H.jsx)(Pr,{}):e.path===`/moderation`?(0,H.jsx)(ji,{navigate:t}):e.path===`/emoji/documents`?(0,H.jsx)(Vr,{navigate:t}):e.path===`/emoji`?(0,H.jsx)(Ea,{kind:`emoji`,navigate:t}):e.path===`/stickers`?(0,H.jsx)(Ea,{kind:`stickers`,navigate:t}):e.path===`/gif-catalog`?(0,H.jsx)(Ur,{}):e.path===`/gifts`?(0,H.jsx)(vi,{}):e.path===`/give-gifts`?(0,H.jsx)(Oi,{}):e.path===`/auctions`?(0,H.jsx)(Si,{}):e.path===`/messages/detail`||e.path===`/messages/private/detail`?(0,H.jsx)(Qr,{ownerUserID:Number(e.search.get(`owner_user_id`)||`0`),msgID:Number(e.search.get(`msg_id`)||`0`),navigate:t}):e.path===`/messages/groups/detail`?(0,H.jsx)(Xr,{channelID:Number(e.search.get(`channel_id`)||`0`),msgID:Number(e.search.get(`msg_id`)||`0`),navigate:t}):e.path===`/messages/groups`?(0,H.jsx)(Zr,{navigate:t}):e.path===`/messages`||e.path===`/messages/private`?(0,H.jsx)($r,{navigate:t}):(0,H.jsx)(Gr,{navigate:t})}function Oa(){let[e,t]=(0,g.useState)(void 0),[n,r]=(0,g.useState)(()=>Sn());(0,g.useEffect)(()=>{let e=()=>r(Sn());return window.addEventListener(`popstate`,e),()=>window.removeEventListener(`popstate`,e)},[]),(0,g.useEffect)(()=>{k.session().then(e=>t(e)).catch(()=>t(null))},[]);let i=e=>{window.history.pushState(null,``,e),r(Sn())};return e===void 0?(0,H.jsx)(Nn,{}):e===null?(0,H.jsx)(In,{onLogin:t}):(0,H.jsx)(_n,{permissions:e.permissions??[],children:(0,H.jsx)(Pn,{actor:e.actor,route:n,navigate:i,onLogout:()=>t(null),children:(0,H.jsx)(Da,{route:n,navigate:i})})})}_.createRoot(document.getElementById(`root`)).render((0,H.jsx)(g.StrictMode,{children:(0,H.jsx)(On,{children:(0,H.jsx)(Mt,{children:(0,H.jsx)(Oa,{})})})})); \ No newline at end of file diff --git a/cmd/telesrv-admin/web/dist/assets/index-y9w88zN5.css b/cmd/telesrv-admin/web/dist/assets/index-y9w88zN5.css new file mode 100644 index 00000000..2c4435cb --- /dev/null +++ b/cmd/telesrv-admin/web/dist/assets/index-y9w88zN5.css @@ -0,0 +1 @@ +:root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;--bg:#eef1f5;--bg-accent:#e7ecf1;--panel:#fff;--panel-subtle:#f5f8fb;--panel-strong:#eef2f6;--surface-soft:#f2f7f6;--overlay:#18222f6b;--topbar-bg:#ffffffdb;--line:#e5eaf0;--line-strong:#d3dce4;--heading:#253040;--text:#333f4d;--text-soft:#45525f;--muted:#6d7885;--muted-2:#9aa4b1;--brand:#1f7d6f;--brand-strong:#196155;--brand-2:#3a6cae;--brand-tint:#e8f4f0;--brand-tint-border:#c8e2db;--brand-tint-text:#235d53;--good:#1f8a57;--good-tint:#eaf6ef;--good-border:#c1e1cf;--warn:#a86a12;--warn-tint:#fcf4e4;--warn-border:#e7d09e;--danger:#c0392b;--danger-tint:#fcefec;--danger-border:#eecac3;--danger-text:#8f2f27;--purple:#6a4fa3;--purple-tint:#f4effb;--purple-border:#dcd0f0;--purple-text:#5a4590;--input-bg:#fff;--btn-bg:#fff;--btn-text:#29323d;--btn-hover:#f4f7fa;--switch-track:#c8d0d6;--code-bg:#1b2733;--code-text:#d6e3ef;--code-border:#2b3a49;--sidebar:#1c2530;--sidebar-soft:#26313d;--sidebar-line:#313c4a;--sidebar-row:#232d38;--sidebar-text:#dbe3ec;--sidebar-muted:#8b98a8;--sidebar-faint:#7c8a9a;--sidebar-heading:#fff;--focus:#1f7d6f29;--shadow:0 12px 34px #1827381a;--shadow-sm:0 2px 10px #1827380d;--shadow-brand:0 8px 22px #1f7d6f38;--radius-xs:8px;--radius-sm:9px;--radius:11px;--radius-lg:14px}[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;--bg:#0f141a;--bg-accent:#131a22;--panel:#171f28;--panel-subtle:#1c2530;--panel-strong:#212c38;--surface-soft:#1a232d;--overlay:#05080c9e;--topbar-bg:#151c24db;--line:#29333f;--line-strong:#38434f;--heading:#eef3f8;--text:#d5dde6;--text-soft:#c2ccd6;--muted:#98a4b1;--muted-2:#6d7885;--brand:#37a596;--brand-strong:#45b6a6;--brand-2:#6fa8e6;--brand-tint:#14322d;--brand-tint-border:#245349;--brand-tint-text:#7fd3c4;--good:#47c281;--good-tint:#12301f;--good-border:#245639;--warn:#e0aa4d;--warn-tint:#322810;--warn-border:#574413;--danger:#e6695c;--danger-tint:#35201d;--danger-border:#5c332d;--danger-text:#f0a49b;--purple:#ac90e2;--purple-tint:#221b31;--purple-border:#3d3357;--purple-text:#c9b6ef;--input-bg:#131a22;--btn-bg:#1e2731;--btn-text:#dbe2ea;--btn-hover:#26313d;--switch-track:#3a454f;--code-bg:#0c1218;--code-text:#cdd9e5;--code-border:#232f3b;--sidebar:#10151b;--sidebar-soft:#1c242f;--sidebar-line:#262f3a;--sidebar-row:#161d25;--sidebar-text:#cbd4de;--sidebar-muted:#7c8794;--sidebar-faint:#6f7b88;--sidebar-heading:#f0f4f8;--focus:#37a5963d;--shadow:0 16px 40px #00000075;--shadow-sm:0 2px 12px #00000061;--shadow-brand:0 8px 22px #37a59642}*{box-sizing:border-box}html,body,#root{min-height:100%}body{color:var(--text);background:var(--bg);-webkit-font-smoothing:antialiased;text-rendering:optimizelegibility;margin:0;font:13px/1.5 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;transition:background-color .2s,color .2s}button,input,select,textarea{font:inherit}a{color:inherit;text-decoration:none}.shell{grid-template-columns:232px minmax(0,1fr);min-height:100vh;display:grid}.sidebar{height:100vh;color:var(--sidebar-text);background:var(--sidebar);border-right:1px solid var(--sidebar-line);flex-direction:column;gap:16px;padding:18px 12px;display:flex;position:sticky;top:0;overflow-y:auto}.brand{align-items:center;gap:10px;min-height:42px;padding:0 4px;display:flex}.brand.compact{justify-content:center}.brand-elevated .brand-mark{box-shadow:var(--shadow-brand)}.brand-mark{color:#fff;background:var(--brand);border-radius:var(--radius-sm);border:1px solid #fff3;place-items:center;width:34px;height:34px;font-weight:800;display:grid}.brand strong{font-size:14px;line-height:1.1;display:block}.brand small{color:var(--sidebar-muted);margin-top:3px;font-size:11px;display:block}.sidebar-label{color:var(--sidebar-faint);text-transform:uppercase;letter-spacing:.04em;padding:0 8px;font-size:11px;font-weight:700}.nav-list,.nav-section{gap:4px;display:grid}.nav-section-toggle{width:100%;min-height:38px;color:var(--sidebar-muted);border-radius:var(--radius-sm);cursor:pointer;text-align:left;background:0 0;border:1px solid #0000;grid-template-columns:18px minmax(0,1fr) 16px;align-items:center;gap:9px;padding:0 10px;font-size:12px;font-weight:800;transition:color .14s,background-color .14s,border-color .14s;display:grid}.nav-section-toggle:hover,.nav-section.active .nav-section-toggle{color:var(--sidebar-heading);background:var(--sidebar-soft);border-color:var(--sidebar-line)}.nav-section-chevron{color:var(--sidebar-muted);justify-self:end;transition:transform .14s}.nav-section.open .nav-section-chevron{transform:rotate(180deg)}.nav-children{gap:4px;padding:2px 0 2px 18px;display:grid}.nav-item{min-height:38px;color:var(--sidebar-text);border-radius:var(--radius-sm);border:1px solid #0000;grid-template-columns:18px minmax(0,1fr);align-items:center;gap:9px;padding:0 10px;transition:color .14s,background-color .14s,border-color .14s;display:grid}.nav-dot{background:var(--sidebar-faint);border-radius:999px;justify-self:center;width:6px;height:6px}.nav-item:hover,.nav-item.active{color:var(--sidebar-heading);background:var(--sidebar-soft);border-color:var(--sidebar-line)}.nav-item.active .nav-dot{background:var(--brand)}.sidebar-status{gap:7px;margin-top:auto;display:grid}.runtime-row{min-height:32px;color:var(--sidebar-text);background:var(--sidebar-row);border:1px solid var(--sidebar-line);border-radius:var(--radius-sm);grid-template-columns:18px minmax(0,1fr) auto;align-items:center;gap:7px;padding:0 8px;display:grid}.runtime-row strong{color:var(--sidebar-heading);font-size:11px}.workspace{min-width:0}.topbar{z-index:20;background:var(--topbar-bg);border-bottom:1px solid var(--line);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);justify-content:space-between;align-items:center;gap:18px;min-height:66px;padding:12px 24px;display:flex;position:sticky;top:0}.topbar h1{color:var(--heading);margin:2px 0 0;font-size:20px;line-height:1.2}.topbar-actions,.page-actions,.section-action,.entity-badges,.row-actions,.modal-actions{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.language-switch{background:var(--panel-subtle);border:1px solid var(--line);border-radius:999px;align-items:center;min-height:30px;padding:2px;display:inline-flex}.language-switch button{min-width:42px;min-height:24px;color:var(--muted);cursor:pointer;background:0 0;border:0;border-radius:999px;padding:0 9px;font-weight:800;transition:color .14s,background-color .14s}.language-switch button.active{color:#fff;background:var(--brand)}.language-switch button:focus-visible{outline:2px solid var(--brand);outline-offset:2px}.theme-toggle{width:34px;height:34px;color:var(--muted);background:var(--panel-subtle);border:1px solid var(--line);cursor:pointer;border-radius:999px;place-items:center;transition:color .16s,background-color .16s,border-color .16s;display:inline-grid}.theme-toggle:hover{color:var(--brand);border-color:var(--brand-tint-border);background:var(--brand-tint)}.theme-toggle:focus-visible{outline:2px solid var(--brand);outline-offset:2px}.actor-pill{min-height:30px;color:var(--text-soft);background:var(--panel-subtle);border:1px solid var(--line);border-radius:999px;align-items:center;padding:0 10px;display:inline-flex}.content{gap:16px;padding:18px 24px 30px;display:grid}.eyebrow{color:var(--muted);text-transform:uppercase;letter-spacing:.04em;font-size:11px;font-weight:800}.dashboard-layout,.stacked-sections{gap:14px;display:grid}.dashboard-section{gap:10px;display:grid}.dashboard-section-title{color:var(--heading);text-transform:uppercase;letter-spacing:.04em;align-items:baseline;gap:8px;font-size:13px;font-weight:800;display:flex}.dashboard-section-title span{color:var(--muted);text-transform:none;letter-spacing:normal;font-size:11px;font-weight:600}.dashboard-grid{grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:10px;display:grid}.stat-tile{text-align:left;background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);min-width:0;box-shadow:var(--shadow-sm);gap:8px;padding:14px;display:grid}a.stat-tile.clickable{transition:border-color .16s,box-shadow .16s,transform .16s}.stat-tile.clickable:hover{border-color:var(--brand-tint-border);box-shadow:var(--shadow);transform:translateY(-1px)}.stat-tile-head{justify-content:space-between;align-items:center;gap:8px;display:flex}.stat-tile-icon{width:30px;height:30px;color:var(--brand);background:var(--brand-tint);border:1px solid var(--brand-tint-border);border-radius:var(--radius-sm);flex:none;place-items:center;display:grid}.stat-tile.warn .stat-tile-icon{color:var(--warn);background:var(--warn-tint);border-color:var(--warn-border)}.stat-tile.danger .stat-tile-icon{color:var(--danger);background:var(--danger-tint);border-color:var(--danger-border)}.stat-tile.good .stat-tile-icon{color:var(--good);background:var(--good-tint);border-color:var(--good-border)}.stat-tile-open{color:var(--muted)}.stat-tile-value{color:var(--heading);font-size:24px;font-weight:800;line-height:1.1}.stat-tile.warn .stat-tile-value{color:var(--warn)}.stat-tile.danger .stat-tile-value{color:var(--danger)}.stat-tile-label{color:var(--text-soft);font-size:12px;font-weight:700}.stat-tile-sub{color:var(--muted);font-size:11px}.stat-tile-bar{background:var(--panel-subtle);border:1px solid var(--line);border-radius:999px;width:100%;height:5px;overflow:hidden}.stat-tile-bar>span{background:var(--brand-2);height:100%;display:block}.stat-tile.warn .stat-tile-bar>span{background:var(--warn)}.stat-tile.danger .stat-tile-bar>span{background:var(--danger)}.overview-band,.page-frame{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);min-width:0;box-shadow:var(--shadow-sm)}.overview-band{grid-template-columns:minmax(220px,1fr) minmax(420px,.9fr);align-items:center;gap:16px;padding:16px;display:grid}.overview-band h2,.page-title-row h2,.section-head h2,.modal h2{color:var(--heading);margin:0;font-size:18px;line-height:1.25}.overview-metrics,.metric-row{grid-template-columns:repeat(4,minmax(120px,1fr));gap:8px;display:grid}.overview-metrics{grid-template-columns:repeat(3,minmax(120px,1fr))}.status-item,.metric,.summary-item{background:var(--panel-subtle);border:1px solid var(--line);border-radius:var(--radius-sm);min-width:0;padding:10px}.status-item span,.metric span,.summary-item span{color:var(--muted);margin-bottom:6px;font-size:11px;display:block}.status-item strong,.metric strong,.summary-item strong{overflow-wrap:anywhere;color:var(--text);font-weight:800;display:block}.status-item.good,.metric.good{border-color:var(--good-border)}.status-item.warn,.metric.warn{border-color:var(--warn-border)}.metric.danger{border-color:var(--danger-border)}.command-grid{grid-template-columns:repeat(3,minmax(220px,1fr));gap:12px;display:grid}.launcher{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);min-height:94px;box-shadow:var(--shadow-sm);grid-template-columns:38px minmax(0,1fr) 18px;align-items:center;gap:12px;padding:14px;transition:border-color .16s,box-shadow .16s,transform .16s;display:grid}.launcher:hover{border-color:var(--brand-tint-border);box-shadow:var(--shadow);transform:translateY(-1px)}.launcher-icon{width:38px;height:38px;color:var(--brand);background:var(--brand-tint);border:1px solid var(--brand-tint-border);border-radius:var(--radius-sm);place-items:center;display:grid}.launcher-copy{gap:4px;display:grid}.launcher-copy strong{color:var(--heading);font-size:15px}.launcher-copy span{color:var(--muted)}.work-strip{grid-template-columns:repeat(4,minmax(160px,1fr));gap:8px;display:grid}.strip-item{min-height:38px;color:var(--text-soft);background:var(--panel);border:1px solid var(--line);border-radius:var(--radius-sm);align-items:center;gap:8px;padding:0 10px;display:flex}.page-frame{gap:14px;padding:14px;display:grid}.page-title-row{border-bottom:1px solid var(--line);justify-content:space-between;align-items:flex-start;gap:14px;padding-bottom:12px;display:flex}.query-panel{background:var(--panel-subtle);border:1px solid var(--line);border-radius:var(--radius-sm);padding:10px}.toolbar{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.message-query input{width:150px}.message-selector-grid{grid-template-columns:repeat(2,minmax(280px,1fr));gap:10px;margin-bottom:10px;display:grid}.message-selector-grid.single{grid-template-columns:minmax(320px,620px)}.entity-picker{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius-sm);gap:8px;min-width:0;padding:10px;display:grid}.picker-head{min-height:24px;color:var(--text-soft);justify-content:space-between;align-items:center;gap:8px;font-weight:800;display:flex}.selected-entity{min-height:40px;color:var(--brand-tint-text);background:var(--brand-tint);border:1px solid var(--brand-tint-border);border-radius:var(--radius-sm);grid-template-columns:18px minmax(0,1fr) auto;align-items:center;gap:8px;padding:7px 9px;display:grid}.selected-entity strong,.selected-entity span{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.selected-entity div{gap:2px;min-width:0;display:grid}.selected-entity div span{color:var(--brand-tint-text);opacity:.85;font-size:11px}.picker-search{background:var(--panel-subtle);border:1px solid var(--line-strong);border-radius:var(--radius-sm);grid-template-columns:18px minmax(0,1fr) auto;align-items:center;gap:7px;height:34px;padding:0 6px 0 9px;display:grid}.picker-search input{width:100%;height:30px;box-shadow:none;background:0 0;border:0;padding:0}.picker-results{border:1px solid var(--line);border-radius:var(--radius-sm);max-height:236px;display:grid;overflow:auto}.picker-row{min-height:36px;color:var(--text);background:var(--panel);border:0;border-bottom:1px solid var(--line);cursor:pointer;text-align:left;grid-template-columns:96px minmax(120px,1fr) minmax(120px,1fr) auto;align-items:center;gap:8px;padding:6px 8px;display:grid}.picker-row:last-child{border-bottom:0}.picker-row:hover,.picker-row.selected{background:var(--surface-soft)}.picker-row strong,.picker-row span{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}.picker-empty,.picker-error{color:var(--muted);text-align:center;padding:9px}.picker-error{color:var(--danger);background:var(--danger-tint);border:1px solid var(--danger-border);border-radius:var(--radius-sm)}input,select,textarea{color:var(--text);background:var(--input-bg);border:1px solid var(--line-strong);border-radius:var(--radius-sm);outline:none;transition:border-color .14s,box-shadow .14s}input::placeholder,textarea::placeholder{color:var(--muted-2)}input,select{width:190px;height:34px;padding:0 10px}select{min-width:220px;height:34px;font:inherit;appearance:none;cursor:pointer;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239aa4b2' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");background-position:right 10px center;background-repeat:no-repeat;padding:0 30px 0 10px;font-weight:600}select:disabled{color:var(--muted-2);cursor:not-allowed}textarea{resize:vertical;width:100%;padding:9px 10px}input:focus,select:focus,textarea:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--focus)}.small-input{width:88px}.field-inline{color:var(--muted);align-items:center;gap:6px;display:inline-flex}.field-inline span{font-size:11px;font-weight:700}.searchbox{width:min(380px,100%);height:34px;color:var(--text);background:var(--input-bg);border:1px solid var(--line-strong);border-radius:var(--radius-sm);align-items:center;gap:8px;padding:0 10px;display:inline-flex}.searchbox input{width:100%;height:30px;box-shadow:none;border:0;padding:0}.btn{min-height:34px;color:var(--btn-text);background:var(--btn-bg);border:1px solid var(--line-strong);border-radius:var(--radius-sm);cursor:pointer;white-space:nowrap;justify-content:center;align-items:center;gap:6px;padding:0 12px;transition:background-color .14s,border-color .14s,color .14s,box-shadow .14s;display:inline-flex}.btn:hover:not(:disabled){background:var(--btn-hover)}.btn:disabled{color:var(--muted-2);cursor:not-allowed}.btn.primary{color:#fff;background:var(--brand);border-color:var(--brand)}.btn.primary:hover:not(:disabled){background:var(--brand-strong);border-color:var(--brand-strong)}.btn.ghost{background:var(--panel-subtle)}.btn.danger{color:var(--danger);background:var(--danger-tint);border-color:var(--danger-border)}.btn.danger:hover:not(:disabled){background:var(--danger-tint);border-color:var(--danger)}.btn.warn{color:var(--warn);background:var(--warn-tint);border-color:var(--warn-border)}.btn.warn:hover:not(:disabled){background:var(--warn-tint);border-color:var(--warn)}.btn:disabled,.btn.primary:disabled,.btn.warn:disabled,.btn.danger:disabled{color:var(--muted-2);background:var(--panel-strong);border-color:var(--line);cursor:not-allowed}.btn.full{width:100%}.icon-text{gap:7px}.compact-btn{min-height:28px;padding:0 8px;font-size:12px}.row-link,.link-button{color:var(--brand-2);cursor:pointer;background:0 0;border:0;align-items:center;gap:4px;padding:0;display:inline-flex}.table-wrap{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);width:100%;overflow-x:auto}.data-table{border-collapse:collapse;width:100%;font-size:12.5px}.data-table th,.data-table td{border-bottom:1px solid var(--line);text-align:left;vertical-align:middle;white-space:nowrap;height:38px;padding:7px 9px}.data-table th{z-index:0;color:var(--muted);background:var(--panel-strong);font-weight:800;position:sticky;top:0}.data-table tbody tr:hover{background:var(--panel-subtle)}.data-table tr:last-child td{border-bottom:0}.mono{font-family:SFMono-Regular,Consolas,Liberation Mono,monospace}.truncate{text-overflow:ellipsis;max-width:380px;overflow:hidden}.badge{min-height:22px;color:var(--muted);background:var(--panel-strong);border:1px solid var(--line-strong);white-space:nowrap;border-radius:999px;align-items:center;padding:1px 8px;display:inline-flex}.badge.good{color:var(--good);background:var(--good-tint);border-color:var(--good-border)}.badge.danger{color:var(--danger);background:var(--danger-tint);border-color:var(--danger-border)}.badge.warn{color:var(--warn);background:var(--warn-tint);border-color:var(--warn-border)}.empty-cell{color:var(--muted);text-align:center}.bot-create-fields{grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;display:grid}.bot-create-fields .duration-field input{width:100%}.bot-create-actions{border-top:1px solid var(--line);justify-content:space-between;align-items:center;gap:14px;margin-top:14px;padding-top:14px;display:flex}.bot-create-note{color:var(--muted);font-size:12px;line-height:1.4}@media (width<=760px){.bot-create-fields{grid-template-columns:1fr}.bot-create-actions{flex-direction:column;align-items:stretch}}.progress-cell{gap:4px;min-width:130px;display:grid}.progress-cell small,.progress-note{color:var(--muted);font-size:11px}.progress-bar{background:var(--panel-subtle);border:1px solid var(--line);border-radius:999px;width:100%;height:6px;overflow:hidden}.progress-bar>span{background:var(--brand-2);height:100%;display:block}.progress-bar.good>span{background:var(--good)}.progress-bar.danger>span{background:var(--danger)}.progress-wide .progress-cell{min-width:0}.split-layout{grid-template-columns:minmax(0,1fr) 330px;align-items:start;gap:14px;display:grid}.split-main,.split-side{min-width:0}.entity-head{background:var(--panel-subtle);border:1px solid var(--line);border-radius:var(--radius);justify-content:space-between;align-items:flex-start;gap:14px;padding:14px;display:flex}.entity-title{color:var(--heading);font-size:20px;font-weight:800;line-height:1.25}.entity-subtitle{color:var(--muted);margin-top:4px}.summary-grid{grid-template-columns:repeat(4,minmax(150px,1fr));gap:8px;display:grid}.about-text{color:var(--text-soft);background:var(--panel-subtle);border:1px solid var(--line);border-radius:var(--radius);margin:0;padding:10px}.section-block,.action-dock,.surface{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);min-width:0;box-shadow:var(--shadow-sm);padding:12px}.section-head{justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:10px;display:flex}.section-head p{color:var(--muted);margin:5px 0 0}.action-dock{gap:10px;display:grid;position:sticky;top:82px}.dock-title{color:var(--text-soft);border-bottom:1px solid var(--line);padding-bottom:4px;font-weight:800}.action-dock>.btn,.action-dock .action-stack .btn{justify-content:center;width:100%}.duration-field{gap:4px;display:grid}.duration-field span{color:var(--muted);font-size:11px;font-weight:800}.duration-field input,.duration-field select{width:100%}.action-stack{gap:10px;display:grid}.action-stack .btn,.action-dock>.btn{min-height:42px}.danger-zone{border-top:1px solid var(--line);flex-wrap:wrap;gap:8px;margin-top:10px;padding-top:10px;display:flex}.dock-title+.danger-zone{border-top:0;margin-top:0;padding-top:0}.authorization-block{gap:10px;display:grid}.authorization-table{table-layout:fixed;min-width:720px}.authorization-table th,.authorization-table td{height:46px}.device-text{text-overflow:ellipsis;max-width:260px;overflow:hidden}.device-actions-head{width:190px}.device-actions-cell{width:190px;min-width:190px}.device-actions{white-space:normal;grid-template-columns:repeat(2,minmax(82px,1fr));gap:6px;min-width:178px;display:grid}.device-actions .btn{justify-content:center;width:100%}.operation-row{grid-template-columns:repeat(2,minmax(280px,1fr));gap:10px;display:grid}.operation-box{background:var(--panel-subtle);border:1px solid var(--line);border-radius:var(--radius);flex-wrap:wrap;align-items:center;gap:8px;padding:10px;display:flex}.operation-title{width:100%;color:var(--heading);align-items:center;gap:6px;font-weight:800;display:flex}.checkline{color:var(--muted);align-items:center;gap:6px;display:inline-flex}.checkline input{width:auto;height:auto}.alert{color:var(--danger-text);background:var(--danger-tint);border:1px solid var(--danger-border);border-radius:var(--radius);align-items:flex-start;gap:8px;padding:9px 10px;display:flex}.json-block{max-height:520px;color:var(--code-text);background:var(--code-bg);border:1px solid var(--code-border);border-radius:var(--radius);margin:0;padding:12px;font-size:12px;overflow:auto}.raw-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;display:grid}.loading-line{min-height:80px;color:var(--muted);place-items:center;display:grid}.empty-panel{min-height:92px;color:var(--muted);background:var(--panel-subtle);border:1px solid var(--line);border-radius:var(--radius);place-items:center;display:grid}.gift-metrics .metric{background:var(--panel-subtle);min-height:68px;padding:12px}.gift-metrics .metric strong{font-size:17px}.gift-file-icon{color:var(--brand);background:var(--brand-tint);border:1px solid var(--brand-tint-border);flex:none;place-items:center;display:grid}.gift-format-chips{flex-wrap:wrap;flex:none;justify-content:flex-end;gap:6px;display:flex}.gift-format-chips span{color:var(--brand-tint-text);background:var(--brand-tint);border:1px solid var(--brand-tint-border);letter-spacing:.02em;border-radius:999px;padding:4px 8px;font-size:10px;font-weight:800}.gift-list-summary{color:var(--muted);margin-left:auto;font-size:11px;font-weight:700}.gift-import-modal{width:min(860px,100%)}.gift-import-modal-body{gap:14px}.gift-source-tabs{gap:8px;display:flex}.gift-lifecycle{background:var(--panel-subtle);border:1px solid var(--line-strong);border-radius:12px;flex-direction:column;gap:10px;padding:12px 13px;display:flex}.gift-lifecycle .gift-source-tabs button{flex:1 1 0;justify-content:center}.auction-cell{gap:3px;min-width:92px;display:grid}.auction-cell small{color:var(--muted);font-size:11px}.auction-cell .mono{font-size:11px}.give-gift-summary{background:var(--panel-subtle);border:1px solid var(--line-strong);color:var(--text-soft);border-radius:12px;align-items:center;gap:11px;padding:11px 13px;display:flex}.give-gift-summary>svg{color:var(--brand);flex:none}.give-gift-summary strong{color:var(--text);font-size:13px;display:block}.give-gift-summary .mono{color:var(--muted);font-size:11px}.give-gift-tabs{background:var(--panel-subtle);border:1px solid var(--line-strong);border-radius:12px;gap:4px;width:100%;padding:4px;display:flex}.give-gift-tabs .btn{min-height:36px;box-shadow:none;color:var(--text-soft);background:0 0;border:1px solid #0000;border-radius:9px;flex:1 1 0;justify-content:center;transition:color .15s,background .15s,border-color .15s,box-shadow .15s}.give-gift-tabs .btn:not(.primary):hover{color:var(--brand);background:var(--brand-tint)}.give-gift-tabs .btn.primary{color:#fff;background:var(--brand);border-color:var(--brand);box-shadow:var(--shadow-brand)}.give-gift-upgrade-note{background:var(--brand-tint);border:1px solid var(--brand-tint-border);color:var(--text-soft);border-radius:10px;margin:0;padding:9px 12px;font-size:11px;font-weight:650;line-height:1.45}.give-gift-attrs{grid-template-columns:repeat(3,minmax(0,1fr));align-items:end}.give-gift-attrs select,.give-gift-attrs input{width:100%;min-width:0;height:38px;color:var(--text);background-color:var(--input-bg);border:1px solid var(--line);border-radius:var(--radius-sm);font:inherit;appearance:none;cursor:pointer;padding:0 32px 0 10px;font-size:12px;font-weight:600}.give-gift-attrs input{cursor:text;text-overflow:ellipsis;padding-right:10px}.give-gift-attrs select{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239aa4b2' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");background-position:right 11px center;background-repeat:no-repeat}.give-gift-attrs select:focus,.give-gift-attrs input:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--focus);outline:none}.give-gift-layout{grid-template-columns:minmax(220px,280px) minmax(0,1fr);align-items:start;gap:16px;display:grid}.give-gift-picker{align-content:start;gap:10px;display:grid}.give-gift-picker-head{align-items:center;gap:12px;display:flex}.give-gift-picker-head .searchbox{flex:auto}.give-gift-picker-list{background:var(--panel-subtle);border:1px solid var(--line);border-radius:var(--radius-lg);gap:8px;max-height:640px;padding:8px;display:grid;overflow-y:auto}.give-gift-option{text-align:left;min-width:0;color:var(--text);background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);cursor:pointer;box-shadow:var(--shadow-sm);grid-template-columns:46px minmax(0,1fr) auto;align-items:center;gap:11px;padding:9px 11px;transition:border-color .15s,box-shadow .15s,transform .15s;display:grid}.give-gift-option:hover{border-color:var(--brand-tint-border);box-shadow:var(--shadow);transform:translateY(-1px)}.give-gift-option.selected{border-color:var(--brand);box-shadow:0 0 0 2px var(--focus), var(--shadow)}.give-gift-thumb{place-items:center;width:46px;height:46px;display:grid}.give-gift-thumb canvas{width:100%!important;height:100%!important}.give-gift-option-info{gap:3px;min-width:0;display:grid}.give-gift-option-info strong{text-overflow:ellipsis;white-space:nowrap;font-size:12px;overflow:hidden}.give-gift-option-info .mono{color:var(--muted);font-size:10px}.give-gift-option-price{white-space:nowrap;justify-self:end}.give-gift-panel{background:var(--panel);border:1px solid var(--line-strong);border-radius:var(--radius-lg);gap:12px;min-width:0;padding:16px;display:grid}.give-gift-form{gap:12px;min-width:0;display:grid}.give-gift-form-actions{flex-wrap:wrap;justify-content:flex-end;gap:10px;padding-top:4px;display:flex}.give-gift-empty-panel{color:var(--muted);text-align:center;place-items:center;gap:10px;padding:48px 20px;display:grid}.give-gift-empty-panel svg{color:var(--brand);opacity:.8}.official-gift-picker{gap:12px;min-width:0;display:grid}.official-gift-tools{align-items:center;gap:12px;display:flex}.official-gift-tools .searchbox{width:100%}.official-gift-tools>span{color:var(--muted);flex:none;font-size:11px;font-weight:750}.official-gift-categories{flex-wrap:wrap;gap:7px;display:flex}.official-gift-categories button{min-height:32px;color:var(--text-soft);background:var(--panel-subtle);border:1px solid var(--line-strong);font:inherit;cursor:pointer;border-radius:999px;align-items:center;gap:7px;padding:5px 10px;font-size:11px;font-weight:800;transition:color .15s,background .15s,border-color .15s,box-shadow .15s;display:inline-flex}.official-gift-categories button:hover{color:var(--brand);border-color:var(--brand-tint-border)}.official-gift-categories button.active{color:#fff;background:var(--brand);border-color:var(--brand);box-shadow:var(--shadow-brand)}.official-gift-categories button span{min-width:20px;height:20px;color:inherit;background:#7d8c9b38;border-radius:999px;place-items:center;padding:0 5px;font-size:10px;display:grid}.official-gift-categories button.active span{color:var(--brand);background:#ffffffd9}.official-gift-list{border:1px solid var(--line);border-radius:var(--radius-lg);background:var(--panel-subtle);scrollbar-gutter:stable;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;min-height:126px;max-height:314px;padding:8px;display:grid;overflow:auto}.official-gift-option{text-align:left;min-width:0;color:var(--text);background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);cursor:pointer;box-shadow:var(--shadow-sm);gap:8px;padding:11px 12px;transition:border-color .15s,box-shadow .15s,transform .15s;display:grid}.official-gift-option:hover{border-color:var(--brand-tint-border);box-shadow:var(--shadow);transform:translateY(-1px)}.official-gift-option.selected{border-color:var(--brand);box-shadow:0 0 0 2px var(--focus), var(--shadow)}.official-gift-option-head{grid-template-columns:minmax(0,1fr) auto;align-items:baseline;gap:8px;display:grid}.official-gift-option-head strong{text-overflow:ellipsis;white-space:nowrap;font-size:12px;overflow:hidden}.official-gift-option-head .mono{color:var(--muted);font-size:9px}.official-gift-option-meta{color:var(--muted);flex-wrap:wrap;gap:10px;font-size:10px;font-weight:700;display:flex}.official-gift-capabilities{flex-wrap:wrap;gap:5px;display:flex}.official-gift-capabilities>span{letter-spacing:.01em;border:1px solid #0000;border-radius:999px;padding:3px 7px;font-size:9px;font-weight:850}.official-gift-capabilities>span.yes{color:var(--good);background:var(--good-tint);border-color:var(--good-border)}.official-gift-capabilities>span.craft{color:var(--purple);background:var(--purple-tint);border-color:var(--purple-border)}.official-gift-capabilities>span.no{color:var(--muted);background:var(--panel-strong);border-color:var(--line-strong)}.official-gift-empty{min-height:108px;color:var(--muted);text-align:center;grid-column:1/-1;place-items:center;padding:20px;font-size:12px;display:grid}.official-gift-selected{border:1px solid var(--line);border-radius:var(--radius-lg);background:var(--surface-soft);grid-template-columns:108px minmax(0,1fr);align-items:center;gap:14px;padding:12px;display:grid}.official-gift-selected .gift-animation-shell{width:96px;height:96px}.official-gift-selected>div:last-child{gap:5px;min-width:0;display:grid}.official-gift-selected small{color:var(--muted)}.gift-import-note{color:var(--muted);justify-content:space-between;align-items:center;gap:12px;line-height:1.45;display:flex}.gift-file-picker{min-height:78px;color:var(--text);background:var(--panel);border:1px dashed var(--line-strong);border-radius:var(--radius);cursor:pointer;grid-template-columns:42px minmax(0,1fr) auto;align-items:center;gap:12px;padding:12px 14px;transition:border-color .16s,background .16s,box-shadow .16s;display:grid;position:relative}.gift-file-picker:hover,.gift-file-picker.has-file{background:var(--brand-tint);border-color:var(--brand);box-shadow:0 0 0 2px var(--focus)}.gift-file-picker input{opacity:0;pointer-events:none;width:1px;height:1px;position:absolute}.gift-file-icon{border-radius:var(--radius-sm);width:40px;height:40px}.gift-file-copy{gap:2px;min-width:0;display:grid}.gift-field-label{color:var(--muted);text-transform:uppercase;letter-spacing:.04em;font-size:10px;font-weight:800}.gift-file-copy strong{color:var(--heading);text-overflow:ellipsis;white-space:nowrap;font-size:13px;overflow:hidden}.gift-file-copy small{color:var(--muted);font-size:11px;font-weight:500}.gift-file-action{color:var(--brand);background:var(--brand-tint);border:1px solid var(--brand-tint-border);border-radius:var(--radius-sm);padding:7px 10px;font-size:11px;font-weight:800}.gif-catalog-thumb{object-fit:cover;border-radius:var(--radius-sm);background:var(--panel-subtle);width:72px;height:52px}.gif-catalog-preview{background:var(--panel-subtle);border:1px solid var(--line);border-radius:var(--radius-sm);place-items:center;max-height:220px;display:grid;overflow:hidden}.gif-catalog-preview img,.gif-catalog-preview video{object-fit:contain;max-width:100%;max-height:220px}.sticker-list-thumb-empty,.list-thumb{width:58px;height:58px;color:var(--muted);background:var(--panel-subtle);border:1px solid var(--line);border-radius:var(--radius-sm);place-items:center;display:grid}.sticker-doc-cell{background:var(--panel-subtle);border:1px solid var(--line);border-radius:var(--radius-sm);place-items:center;width:92px;min-height:92px;display:grid;position:relative;overflow:hidden}.sticker-doc-cell.list-thumb{width:58px;height:58px;min-height:58px}.sticker-doc-canvas,.sticker-doc-image{width:100%;height:100%}.sticker-doc-canvas canvas{width:100%!important;height:100%!important}.sticker-doc-image{object-fit:contain}.sticker-doc-error{color:var(--danger);background:var(--danger-tint);border-radius:var(--radius-xs);text-overflow:ellipsis;white-space:nowrap;padding:2px 4px;font-size:9px;position:absolute;inset:auto 4px 4px;overflow:hidden}.sticker-preview-modal{width:min(980px,100%);max-height:min(92vh,860px)}.sticker-doc-grid{grid-template-columns:repeat(auto-fill,minmax(112px,1fr));gap:10px;display:grid}.sticker-doc-grid-cell{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);justify-items:center;gap:8px;padding:10px;display:grid}.sticker-add-form{grid-template-columns:minmax(210px,1.4fr) 86px minmax(170px,1fr) auto;align-items:center;gap:8px;display:grid}.gift-file-picker.compact{grid-template-columns:minmax(0,1fr);min-height:42px;padding:8px 10px}.title-input{width:min(220px,100%)}.sort-order-editor{align-items:center;gap:6px;display:inline-flex}.sticker-add-form-error{color:var(--danger);grid-column:1/-1;font-size:11px;font-weight:700}.gift-fields-grid{grid-template-columns:minmax(200px,1.5fr) repeat(3,minmax(120px,1fr));gap:10px;display:grid}.gift-fields-grid label,.gift-reason-field{color:var(--muted);gap:6px;font-size:11px;font-weight:700;display:grid}.gift-fields-grid input,.gift-reason-field input{width:100%;min-width:0;height:38px;color:var(--text);background:var(--input-bg);border:1px solid var(--line);border-radius:var(--radius-sm);padding:0 10px}.gift-fields-grid input:focus,.gift-reason-field input:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--focus);outline:none}.gift-switch{color:var(--text-soft);cursor:pointer;align-items:center;gap:9px;font-size:12px;font-weight:700;display:inline-flex}.gift-switch input{opacity:0;width:1px;height:1px;position:absolute}.gift-switch-track{background:var(--switch-track);border-radius:999px;align-items:center;width:34px;height:19px;padding:2px;transition:background .16s;display:flex}.gift-switch-track span{background:#fff;border-radius:50%;width:15px;height:15px;transition:transform .16s;box-shadow:0 1px 3px #10182838}.gift-switch input:checked+.gift-switch-track{background:var(--brand)}.gift-switch input:checked+.gift-switch-track span{transform:translate(15px)}.gift-switch input:focus-visible+.gift-switch-track{outline:3px solid var(--focus);outline-offset:2px}.premium-plans-table{min-width:1080px}.premium-plan-input{width:150px;min-height:34px;padding:7px 9px}.premium-plan-input.numeric{font-variant-numeric:tabular-nums;width:116px}.premium-plan-input.numeric.short{width:78px}.premium-stars-input{color:var(--text-soft);align-items:center;gap:7px;font-size:12px;font-weight:700;display:flex}.premium-plan-version{color:var(--text-soft);font-family:var(--mono);margin-top:5px;display:block}.premium-new-plan{margin-top:18px}.premium-plan-form{grid-template-columns:repeat(5,minmax(140px,1fr))}.premium-new-plan-actions{justify-content:flex-end;gap:10px;margin-top:18px;display:flex}.gift-validation{color:var(--code-text);background:var(--code-bg);border:1px solid var(--code-border);border-radius:var(--radius-sm);overflow:hidden}.gift-validation-head{color:var(--code-text);background:#ffffff09;border-bottom:1px solid #ffffff17;align-items:center;gap:9px;padding:10px 12px;display:flex}.gift-validation-head div{gap:2px;display:grid}.gift-validation-head span{color:var(--brand);font-size:10px}.gift-validation pre{max-height:180px;color:var(--code-text);margin:0;padding:11px 12px;font-size:11px;overflow:auto}.gift-animation-shell{background:var(--surface-soft);place-items:center;min-height:210px;display:grid;position:relative}.gift-animation{width:200px;height:200px}.gift-animation canvas{width:100%!important;height:100%!important}.gift-play{width:30px;height:30px;color:var(--text);background:var(--panel);border:1px solid var(--line);border-radius:50%;place-items:center;display:grid;position:absolute;bottom:8px;right:8px}.gift-table-wrap{background:var(--panel)}.gift-table{min-width:1080px}.gift-table th:first-child{width:74px}.gift-table td{vertical-align:middle}.gift-animation-shell.compact{border:1px solid var(--line);border-radius:var(--radius-sm);width:56px;min-height:56px;overflow:hidden}.gift-animation-shell.compact .gift-animation{width:54px;height:54px}.gift-animation-shell.compact .gift-play{width:20px;height:20px;bottom:3px;right:3px}.gift-row-disabled{opacity:.68}.gift-table-title,.gift-sort-order,.gift-source-size,.gift-convert-price{display:block}.gift-table-title{text-overflow:ellipsis;white-space:nowrap;max-width:220px;overflow:hidden}.gift-sort-order,.gift-source-size,.gift-convert-price{color:var(--muted);margin-top:3px;font-size:10px}.gift-table-price{color:var(--warn)}.gift-table-actions{align-items:center;gap:6px;display:flex}.collectible-button{color:var(--purple);background:var(--purple-tint);border-color:var(--purple-border)}.collectible-button:hover{background:var(--purple-tint);border-color:var(--purple)}.collectible-modal{width:min(1180px,100%);max-height:min(92vh,980px)}.collectible-modal .modal-head p{color:var(--muted);margin:4px 0 0;font-size:11px}.collectible-modal-body{background:var(--bg);gap:16px;padding:16px 18px 22px;overflow:auto}.collectible-loading{min-height:90px;color:var(--muted);justify-content:center;align-items:center;gap:8px;display:flex}.collectible-empty{color:var(--purple-text);background:var(--purple-tint);border:1px dashed var(--purple-border);border-radius:var(--radius);align-items:center;gap:12px;padding:16px;display:flex}.collectible-empty div,.collectible-definition-head>div:first-child,.collectible-section-head>div:first-child{gap:3px;display:grid}.collectible-empty span,.collectible-definition-head span,.collectible-section-head span{color:var(--muted);font-size:10px;font-weight:500}.collectible-active{background:var(--panel);border:1px solid var(--purple-border);border-radius:var(--radius);box-shadow:var(--shadow-sm);overflow:hidden}.collectible-active-head{background:var(--purple-tint);border-bottom:1px solid var(--purple-border);justify-content:space-between;align-items:center;gap:12px;padding:12px 14px;display:flex}.collectible-active-head>div{color:var(--purple-text);align-items:center;gap:9px;display:flex}.collectible-active-head>div>div{gap:2px;display:grid}.collectible-active-head span{color:var(--muted);font-size:10px}.collectible-active-grid{background:var(--line);grid-template-columns:repeat(auto-fill,minmax(145px,1fr));gap:1px;display:grid}.collectible-active-grid article{background:var(--panel);align-items:center;gap:9px;min-width:0;padding:9px 11px;display:flex}.collectible-active-grid article>div:last-child{gap:2px;min-width:0;display:grid}.collectible-active-grid article strong{text-overflow:ellipsis;white-space:nowrap;font-size:11px;overflow:hidden}.collectible-active-grid article span{color:var(--muted);font-size:9px}.collectible-definition{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow-sm);overflow:hidden}.collectible-definition-head{background:var(--panel-subtle);border-bottom:1px solid var(--line);justify-content:space-between;align-items:center;gap:12px;padding:14px 16px;display:flex}.collectible-main-fields{background:var(--panel-subtle);border-bottom:1px solid var(--line);padding:14px 16px}.collectible-section{border-bottom:1px solid var(--line);padding:14px 16px}.collectible-section:last-child{border-bottom:0}.collectible-section-head{justify-content:space-between;align-items:center;gap:12px;margin-bottom:10px;display:flex}.collectible-section-tools{align-items:center;gap:7px;display:flex}.collectible-rows{gap:7px;display:grid}.collectible-row{background:var(--panel-subtle);border:1px solid var(--line);border-radius:var(--radius-sm);align-items:end;gap:7px;padding:9px 9px 9px 36px;display:grid;position:relative}.collectible-row:hover{background:var(--panel);border-color:var(--line-strong);box-shadow:var(--shadow-sm)}.collectible-row.animated{grid-template-columns:minmax(120px,1.2fr) 90px 78px minmax(160px,1.4fr) 48px 30px}.collectible-row.backdrop{grid-template-columns:minmax(110px,1.2fr) 70px 80px 70px repeat(4,52px) 48px 30px}.collectible-row-index{width:27px;color:var(--purple-text);background:var(--purple-tint);border-right:1px solid var(--purple-border);border-radius:var(--radius-xs) 0 0 var(--radius-xs);place-items:center;font-size:10px;font-weight:800;display:grid;position:absolute;top:0;bottom:0;left:0}.collectible-row label{gap:4px;min-width:0;display:grid}.collectible-row label>span{color:var(--muted);text-transform:uppercase;letter-spacing:.025em;font-size:9px;font-weight:800}.collectible-row input:not([type=file]){width:100%;min-width:0;height:32px;color:var(--text);background:var(--input-bg);border:1px solid var(--line-strong);border-radius:var(--radius-sm);font:inherit;padding:0 8px;font-size:11px}.collectible-row input:focus{border-color:var(--purple);box-shadow:0 0 0 3px var(--purple-tint);outline:none}.collectible-file input{opacity:0;pointer-events:none;width:1px;height:1px;position:absolute}.collectible-file em{min-width:0;height:32px;color:var(--purple-text);background:var(--purple-tint);border:1px dashed var(--purple-border);border-radius:var(--radius-sm);text-overflow:ellipsis;white-space:nowrap;cursor:pointer;align-items:center;gap:5px;padding:0 8px;font-size:10px;font-style:normal;font-weight:700;display:flex;overflow:hidden}.collectible-inline-preview{width:42px;height:42px;color:var(--purple);background:var(--purple-tint);border:1px solid var(--purple-border);border-radius:var(--radius-sm);place-items:center;display:grid;overflow:hidden}.collectible-animation{width:100%;height:100%;overflow:hidden}.collectible-animation.compact{background:var(--purple-tint);border:1px solid var(--purple-border);border-radius:var(--radius-sm);flex:0 0 42px;place-items:center;width:42px;height:42px;display:grid}.collectible-animation canvas{width:100%!important;height:100%!important}.collectible-animation.failed{color:var(--danger);background:var(--danger-tint)}.collectible-animation.loading{color:var(--purple-text)}.collectible-file-error{color:var(--danger);grid-column:1/-1;font-size:10px}.collectible-color input{cursor:pointer;height:32px!important;padding:3px!important}.collectible-backdrop-preview{border-radius:var(--radius-sm);border:1px solid #2a1f472e;flex:0 0 42px;place-items:center;width:42px;height:42px;font-size:11px;font-weight:900;display:grid;box-shadow:inset 0 0 0 1px #fff3}.collectible-row .icon-btn{align-self:center}.collectible-row .icon-btn:disabled{opacity:.28}@media (width<=900px){.gift-fields-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.give-gift-layout{grid-template-columns:1fr}.give-gift-picker-list{max-height:320px}.collectible-row.animated,.collectible-row.backdrop{grid-template-columns:repeat(2,minmax(0,1fr))}.collectible-inline-preview,.collectible-backdrop-preview,.collectible-row .icon-btn{place-self:center start}}@media (width<=620px){.gift-import-note{flex-direction:column;align-items:flex-start}.gift-format-chips{justify-content:flex-start}.gift-file-picker{grid-template-columns:40px minmax(0,1fr)}.gift-file-action{display:none}.gift-fields-grid{grid-template-columns:1fr}.gift-list-summary{width:100%;margin-left:0}.official-gift-tools{flex-direction:column;align-items:stretch}.official-gift-list{grid-template-columns:1fr;max-height:340px}.official-gift-selected{grid-template-columns:82px minmax(0,1fr)}.official-gift-selected .gift-animation-shell{width:72px;height:72px}.collectible-modal-body{padding:10px}.collectible-definition-head,.collectible-section-head{flex-direction:column;align-items:flex-start}.collectible-row.animated,.collectible-row.backdrop{grid-template-columns:1fr}.collectible-active-grid{grid-template-columns:1fr 1fr}}.attr-block{background:var(--panel-subtle);border:1px solid var(--line);border-radius:var(--radius-sm);gap:8px;padding:10px;display:grid}.attr-block .duration-field input,.duration-field select{width:100%}.attr-block .btn{justify-content:center;width:100%}.emoji-grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;display:grid}.emoji-card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow-sm);gap:8px;padding:12px;display:grid}.emoji-preview{background:var(--surface-soft);border:1px solid var(--line);border-radius:var(--radius-sm);place-items:center;height:88px;display:grid}.emoji-anim{width:80px;height:80px}.emoji-anim canvas{width:100%!important;height:100%!important}.emoji-glyph{font-size:46px;line-height:1}.emoji-meta{gap:4px;min-width:0;display:grid}.emoji-alt{font-size:18px;line-height:1.2}.emoji-id{width:100%;min-width:0;color:var(--text);background:var(--panel-subtle);border:1px solid var(--line);border-radius:var(--radius-sm);cursor:pointer;justify-content:space-between;align-items:center;gap:6px;padding:4px 8px;font-size:11px;display:flex}.emoji-id .mono{text-overflow:ellipsis;white-space:nowrap;flex:auto;min-width:0;overflow:hidden}.emoji-id svg{flex:none}.emoji-id:hover{border-color:var(--brand-tint-border);color:var(--brand)}.emoji-sub{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;font-size:11px;overflow:hidden}.breakdown-list{gap:8px;margin-bottom:10px;display:grid}.breakdown-row{background:var(--panel-subtle);border:1px solid var(--line);border-radius:var(--radius-sm);grid-template-columns:minmax(140px,260px) 1fr minmax(80px,auto);align-items:center;gap:12px;padding:9px 10px;display:grid}.breakdown-row.total{background:0 0;grid-template-columns:1fr minmax(80px,auto)}.breakdown-label{gap:2px;min-width:0;display:grid}.breakdown-label strong{color:var(--text);font-weight:800}.breakdown-label small{color:var(--muted);font-size:11px;line-height:1.35}.breakdown-value{color:var(--text);text-align:right;font-weight:800}.breakdown-value.good{color:var(--good)}.breakdown-value.danger{color:var(--danger-text)}@media (width<=760px){.breakdown-row,.breakdown-row.total{grid-template-columns:1fr}.breakdown-value{text-align:left}}.username-branch{margin:2px 0 0;padding:0;list-style:none}.username-branch li{color:var(--text-soft);padding-left:14px;font-size:12px;line-height:1.7;position:relative}.username-branch li:before{border-left:1px solid var(--line-strong,var(--line));border-bottom:1px solid var(--line-strong,var(--line));content:"";width:6px;height:11px;position:absolute;top:0;left:3px}.username-branch li.inactive{color:var(--muted)}.username-branch li.inactive span{text-decoration:line-through}.username-branch li em{text-transform:uppercase;letter-spacing:.04em;margin-left:6px;font-size:10px;font-style:normal;font-weight:800}.premium-operations-compact,.premium-catalog-compact{padding:14px}.premium-catalog-compact{margin-top:12px}.premium-operation-bar{grid-template-columns:minmax(320px,1fr) repeat(2,minmax(220px,250px));gap:10px;min-width:0;display:grid}.premium-account-picker,.premium-quick-action{background:var(--panel-subtle);border:1px solid var(--line);border-radius:11px;min-width:0;padding:8px}.premium-account-picker{z-index:3;position:relative}.entity-picker-dropdown{background:0 0;border:0;border-radius:0;gap:5px;padding:0;position:relative}.entity-picker-dropdown .picker-head{min-height:18px;font-size:11px}.entity-picker-dropdown .picker-head .link-button{font-size:10px}.entity-picker-dropdown .picker-search{background:var(--input-bg);height:32px}.entity-picker-dropdown .selected-entity{min-height:32px;padding:5px 8px}.entity-picker-dropdown .picker-results{z-index:60;background:var(--panel);border-color:var(--line-strong);width:min(620px,100vw - 56px);max-height:260px;box-shadow:var(--shadow);position:absolute;top:calc(100% + 5px);left:0}.premium-quick-action{align-content:start;gap:5px;display:grid}.premium-quick-action-head{min-height:18px;color:var(--heading);align-items:center;gap:6px;font-size:11px;display:flex}.premium-quick-action-head>span{width:20px;height:18px;color:var(--brand);background:var(--brand-tint);border-radius:6px;place-items:center;display:grid}.premium-quick-action.stars .premium-quick-action-head>span{color:#c58a00;background:#e8a5001f}.premium-quick-action-control{grid-template-columns:minmax(68px,82px) minmax(130px,1fr);gap:6px;min-width:0;display:grid}.premium-quick-action-control>input{font-variant-numeric:tabular-nums;width:100%;min-width:0;height:32px;padding-inline:8px}.premium-action-submit,.premium-action-submit .btn{min-width:0}.premium-action-submit .btn{white-space:normal;justify-content:center;width:100%;min-height:32px;padding-inline:9px;line-height:1.15}.premium-action-submit .btn:not(:disabled){color:#fff;background:var(--brand);border-color:var(--brand);box-shadow:var(--shadow-brand)}.premium-action-submit .btn:not(:disabled):hover{background:var(--brand-strong);border-color:var(--brand-strong)}.premium-catalog-actions{flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:8px;display:flex}.premium-catalog-count{color:var(--muted);white-space:nowrap;font-size:11px;font-weight:750}.premium-plan-list{gap:7px;display:grid}.premium-plan-card{background:var(--panel-subtle);border:1px solid var(--line);border-radius:11px;min-width:0;transition:border-color .14s,box-shadow .14s;overflow:hidden}.premium-plan-card[open]{background:var(--panel);border-color:var(--line-strong);box-shadow:var(--shadow-sm)}.premium-plan-card>summary{min-width:0;min-height:54px;color:var(--text);cursor:pointer;grid-template-columns:20px minmax(160px,1.45fr) minmax(92px,.55fr) minmax(122px,.7fr) auto minmax(96px,auto) minmax(96px,auto);align-items:center;gap:10px;padding:8px 10px;list-style:none;display:grid}.premium-plan-card>summary::-webkit-details-marker{display:none}.premium-plan-card>summary::marker{content:"";display:none}.premium-plan-card>summary:hover{background:var(--surface-soft)}.premium-plan-card>summary:focus-visible{outline:3px solid var(--focus);outline-offset:-3px}.premium-plan-chevron{color:var(--muted);place-items:center;display:grid}.premium-plan-chevron svg{transition:transform .14s}.premium-plan-card[open] .premium-plan-chevron svg{transform:rotate(180deg)}.premium-plan-identity,.premium-plan-summary-value{min-width:0}.premium-plan-identity strong,.premium-plan-identity span,.premium-plan-summary-value small,.premium-plan-summary-value strong{text-overflow:ellipsis;white-space:nowrap;display:block;overflow:hidden}.premium-plan-identity strong{color:var(--heading);font-size:13px}.premium-plan-identity span,.premium-plan-summary-value small{color:var(--muted);margin-top:2px;font-size:10px;font-weight:650}.premium-plan-summary-value strong{color:var(--text-soft);font-variant-numeric:tabular-nums;margin-top:2px;font-size:12px}.premium-plan-state,.premium-plan-edit{min-width:0;color:var(--muted);white-space:nowrap;align-items:center;gap:6px;font-size:11px;font-weight:750;display:inline-flex}.premium-plan-state i{background:var(--muted-2);border-radius:50%;flex:none;width:7px;height:7px}.premium-plan-state.enabled{color:var(--good)}.premium-plan-state.enabled i{background:var(--good);box-shadow:0 0 0 3px var(--good-tint)}.premium-plan-edit{color:var(--brand);justify-content:flex-end}.premium-plan-editor{border-top:1px solid var(--line);padding:10px}.premium-plan-groups{grid-template-columns:repeat(auto-fit,minmax(235px,1fr));gap:8px;min-width:0;display:grid}.premium-plan-group{background:var(--panel-subtle);border:1px solid var(--line);border-radius:9px;min-width:0;padding:9px}.premium-plan-group-title{min-height:20px;color:var(--heading);align-items:center;gap:6px;margin-bottom:8px;font-size:11px;display:flex}.premium-plan-group-title svg{color:var(--brand)}.premium-plan-field-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;min-width:0;display:grid}.premium-plan-field{min-width:0;display:block}.premium-plan-field.wide,.premium-store-hint{grid-column:1/-1}.premium-plan-field>span{min-height:14px;color:var(--muted);text-overflow:ellipsis;white-space:nowrap;margin-bottom:3px;font-size:10px;font-weight:750;line-height:1.25;display:block;overflow:hidden}.premium-plan-field input{font-variant-numeric:tabular-nums;width:100%;min-width:0;min-height:32px;padding:5px 7px}.premium-input-affix{background:var(--input-bg);border:1px solid var(--line);border-radius:var(--radius-sm);grid-template-columns:25px minmax(0,1fr);align-items:center;display:grid;overflow:hidden}.premium-input-affix:focus-within{border-color:var(--brand);box-shadow:0 0 0 3px var(--focus)}.premium-input-affix>span{color:#e8a500;text-align:center}.premium-input-affix input{border:0;border-left:1px solid var(--line);border-radius:0}.premium-input-affix input:focus{box-shadow:none}.premium-store-hint{color:var(--muted);align-self:end;margin:0;font-size:10px;line-height:1.35}.premium-plan-footer{border-top:1px solid var(--line);flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:9px;margin-top:8px;padding-top:8px;display:flex}.premium-plan-meta{min-width:0;color:var(--muted);flex-wrap:wrap;flex:260px;gap:5px 14px;font-size:10px;display:flex}.premium-plan-meta strong{color:var(--text-soft)}.premium-plan-footer .btn{justify-content:center;min-width:118px}.premium-plan-modal{width:min(980px,100vw - 32px)}.premium-plan-modal .modal-head p{max-width:680px;color:var(--muted);margin:4px 0 0}.premium-plan-modal-body{padding:12px}.premium-plan-modal .premium-plan-groups{grid-template-columns:repeat(3,minmax(0,1fr))}.premium-plan-modal .modal-actions .btn{justify-content:center;min-width:130px}@media (width<=1180px){.premium-operation-bar{grid-template-columns:repeat(2,minmax(0,1fr))}.premium-account-picker{grid-column:1/-1}.premium-plan-card>summary{grid-template-columns:20px minmax(160px,1fr) minmax(90px,.55fr) minmax(118px,.65fr) auto minmax(96px,auto)}.premium-plan-edit{display:none}}@media (width<=820px){.premium-operation-bar{grid-template-columns:1fr}.premium-account-picker{grid-column:auto}.premium-plan-card>summary{grid-template-columns:20px minmax(0,1fr) auto;grid-template-areas:"chevron identity state"".stars fiat"".source source";gap:5px 9px;padding-block:10px}.premium-plan-chevron{grid-area:chevron}.premium-plan-identity{grid-area:identity}.premium-plan-summary-value:nth-of-type(3){grid-area:stars}.premium-plan-summary-value:nth-of-type(4){grid-area:fiat}.premium-plan-card>summary>.badge{grid-area:source;justify-self:start}.premium-plan-state{grid-area:state}.premium-plan-modal .premium-plan-groups{grid-template-columns:1fr}}@media (width<=560px){.premium-operations-compact,.premium-catalog-compact{padding:10px}.premium-quick-action{grid-template-columns:1fr}.entity-picker-dropdown .picker-results{width:100%}.entity-picker-dropdown .picker-row{grid-template-columns:78px minmax(0,1fr) minmax(0,1fr)}.entity-picker-dropdown .picker-row .badge{display:none}.premium-catalog-actions{justify-content:stretch}.premium-catalog-count{white-space:normal;width:100%}.premium-catalog-actions .btn{justify-content:center;width:100%}.premium-plan-card>summary{grid-template-columns:18px minmax(0,1fr) auto;grid-template-areas:"chevron identity state"".stars stars"".fiat fiat"".source source"}.premium-plan-summary-value{justify-content:space-between;align-items:baseline;gap:8px;display:flex}.premium-plan-summary-value small,.premium-plan-summary-value strong{margin:0}.premium-plan-groups{grid-template-columns:1fr}.premium-plan-footer{align-items:stretch}.premium-plan-footer .gift-switch,.premium-plan-footer .btn{justify-content:center;width:100%}.premium-plan-modal{width:calc(100vw - 16px)}.premium-plan-modal .modal-actions{flex-direction:column-reverse}.premium-plan-modal .modal-actions .btn{width:100%}}.compact-metrics .metric{min-height:58px;padding:10px 14px}.collectible-phone-compose{padding:14px}.compact-form-grid{grid-template-columns:minmax(150px,1.1fr) repeat(3,minmax(110px,.65fr));align-items:end;gap:10px;display:grid}.compact-form-grid .duration-field,.compact-owner-row .entity-picker{min-width:0}.compact-span-2{grid-column:span 2}.compact-owner-row{grid-template-columns:minmax(260px,1fr) auto;align-items:end;gap:12px;margin-top:10px;display:grid}.collectible-phone-filter{margin:12px 0}.selected-row{background:color-mix(in srgb, var(--accent) 7%, transparent)}.phone-action-strip{grid-template-columns:minmax(170px,.42fr) minmax(420px,1fr);align-items:end;gap:12px;margin-top:12px;padding:12px 14px;display:grid}.phone-action-summary{align-self:center;gap:3px;display:grid}.phone-action-summary span{color:var(--muted);font-size:12px}.phone-price-editor{grid-template-columns:repeat(2,minmax(130px,1fr)) auto;align-items:end;gap:8px;display:grid}.phone-transfer-editor{grid-column:1/-1;grid-template-columns:minmax(260px,1fr) auto;align-items:end;gap:12px;display:grid}@media (width<=1100px){.compact-form-grid{grid-template-columns:repeat(2,minmax(150px,1fr))}.phone-action-strip{grid-template-columns:1fr}.phone-transfer-editor{grid-column:auto}}@media (width<=680px){.compact-form-grid,.compact-owner-row,.phone-price-editor,.phone-transfer-editor{grid-template-columns:1fr}.compact-span-2{grid-column:auto}}.entity-identity,.table-identity{align-items:center;gap:12px;min-width:0;display:flex}.table-identity{gap:8px}.avatar-photo-img,.avatar-fallback{object-fit:cover;border-radius:50%;flex:none;display:block}.avatar-fallback{color:#fff;letter-spacing:-.02em;place-items:center;font-weight:800;display:grid}.modal-backdrop{z-index:10000;background:var(--overlay);-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);place-items:center;padding:24px;display:grid;position:fixed;inset:0}.modal{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius-lg);width:min(760px,100%);max-height:min(820px,100vh - 48px);box-shadow:var(--shadow);padding:0;overflow:hidden}.command-modal{flex-direction:column;display:flex}.command-modal>.modal-head,.command-modal>.modal-actions{flex:none}.modal-head{border-bottom:1px solid var(--line);justify-content:space-between;align-items:flex-start;gap:12px;padding:16px 18px 12px;display:flex}.icon-btn{width:30px;height:30px;color:var(--text-soft);background:var(--panel-subtle);border:1px solid var(--line);border-radius:var(--radius-sm);cursor:pointer;place-items:center;transition:background-color .14s,border-color .14s,color .14s;display:grid}.icon-btn:hover{background:var(--btn-hover);border-color:var(--line-strong)}.command-steps{grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;display:grid}.command-body{grid-auto-rows:max-content;gap:12px;min-height:0;padding:14px 18px;display:grid;overflow:auto}.command-step{min-height:38px;color:var(--muted);background:var(--panel-subtle);border:1px solid var(--line);border-radius:var(--radius-sm);align-items:center;gap:8px;padding:0 10px;display:flex}.command-step span{background:var(--panel);border:1px solid var(--line);border-radius:999px;place-items:center;width:20px;height:20px;font-size:11px;font-weight:800;display:grid}.command-step.active{color:var(--brand);border-color:var(--brand-tint-border)}.command-step.done{color:var(--good);border-color:var(--good-border)}.form-field{gap:6px;display:grid}.form-field span,.form-stack span{color:var(--text-soft);font-weight:800}.form-field input:disabled,.form-field textarea:disabled{opacity:.6;cursor:not-allowed}.command-preview{gap:8px;display:grid}.command-preview .json-block{max-height:150px}.preview-head,.result-title{color:var(--text-soft);align-items:center;gap:7px;font-weight:800;display:flex}.result-box{background:var(--panel-subtle);border:1px solid var(--line);border-radius:var(--radius);gap:8px;padding:10px;display:grid}.result-line{grid-template-columns:92px minmax(0,1fr);gap:8px;display:grid}.result-line span{color:var(--muted)}.result-line strong{overflow-wrap:anywhere}.result-message{color:var(--text-soft)}.modal-actions{background:var(--panel);border-top:1px solid var(--line);justify-content:flex-end;padding:12px 18px}.login-page{background:var(--bg);place-items:center;min-height:100vh;padding:24px;display:grid}.login-panel{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius-lg);width:min(420px,100%);box-shadow:var(--shadow);gap:18px;padding:22px;display:grid}.login-head{justify-content:space-between;align-items:center;gap:12px;display:flex}.login-head-actions{flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:8px;display:flex}.login-chip{min-height:24px;color:var(--brand);background:var(--brand-tint);border:1px solid var(--brand-tint-border);border-radius:999px;align-items:center;padding:0 8px;font-size:12px;display:inline-flex}.login-copy h1{color:var(--heading);margin:0;font-size:22px}.login-copy p{color:var(--muted);margin:8px 0 0}.form-stack{gap:12px;display:grid}.form-stack label{gap:6px;display:grid}.form-stack input{width:100%}.boot-screen{background:var(--bg);align-content:center;place-items:center;gap:18px;min-height:100vh;display:grid}.loader-bar{background:var(--line-strong);border-radius:999px;width:180px;height:4px;overflow:hidden}.loader-bar:before{content:"";background:var(--brand);width:42%;height:100%;animation:1s ease-in-out infinite load;display:block}.spin{animation:.8s linear infinite spin}@keyframes load{0%{transform:translate(-120%)}to{transform:translate(260%)}}@keyframes spin{to{transform:rotate(360deg)}}.secret-reveal{background:var(--warn-tint);border:1px solid var(--warn-border);border-radius:var(--radius);gap:6px;padding:10px;display:grid}.secret-reveal-label{color:var(--warn);align-items:center;gap:6px;font-size:12px;font-weight:800;display:flex}.secret-reveal-row{align-items:center;gap:10px;display:flex}.secret-reveal-value{color:var(--text-soft);letter-spacing:.12em;background:var(--panel);border:1px solid var(--line);border-radius:var(--radius-sm);text-overflow:ellipsis;white-space:nowrap;flex:auto;padding:6px 10px;overflow:hidden}@media (width<=1120px){.shell{grid-template-columns:1fr}.sidebar{height:auto;position:static}.nav-list{grid-template-columns:repeat(4,minmax(0,1fr))}.sidebar-status{display:none}.overview-band,.split-layout,.operation-row,.raw-grid,.message-selector-grid,.message-selector-grid.single{grid-template-columns:1fr}.action-dock{position:static}}@media (width<=760px){.content,.topbar{padding-left:14px;padding-right:14px}.command-grid,.work-strip,.overview-metrics,.metric-row,.summary-grid,.command-steps{grid-template-columns:1fr}.sidebar{gap:12px;padding:14px}.nav-list{grid-template-columns:repeat(2,minmax(0,1fr))}.topbar,.page-title-row,.entity-head{flex-direction:column;align-items:flex-start}input,.searchbox{width:100%}.toolbar{align-items:stretch}.picker-row,.selected-entity{grid-template-columns:1fr}} diff --git a/cmd/telesrv-admin/web/src/api.ts b/cmd/telesrv-admin/web/src/api.ts index 0cbe982c..eb6f291b 100644 --- a/cmd/telesrv-admin/web/src/api.ts +++ b/cmd/telesrv-admin/web/src/api.ts @@ -242,6 +242,7 @@ export const api = { gifCatalogDocumentPreviewURL: (documentID: string) => `/api/gif-catalog/documents/${encodeURIComponent(documentID)}/preview`, createStickerSet: (form: FormData) => request("/api/actions/create-sticker-set", { method: "POST", body: form }), setAccountAvatar: (form: FormData) => request("/api/actions/set-account-avatar", { method: "POST", body: form }), + setAccountAvatarVideo: (form: FormData) => request("/api/actions/set-account-avatar-video", { method: "POST", body: form }), setChannelAvatar: (form: FormData) => request("/api/actions/set-channel-avatar", { method: "POST", body: form }), addStickerToSet: (form: FormData) => request("/api/actions/add-sticker-to-set", { method: "POST", body: form }), gifCatalog: () => request("/api/gif-catalog"), diff --git a/cmd/telesrv-admin/web/src/components/AvatarModal.tsx b/cmd/telesrv-admin/web/src/components/AvatarModal.tsx index 2262bf28..22ce199c 100644 --- a/cmd/telesrv-admin/web/src/components/AvatarModal.tsx +++ b/cmd/telesrv-admin/web/src/components/AvatarModal.tsx @@ -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(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 = { 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
+ {isVideo && ( + + )} {error && {error}}
diff --git a/deploy/migrations/0181_sticker_set_system_key_unique.down.sql b/deploy/migrations/0181_sticker_set_system_key_unique.down.sql new file mode 100644 index 00000000..87c7ac57 --- /dev/null +++ b/deploy/migrations/0181_sticker_set_system_key_unique.down.sql @@ -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; diff --git a/deploy/migrations/0181_sticker_set_system_key_unique.up.sql b/deploy/migrations/0181_sticker_set_system_key_unique.up.sql new file mode 100644 index 00000000..2f36de1a --- /dev/null +++ b/deploy/migrations/0181_sticker_set_system_key_unique.up.sql @@ -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; diff --git a/deploy/migrations/20260908000001_phone_identity_canonicalization.down.sql b/deploy/migrations/20260908000001_phone_identity_canonicalization.down.sql new file mode 100644 index 00000000..0380d647 --- /dev/null +++ b/deploy/migrations/20260908000001_phone_identity_canonicalization.down.sql @@ -0,0 +1,3 @@ +-- E.164 canonicalization is intentionally irreversible: the removed national +-- trunk presentation is not part of the account identity. +SELECT 1; diff --git a/deploy/migrations/20260908000001_phone_identity_canonicalization.up.sql b/deploy/migrations/20260908000001_phone_identity_canonicalization.up.sql new file mode 100644 index 00000000..9bb280ea --- /dev/null +++ b/deploy/migrations/20260908000001_phone_identity_canonicalization.up.sql @@ -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; diff --git a/deploy/migrations/20260908000002_private_external_reply.down.sql b/deploy/migrations/20260908000002_private_external_reply.down.sql new file mode 100644 index 00000000..ede4cb7d --- /dev/null +++ b/deploy/migrations/20260908000002_private_external_reply.down.sql @@ -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; diff --git a/deploy/migrations/20260908000002_private_external_reply.up.sql b/deploy/migrations/20260908000002_private_external_reply.up.sql new file mode 100644 index 00000000..27b7ee53 --- /dev/null +++ b/deploy/migrations/20260908000002_private_external_reply.up.sql @@ -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); diff --git a/go.mod b/go.mod index 113f9586..1a7c0bf9 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/internal/admin/service.go b/internal/admin/service.go index 920f00cf..326287bd 100644 --- a/internal/admin/service.go +++ b/internal/admin/service.go @@ -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 diff --git a/internal/adminapi/server.go b/internal/adminapi/server.go index 4262389d..f80af86c 100644 --- a/internal/adminapi/server.go +++ b/internal/adminapi/server.go @@ -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 } diff --git a/internal/adminapi/server_test.go b/internal/adminapi/server_test.go index 5d690b46..b69a7ad9 100644 --- a/internal/adminapi/server_test.go +++ b/internal/adminapi/server_test.go @@ -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 } diff --git a/internal/app/account/lifecycle.go b/internal/app/account/lifecycle.go index 4023be40..da7b2b04 100644 --- a/internal/app/account/lifecycle.go +++ b/internal/app/account/lifecycle.go @@ -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{ diff --git a/internal/app/bots/botfather.go b/internal/app/bots/botfather.go index d1efb819..8a545f08 100644 --- a/internal/app/bots/botfather.go +++ b/internal/app/bots/botfather.go @@ -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: diff --git a/internal/app/bots/chatbot.go b/internal/app/bots/chatbot.go index 9f5ff3ce..fa88acc4 100644 --- a/internal/app/bots/chatbot.go +++ b/internal/app/bots/chatbot.go @@ -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 ( diff --git a/internal/app/bots/stickersbot.go b/internal/app/bots/stickersbot.go index 4d5d920d..624a4186 100644 --- a/internal/app/bots/stickersbot.go +++ b/internal/app/bots/stickersbot.go @@ -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: diff --git a/internal/app/bots/verifierbot.go b/internal/app/bots/verifierbot.go index 097f6d3f..d42f94db 100644 --- a/internal/app/bots/verifierbot.go +++ b/internal/app/bots/verifierbot.go @@ -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 != "" { diff --git a/internal/app/bots/verifybot.go b/internal/app/bots/verifybot.go index da4438d7..d0564604 100644 --- a/internal/app/bots/verifybot.go +++ b/internal/app/bots/verifybot.go @@ -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 diff --git a/internal/app/botverification/seed.go b/internal/app/botverification/seed.go index ad9839a7..5ed2936f 100644 --- a/internal/app/botverification/seed.go +++ b/internal/app/botverification/seed.go @@ -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, diff --git a/internal/app/files/blobcache.go b/internal/app/files/blobcache.go index 3837dda6..df84ba8a 100644 --- a/internal/app/files/blobcache.go +++ b/internal/app/files/blobcache.go @@ -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 } diff --git a/internal/app/files/diskspace.go b/internal/app/files/diskspace.go index 3920de42..2044f4c1 100644 --- a/internal/app/files/diskspace.go +++ b/internal/app/files/diskspace.go @@ -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 diff --git a/internal/app/files/guarded_backend.go b/internal/app/files/guarded_backend.go new file mode 100644 index 00000000..7987ec24 --- /dev/null +++ b/internal/app/files/guarded_backend.go @@ -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) +} diff --git a/internal/app/files/photos.go b/internal/app/files/photos.go index 49745d33..965cc731 100644 --- a/internal/app/files/photos.go +++ b/internal/app/files/photos.go @@ -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 } diff --git a/internal/app/files/seed_test.go b/internal/app/files/seed_test.go index 17ba6cd9..32686049 100644 --- a/internal/app/files/seed_test.go +++ b/internal/app/files/seed_test.go @@ -15,6 +15,7 @@ import ( "time" "telesrv/internal/domain" + "telesrv/internal/store" ) // fakeMediaStore 是 store.MediaStore 的内存替身,用于在无 PG 时验证 seed 导入器。 @@ -29,20 +30,27 @@ 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 { return &fakeMediaStore{ - blobs: map[string]domain.FileBlob{}, - docs: map[int64]domain.Document{}, - photos: map[int64]domain.Photo{}, - sets: map[int64]domain.StickerSet{}, - parts: map[string][]domain.UploadPart{}, - seedState: map[string]string{}, - receipts: map[string]domain.UploadedMediaReceipt{}, + blobs: map[string]domain.FileBlob{}, + docs: map[int64]domain.Document{}, + photos: map[int64]domain.Photo{}, + sets: map[int64]domain.StickerSet{}, + 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) { - return 0, false, nil +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) { diff --git a/internal/app/files/upload_parts_test.go b/internal/app/files/upload_parts_test.go index 3ead4afd..9ca421a3 100644 --- a/internal/app/files/upload_parts_test.go +++ b/internal/app/files/upload_parts_test.go @@ -256,7 +256,9 @@ func TestMaxUploadFileBytesUnlimitedByDefault(t *testing.T) { type countingUploadPartBackend struct { *LocalFS - getUploadPartCalls int + 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()) diff --git a/internal/app/langpack/service.go b/internal/app/langpack/service.go index 2514fe88..feb1a56c 100644 --- a/internal/app/langpack/service.go +++ b/internal/app/langpack/service.go @@ -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(), } } diff --git a/internal/app/passkey/service.go b/internal/app/passkey/service.go index bb25262a..f4e5f736 100644 --- a/internal/app/passkey/service.go +++ b/internal/app/passkey/service.go @@ -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, diff --git a/internal/app/phone/dh.go b/internal/app/phone/dh.go index 2beda466..5b752ad6 100644 --- a/internal/app/phone/dh.go +++ b/internal/app/phone/dh.go @@ -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。与官方一致取 3:TDesktop MTP::IsPrimeAndGood 对 // 「官方 2048-bit prime + g∈{3,4,5,7}」有白名单快速通过路径,DrKLO native 同。 diff --git a/internal/app/privacy/service.go b/internal/app/privacy/service.go index af20d574..33418adc 100644 --- a/internal/app/privacy/service.go +++ b/internal/app/privacy/service.go @@ -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, diff --git a/internal/app/users/service.go b/internal/app/users/service.go index 5df14e56..737ee35a 100644 --- a/internal/app/users/service.go +++ b/internal/app/users/service.go @@ -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 { diff --git a/internal/branding/branding.go b/internal/branding/branding.go index 0a08920d..ea88d8e3 100644 --- a/internal/branding/branding.go +++ b/internal/branding/branding.go @@ -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 +} diff --git a/internal/branding/branding_test.go b/internal/branding/branding_test.go index a4606635..58086aa0 100644 --- a/internal/branding/branding_test.go +++ b/internal/branding/branding_test.go @@ -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") + } + }) + } +} diff --git a/internal/compat/tdesktop/startup_stubs.go b/internal/compat/tdesktop/startup_stubs.go index 66dcbeb1..cc53fa6d 100644 --- a/internal/compat/tdesktop/startup_stubs.go +++ b/internal/compat/tdesktop/startup_stubs.go @@ -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: diff --git a/internal/config/config.go b/internal/config/config.go index 70fb5549..b4febedc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"), diff --git a/internal/domain/account.go b/internal/domain/account.go index fca65d27..8a5d1d18 100644 --- a/internal/domain/account.go +++ b/internal/domain/account.go @@ -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 长度, -// 但拒绝空串、非法字符和会截断的超长输入。 -func ValidPhone(phone string) bool { - if len(phone) < 5 || len(phone) > 200 { - return false - } +// 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 + } 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 } diff --git a/internal/domain/authorization.go b/internal/domain/authorization.go index 2594853d..2174940d 100644 --- a/internal/domain/authorization.go +++ b/internal/domain/authorization.go @@ -43,4 +43,7 @@ type AuthKeyClientInfo struct { SystemVersion string APIID int AppVersion string + // IP 是最近一次会话建立的客户端对端地址(host-only)。只做 metadata 级别的 + // 合并刷新,绝不当作登录/绑定的身份证据,也不会触碰 created_at。 + IP string } diff --git a/internal/domain/emailphone_test.go b/internal/domain/emailphone_test.go index e6d4ceac..e5c2a4a2 100644 --- a/internal/domain/emailphone_test.go +++ b/internal/domain/emailphone_test.go @@ -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) } } } diff --git a/internal/domain/login_welcome_template_test.go b/internal/domain/login_welcome_template_test.go index 6da866c8..5bc19a19 100644 --- a/internal/domain/login_welcome_template_test.go +++ b/internal/domain/login_welcome_template_test.go @@ -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") diff --git a/internal/domain/media_category.go b/internal/domain/media_category.go index 50cd5a33..440c8d1f 100644 --- a/internal/domain/media_category.go +++ b/internal/domain/media_category.go @@ -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 diff --git a/internal/domain/message.go b/internal/domain/message.go index c52df48c..48888e11 100644 --- a/internal/domain/message.go +++ b/internal/domain/message.go @@ -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 为 0,Peer 为 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 @@ -716,11 +724,13 @@ type PinPrivateMessageRequest struct { Pinned bool // PmOneside 仅置顶在本侧(官方私聊置顶框"同时为对方置顶"未勾选时), // 不向对端翻转、不生成服务消息。unpin 无此语义,恒双侧清除。 - PmOneside bool - Silent bool - Date int - OriginAuthKeyID [8]byte - OriginSessionID int64 + PmOneside bool + Silent bool + // RecipientBlocked applies the normal private-service delivery policy. + RecipientBlocked bool + Date int + OriginAuthKeyID [8]byte + OriginSessionID int64 } // PinnedMessagesForUser 描述置顶状态变化对某个 owner 视角的影响。 diff --git a/internal/domain/message_errors.go b/internal/domain/message_errors.go index f7b6e9d6..5afb0e7a 100644 --- a/internal/domain/message_errors.go +++ b/internal/domain/message_errors.go @@ -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:收藏夹子会话置顶 diff --git a/internal/domain/message_reply_external.go b/internal/domain/message_reply_external.go new file mode 100644 index 00000000..5cde816b --- /dev/null +++ b/internal/domain/message_reply_external.go @@ -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 + } +} diff --git a/internal/domain/message_reply_external_test.go b/internal/domain/message_reply_external_test.go new file mode 100644 index 00000000..38a05196 --- /dev/null +++ b/internal/domain/message_reply_external_test.go @@ -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) + } +} diff --git a/internal/domain/phone_identity_test.go b/internal/domain/phone_identity_test.go new file mode 100644 index 00000000..6b39213c --- /dev/null +++ b/internal/domain/phone_identity_test.go @@ -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) + } + } +} diff --git a/internal/domain/privacy_evaluate.go b/internal/domain/privacy_evaluate.go new file mode 100644 index 00000000..acba7de7 --- /dev/null +++ b/internal/domain/privacy_evaluate.go @@ -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 +} diff --git a/internal/domain/system.go b/internal/domain/system.go index cb1344be..f4d0cf10 100644 --- a/internal/domain/system.go +++ b/internal/domain/system.go @@ -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 结尾属种子例外(与官方一致)。 diff --git a/internal/loadharness/group_media_load_test.go b/internal/loadharness/group_media_load_test.go new file mode 100644 index 00000000..0b9bfa2e --- /dev/null +++ b/internal/loadharness/group_media_load_test.go @@ -0,0 +1,1393 @@ +package loadharness + +import ( + "context" + "crypto/md5" + cryptorand "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "hash" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/iamxvbaba/td/telegram" + "github.com/iamxvbaba/td/tg" +) + +// TestGroupMediaDownloadLoad exercises the complete real-wire group media path: +// authenticated members come online, multiple senders concurrently upload and +// send photo/video/audio/voice/document messages, every member treats the live +// channel delivery signal (or a too-long nudge) only as a reason to call +// getChannelDifference, and then downloads each file from the location in its +// own difference result. +// +// The test is deliberately opt-in because it requires an owner-only account +// bundle and a pre-seeded exact-membership supergroup. It never imports server +// handlers or writes database fixtures. +func TestGroupMediaDownloadLoad(t *testing.T) { + if os.Getenv("TELESRV_GROUP_MEDIA_LOAD") != "1" { + t.Skip("set TELESRV_GROUP_MEDIA_LOAD=1 to run the real-wire group media load") + } + cfg := loadGroupMediaConfig(t) + report, err := runGroupMediaLoad(context.Background(), cfg, t.Logf) + if writeErr := writeGroupMediaReport(cfg.ReportPath, report); writeErr != nil { + t.Fatalf("write report: %v", writeErr) + } + if err != nil { + t.Fatalf("group media load: %v (report %s)", err, cfg.ReportPath) + } + if !report.Pass { + t.Fatalf("group media acceptance failed: %v (report %s)", report.Failures, cfg.ReportPath) + } +} + +type groupMediaConfig struct { + ManifestPath string + SessionKeyPath string + DatasetPath string + SeedStatePath string + RSAKeyOverride string + ReportPath string + ServerMetricsURL string + Media []groupMediaSource + Accounts int + GroupPosition int + Copies int + ChunkBytes int + RampDuration time.Duration + ReadyTimeout time.Duration + OperationTimeout time.Duration + FanoutTimeout time.Duration + DownloadTimeout time.Duration + RecoveryDuration time.Duration +} + +type groupMediaSource struct { + Kind string + Path string + Name string + MimeType string + Data []byte +} + +type groupMediaTarget struct { + Kind string + Marker string + Size int64 + Location tg.InputFileLocationClass + SHA256 [sha256.Size]byte +} + +type groupMediaTypeReport struct { + Kind string `json:"kind"` + SourceBytes int64 `json:"source_bytes"` + MessagesExpected int `json:"messages_expected"` + MessagesSent int `json:"messages_sent"` + DownloadFileBytes int64 `json:"download_file_bytes"` + DownloadsExpected int `json:"downloads_expected"` + DownloadsComplete int64 `json:"downloads_complete"` + DownloadedBytes int64 `json:"downloaded_bytes"` + DownloadErrors int64 `json:"download_errors"` +} + +type groupMediaFanoutReport struct { + Messages int `json:"messages"` + Expected int64 `json:"expected"` + Observed int64 `json:"observed"` + Missing int64 `json:"missing"` + Duplicate int64 `json:"duplicate"` + MissingByMessage map[string]int `json:"missing_by_message,omitempty"` + MissingMemberSlots []int `json:"missing_member_slots,omitempty"` + P50MS float64 `json:"p50_ms"` + P95MS float64 `json:"p95_ms"` + P99MS float64 `json:"p99_ms"` + MaxMS float64 `json:"max_ms"` +} + +type groupMediaLoadReport struct { + Version int `json:"version"` + StartedAt time.Time `json:"started_at"` + LoadEndedAt time.Time `json:"load_ended_at"` + FinishedAt time.Time `json:"finished_at"` + Accounts int `json:"accounts"` + ExactGroupMembers int `json:"exact_group_members"` + PeakReady int64 `json:"peak_ready"` + FinalReady int64 `json:"final_ready"` + ConnectionAttempts uint64 `json:"connection_attempts"` + Reconnects uint64 `json:"reconnects"` + Disconnects uint64 `json:"disconnects"` + FatalClients uint64 `json:"fatal_clients"` + ChannelNudges uint64 `json:"channel_nudges"` + ChannelLiveUpdates uint64 `json:"channel_live_updates"` + UploadDurationMS float64 `json:"upload_duration_ms"` + FanoutSettleMS float64 `json:"fanout_settle_ms"` + DownloadDurationMS float64 `json:"download_duration_ms"` + DownloadThroughputMiB float64 `json:"download_throughput_mib_s"` + DownloadedBytes int64 `json:"downloaded_bytes"` + Media []groupMediaTypeReport `json:"media"` + Fanout groupMediaFanoutReport `json:"fanout"` + Operations map[string]OperationReport `json:"operations"` + BaselineServerMetrics map[string]float64 `json:"baseline_server_metrics,omitempty"` + PeakServerMetrics map[string]float64 `json:"peak_server_metrics,omitempty"` + LoadEndServerMetrics map[string]float64 `json:"load_end_server_metrics,omitempty"` + FinalServerMetrics map[string]float64 `json:"final_server_metrics,omitempty"` + ServerMetricsScrapes uint64 `json:"server_metrics_scrapes"` + ServerMetricsErrors uint64 `json:"server_metrics_errors"` + Pass bool `json:"pass"` + Failures []string `json:"failures,omitempty"` +} + +type groupMediaCounters struct { + connectionAttempts atomic.Uint64 + reconnects atomic.Uint64 + disconnects atomic.Uint64 + fatalClients atomic.Uint64 + channelNudges atomic.Uint64 + channelLiveUpdates atomic.Uint64 + differenceFailures atomic.Uint64 + ready atomic.Int64 + peakReady atomic.Int64 +} + +type groupMediaClient struct { + record SessionRecord + raw atomic.Pointer[tg.Client] + cancel context.CancelFunc + done chan error + ready atomic.Bool + channelPts atomic.Int64 + differenceTargetPts atomic.Int64 + differenceRequested atomic.Bool + differenceForce atomic.Bool + differenceWake chan struct{} +} + +type groupMediaFanoutTracker struct { + mu sync.Mutex + prefix string + members map[int64]int + expected map[string]time.Time + committed map[string]bool + observations map[string]map[int64]groupMediaObservation +} + +type groupMediaObservation struct { + first time.Time + repeat int64 + target groupMediaTarget +} + +type groupMediaSampler struct { + mu sync.Mutex + client *serverMetricsClient + peak map[string]float64 +} + +func loadGroupMediaConfig(t *testing.T) groupMediaConfig { + t.Helper() + required := func(name string) string { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + t.Fatalf("%s is required", name) + } + return value + } + media := []groupMediaSource{ + {Kind: "photo", Path: required("TELESRV_GROUP_MEDIA_PHOTO"), Name: "load-photo.jpg", MimeType: "image/jpeg"}, + {Kind: "video", Path: required("TELESRV_GROUP_MEDIA_VIDEO"), Name: "load-video.mp4", MimeType: "video/mp4"}, + {Kind: "audio", Path: required("TELESRV_GROUP_MEDIA_AUDIO"), Name: "load-audio.mp3", MimeType: "audio/mpeg"}, + {Kind: "voice", Path: required("TELESRV_GROUP_MEDIA_VOICE"), Name: "load-voice.ogg", MimeType: "audio/ogg"}, + {Kind: "document", Path: required("TELESRV_GROUP_MEDIA_DOCUMENT"), Name: "load-document.bin", MimeType: "application/octet-stream"}, + } + for i := range media { + data, err := os.ReadFile(media[i].Path) + if err != nil { + t.Fatalf("read %s fixture: %v", media[i].Kind, err) + } + if len(data) == 0 || len(data) > 64<<20 { + t.Fatalf("%s fixture size %d is outside (0,64MiB]", media[i].Kind, len(data)) + } + media[i].Data = data + } + return groupMediaConfig{ + ManifestPath: required("TELESRV_GROUP_MEDIA_MANIFEST"), SessionKeyPath: required("TELESRV_GROUP_MEDIA_SESSION_KEY"), + DatasetPath: required("TELESRV_GROUP_MEDIA_DATASET"), SeedStatePath: required("TELESRV_GROUP_MEDIA_SEED_STATE"), + RSAKeyOverride: strings.TrimSpace(os.Getenv("TELESRV_GROUP_MEDIA_RSA_KEY")), + ReportPath: required("TELESRV_GROUP_MEDIA_REPORT"), ServerMetricsURL: groupMediaEnv("TELESRV_GROUP_MEDIA_SERVER_METRICS", "http://127.0.0.1:6060/metrics"), + Media: media, Accounts: groupMediaEnvInt(t, "TELESRV_GROUP_MEDIA_ACCOUNTS", 2000), + GroupPosition: groupMediaEnvInt(t, "TELESRV_GROUP_MEDIA_GROUP_POSITION", 0), Copies: groupMediaEnvInt(t, "TELESRV_GROUP_MEDIA_COPIES", 2), + ChunkBytes: groupMediaEnvInt(t, "TELESRV_GROUP_MEDIA_CHUNK_BYTES", 128<<10), + RampDuration: groupMediaEnvDuration(t, "TELESRV_GROUP_MEDIA_RAMP", 60*time.Second), + ReadyTimeout: groupMediaEnvDuration(t, "TELESRV_GROUP_MEDIA_READY_TIMEOUT", 5*time.Minute), + OperationTimeout: groupMediaEnvDuration(t, "TELESRV_GROUP_MEDIA_OPERATION_TIMEOUT", 30*time.Second), + FanoutTimeout: groupMediaEnvDuration(t, "TELESRV_GROUP_MEDIA_FANOUT_TIMEOUT", 90*time.Second), + DownloadTimeout: groupMediaEnvDuration(t, "TELESRV_GROUP_MEDIA_DOWNLOAD_TIMEOUT", 15*time.Minute), + RecoveryDuration: groupMediaEnvDuration(t, "TELESRV_GROUP_MEDIA_RECOVERY", 7*time.Minute), + } +} + +func groupMediaEnv(name, fallback string) string { + if value := strings.TrimSpace(os.Getenv(name)); value != "" { + return value + } + return fallback +} + +func groupMediaEnvInt(t *testing.T, name string, fallback int) int { + t.Helper() + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback + } + parsed, err := strconv.Atoi(value) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + return parsed +} + +func groupMediaEnvDuration(t *testing.T, name string, fallback time.Duration) time.Duration { + t.Helper() + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback + } + parsed, err := time.ParseDuration(value) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + return parsed +} + +func runGroupMediaLoad(ctx context.Context, cfg groupMediaConfig, logf func(string, ...any)) (*groupMediaLoadReport, error) { + report := &groupMediaLoadReport{Version: 2, StartedAt: time.Now().UTC(), Accounts: cfg.Accounts} + if cfg.Accounts < 2 || cfg.Copies < 1 || cfg.Copies > 20 || cfg.ChunkBytes <= 0 || cfg.ChunkBytes > 1<<20 || + cfg.RampDuration < 0 || cfg.ReadyTimeout <= 0 || cfg.OperationTimeout <= 0 || cfg.FanoutTimeout <= 0 || cfg.DownloadTimeout <= 0 || cfg.RecoveryDuration < 0 { + return report, errors.New("invalid group media load configuration") + } + manifest, err := LoadManifest(cfg.ManifestPath) + if err != nil { + return report, err + } + dataset, err := LoadDataset(cfg.DatasetPath) + if err != nil { + return report, err + } + seedState, err := LoadDatasetSeedState(cfg.SeedStatePath, dataset) + if err != nil { + return report, err + } + if cfg.GroupPosition < 0 || cfg.GroupPosition >= len(dataset.Groups) { + return report, errors.New("group position is outside the dataset") + } + group := dataset.Groups[cfg.GroupPosition] + groupState := seedState.Groups[cfg.GroupPosition] + if len(group.MemberAccounts) != cfg.Accounts || groupState.ChannelID <= 0 || groupState.AccessHash == 0 || groupState.InviteCursor != len(group.MemberAccounts)-1 { + return report, fmt.Errorf("group is not an exact, fully seeded %d-member supergroup", cfg.Accounts) + } + report.ExactGroupMembers = len(group.MemberAccounts) + primaries := primaryTargets(manifest.Sessions) + records := make([]SessionRecord, 0, len(group.MemberAccounts)) + for _, account := range group.MemberAccounts { + if account < 0 || account >= len(primaries) || primaries[account].AccountIndex != account { + return report, fmt.Errorf("manifest has no primary session for member account %d", account) + } + records = append(records, primaries[account]) + } + key, err := LoadSessionKey(cfg.SessionKeyPath) + if err != nil { + return report, err + } + publicKey, err := loadManifestPublicKey(cfg.ManifestPath, manifest.Endpoint, cfg.RSAKeyOverride) + if err != nil { + return report, err + } + metrics := newMetricSet("auth.status", "updates.getState", "channels.getFullChannel", "updates.getChannelDifference", "upload.saveFilePart", "messages.sendMedia", "upload.getFile.canonical", "upload.getFile") + serverMetrics := newServerMetricsClient(cfg.ServerMetricsURL) + baseline, err := serverMetrics.scrape(ctx) + if err != nil { + return report, fmt.Errorf("baseline server metrics: %w", err) + } + report.BaselineServerMetrics = baseline + sampler := &groupMediaSampler{client: serverMetrics, peak: cloneMetricMap(baseline)} + sampleCtx, stopSampler := context.WithCancel(ctx) + defer stopSampler() + go sampler.run(sampleCtx, 2*time.Second) + + memberIDs := make(map[int64]int, len(records)) + for slot, record := range records { + memberIDs[record.UserID] = slot + } + runID := fmt.Sprintf("%d", report.StartedAt.UnixNano()) + fanout := &groupMediaFanoutTracker{ + prefix: "telesrv-group-media/" + runID + "/", members: memberIDs, + expected: make(map[string]time.Time), committed: make(map[string]bool), observations: make(map[string]map[int64]groupMediaObservation), + } + counters := &groupMediaCounters{} + clients, err := startGroupMediaClients(ctx, cfg, manifest.Endpoint, cfg.ManifestPath, records, key, publicKey, groupState, metrics, fanout, counters, logf) + if err != nil { + stopGroupMediaClients(clients) + return report, err + } + report.PeakReady = counters.peakReady.Load() + report.FinalReady = counters.ready.Load() + logf("group media clients ready=%d/%d", report.FinalReady, cfg.Accounts) + + uploadStarted := time.Now() + targets, typeReports, uploadErrs := uploadGroupMedia(ctx, cfg, clients, groupState, fanout, metrics, runID) + report.UploadDurationMS = durationMS(time.Since(uploadStarted)) + report.Media = typeReports + for _, uploadErr := range uploadErrs { + report.Failures = append(report.Failures, uploadErr.Error()) + } + if len(targets) > 0 { + canonicalRaw := clients[0].raw.Load() + for i := range targets { + digest, _, downloadErr := downloadGroupMediaFile(ctx, canonicalRaw, targets[i], cfg.ChunkBytes, cfg.OperationTimeout, metrics, "upload.getFile.canonical") + if downloadErr != nil { + report.Failures = append(report.Failures, fmt.Sprintf("canonical %s download: %v", targets[i].Kind, downloadErr)) + continue + } + targets[i].SHA256 = digest + } + } + + settleStarted := time.Now() + fanout.wait(cfg.FanoutTimeout) + report.FanoutSettleMS = durationMS(time.Since(settleStarted)) + report.Fanout = fanout.report() + if report.Fanout.Missing != 0 { + report.Failures = append(report.Failures, fmt.Sprintf("group fanout missing %d/%d observations", report.Fanout.Missing, report.Fanout.Expected)) + } + if report.Fanout.Duplicate != 0 { + report.Failures = append(report.Failures, fmt.Sprintf("group difference repeated %d marker observations", report.Fanout.Duplicate)) + } + + downloadStarted := time.Now() + downloadCtx, cancelDownload := context.WithTimeout(ctx, cfg.DownloadTimeout) + downloaded, downloadErrs, targetErr := runGroupMediaDownloads(downloadCtx, clients, targets, cfg, report.Media, fanout, metrics) + cancelDownload() + if targetErr != nil { + report.Failures = append(report.Failures, targetErr.Error()) + } + report.DownloadDurationMS = durationMS(time.Since(downloadStarted)) + report.DownloadedBytes = downloaded + if elapsed := time.Since(downloadStarted).Seconds(); elapsed > 0 { + report.DownloadThroughputMiB = float64(downloaded) / (1024 * 1024) / elapsed + } + if downloadErrs > 0 { + report.Failures = append(report.Failures, fmt.Sprintf("group downloads failed %d files", downloadErrs)) + } + for _, media := range report.Media { + if media.MessagesSent != media.MessagesExpected { + report.Failures = append(report.Failures, fmt.Sprintf("%s messages sent=%d want=%d", media.Kind, media.MessagesSent, media.MessagesExpected)) + } + if media.DownloadsComplete != int64(media.DownloadsExpected) { + report.Failures = append(report.Failures, fmt.Sprintf("%s downloads complete=%d want=%d", media.Kind, media.DownloadsComplete, media.DownloadsExpected)) + } + } + report.LoadEndedAt = time.Now().UTC() + loadEnd, scrapeErr := serverMetrics.scrape(ctx) + if scrapeErr != nil { + report.Failures = append(report.Failures, fmt.Sprintf("load-end metrics: %v", scrapeErr)) + } + report.LoadEndServerMetrics = loadEnd + report.Operations = metrics.freeze() + report.ConnectionAttempts = counters.connectionAttempts.Load() + report.Reconnects = counters.reconnects.Load() + report.Disconnects = counters.disconnects.Load() + report.FatalClients = counters.fatalClients.Load() + report.ChannelNudges = counters.channelNudges.Load() + report.ChannelLiveUpdates = counters.channelLiveUpdates.Load() + if report.PeakReady != int64(cfg.Accounts) || report.FinalReady != int64(cfg.Accounts) { + report.Failures = append(report.Failures, fmt.Sprintf("ready sessions peak/final=%d/%d want=%d", report.PeakReady, report.FinalReady, cfg.Accounts)) + } + if report.FatalClients != 0 || report.Disconnects != 0 { + report.Failures = append(report.Failures, fmt.Sprintf("client fatal/disconnect=%d/%d", report.FatalClients, report.Disconnects)) + } + if failures := counters.differenceFailures.Load(); failures != 0 { + report.Failures = append(report.Failures, fmt.Sprintf("channel difference failures=%d", failures)) + } + for name, operation := range report.Operations { + if operation.Errors != 0 || operation.FloodWaits != 0 || operation.Timeouts != 0 || operation.ConnectionErrors != 0 { + report.Failures = append(report.Failures, fmt.Sprintf("%s errors/flood/timeouts/connection=%d/%d/%d/%d", name, operation.Errors, operation.FloodWaits, operation.Timeouts, operation.ConnectionErrors)) + } + } + if metricDelta(baseline, loadEnd, "telesrv_rpc_db_errors_total") > 0 { + report.Failures = append(report.Failures, "server reported database errors during load") + } + + stopGroupMediaClients(clients) + if cfg.RecoveryDuration > 0 { + deadline := time.NewTimer(cfg.RecoveryDuration) + ticker := time.NewTicker(10 * time.Second) + defer deadline.Stop() + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return report, ctx.Err() + case <-ticker.C: + _, _ = serverMetrics.scrape(ctx) + case <-deadline.C: + goto recovered + } + } + } +recovered: + stopSampler() + report.FinalServerMetrics, _ = serverMetrics.scrape(ctx) + report.PeakServerMetrics = sampler.snapshot() + report.ServerMetricsScrapes = serverMetrics.success.Load() + report.ServerMetricsErrors = serverMetrics.errors.Load() + if report.ServerMetricsErrors != 0 { + report.Failures = append(report.Failures, fmt.Sprintf("server metrics errors=%d", report.ServerMetricsErrors)) + } + if cfg.RecoveryDuration >= 6*time.Minute && metricValue(report.FinalServerMetrics, "telesrv_mtproto_raw_connections") != 0 { + report.Failures = append(report.Failures, "raw connections did not return to zero after recovery") + } + report.FinishedAt = time.Now().UTC() + report.Pass = len(report.Failures) == 0 + return report, nil +} + +func startGroupMediaClients( + ctx context.Context, + cfg groupMediaConfig, + endpoint Endpoint, + manifestPath string, + records []SessionRecord, + key [32]byte, + publicKey *rsa.PublicKey, + group DatasetSeedGroupState, + metrics *metricSet, + fanout *groupMediaFanoutTracker, + counters *groupMediaCounters, + logf func(string, ...any), +) ([]*groupMediaClient, error) { + clients := make([]*groupMediaClient, len(records)) + readyDeadline := time.Now().Add(cfg.RampDuration + cfg.ReadyTimeout) + for i, record := range records { + if i > 0 && cfg.RampDuration > 0 { + want := cfg.RampDuration * time.Duration(i) / time.Duration(len(records)) + previous := cfg.RampDuration * time.Duration(i-1) / time.Duration(len(records)) + delay := want - previous + select { + case <-ctx.Done(): + return clients, ctx.Err() + case <-time.After(delay): + } + } + clientCtx, cancel := context.WithCancel(ctx) + holder := &groupMediaClient{ + record: record, cancel: cancel, done: make(chan error, 1), differenceWake: make(chan struct{}, 1), + } + clients[i] = holder + var everReady atomic.Bool + client, err := newClient(endpoint, publicKey, &EncryptedFileStorage{Path: resolveSessionPath(manifestPath, record), Key: key}, clientHooks{ + Update: telegram.UpdateHandlerFunc(func(_ context.Context, updates tg.UpdatesClass) error { + nudgePTS, nudged := groupMediaChannelNudge(updates, group.ChannelID) + livePTS, live := groupMediaChannelLiveUpdate(updates, group.ChannelID) + if nudged { + counters.channelNudges.Add(1) + } + if live > 0 { + counters.channelLiveUpdates.Add(uint64(live)) + } + if nudged || live > 0 { + holder.requestChannelDifference(max(nudgePTS, livePTS)) + } + return nil + }), + ConnectionState: func(state telegram.ConnectionState) { + switch state { + case telegram.ConnectionStateConnecting: + counters.connectionAttempts.Add(1) + if everReady.Load() { + counters.reconnects.Add(1) + } + case telegram.ConnectionStateReady: + everReady.Store(true) + case telegram.ConnectionStateDisconnected: + counters.disconnects.Add(1) + } + }, + }) + if err != nil { + cancel() + return clients, err + } + go func() { + runErr := client.Run(clientCtx, func(runCtx context.Context) error { + started := time.Now() + opCtx, stop := context.WithTimeout(runCtx, cfg.OperationTimeout) + status, statusErr := client.Auth().Status(opCtx) + stop() + metrics.observe("auth.status", started, statusErr) + if statusErr != nil { + return statusErr + } + if !status.Authorized || status.User == nil || status.User.ID != record.UserID { + return errors.New("session authorization does not match manifest") + } + raw := tg.NewClient(client) + started = time.Now() + opCtx, stop = context.WithTimeout(runCtx, cfg.OperationTimeout) + _, stateErr := raw.UpdatesGetState(opCtx) + stop() + metrics.observe("updates.getState", started, stateErr) + if stateErr != nil { + return stateErr + } + started = time.Now() + opCtx, stop = context.WithTimeout(runCtx, cfg.OperationTimeout) + full, fullErr := raw.ChannelsGetFullChannel(opCtx, &tg.InputChannel{ChannelID: group.ChannelID, AccessHash: group.AccessHash}) + stop() + metrics.observe("channels.getFullChannel", started, fullErr) + if fullErr != nil { + return fullErr + } + channel, ok := full.FullChat.(*tg.ChannelFull) + if !ok { + return fmt.Errorf("channels.getFullChannel returned %T", full.FullChat) + } + if channel.Pts <= 0 { + return fmt.Errorf("channels.getFullChannel returned invalid pts=%d", channel.Pts) + } + holder.channelPts.Store(int64(channel.Pts)) + holder.raw.Store(raw) + holder.ready.Store(true) + ready := counters.ready.Add(1) + for { + peak := counters.peakReady.Load() + if ready <= peak || counters.peakReady.CompareAndSwap(peak, ready) { + break + } + } + for { + select { + case <-runCtx.Done(): + holder.ready.Store(false) + counters.ready.Add(-1) + return runCtx.Err() + case <-holder.differenceWake: + for holder.differenceRequested.Swap(false) { + if err := holder.catchUpChannelDifference(runCtx, raw, group, cfg.OperationTimeout, fanout, metrics); err != nil { + counters.differenceFailures.Add(1) + break + } + } + } + } + }) + if runErr != nil && !errors.Is(runErr, context.Canceled) { + counters.fatalClients.Add(1) + } + holder.done <- runErr + }() + if (i+1)%250 == 0 { + logf("group media ramp launched=%d/%d ready=%d", i+1, len(records), counters.ready.Load()) + } + } + for counters.ready.Load() != int64(len(records)) { + if time.Now().After(readyDeadline) { + return clients, fmt.Errorf("ready timeout: %d/%d", counters.ready.Load(), len(records)) + } + select { + case <-ctx.Done(): + return clients, ctx.Err() + case <-time.After(100 * time.Millisecond): + } + } + return clients, nil +} + +func (c *groupMediaClient) requestChannelDifference(pts int) { + if c == nil { + return + } + for pts > 0 { + current := c.differenceTargetPts.Load() + if int64(pts) <= current || c.differenceTargetPts.CompareAndSwap(current, int64(pts)) { + break + } + } + if pts <= 0 { + c.differenceForce.Store(true) + } + c.differenceRequested.Store(true) + select { + case c.differenceWake <- struct{}{}: + default: + } +} + +func groupMediaChannelNudge(updates tg.UpdatesClass, channelID int64) (int, bool) { + maxPTS := 0 + found := false + for _, update := range groupMediaUpdateClasses(updates) { + nudge, ok := update.(*tg.UpdateChannelTooLong) + if !ok || nudge.ChannelID != channelID { + continue + } + found = true + if pts, ok := nudge.GetPts(); ok && pts > maxPTS { + maxPTS = pts + } + } + return maxPTS, found +} + +func groupMediaChannelLiveUpdate(updates tg.UpdatesClass, channelID int64) (int, int) { + maxPTS := 0 + count := 0 + for _, update := range groupMediaUpdateClasses(updates) { + live, ok := update.(*tg.UpdateNewChannelMessage) + if !ok { + continue + } + message, ok := live.Message.(*tg.Message) + if !ok { + continue + } + peer, ok := message.PeerID.(*tg.PeerChannel) + if !ok || peer.ChannelID != channelID { + continue + } + count++ + if live.Pts > maxPTS { + maxPTS = live.Pts + } + } + return maxPTS, count +} + +func groupMediaUpdateClasses(updates tg.UpdatesClass) []tg.UpdateClass { + switch value := updates.(type) { + case *tg.Updates: + return value.Updates + case *tg.UpdatesCombined: + return value.Updates + case *tg.UpdateShort: + return []tg.UpdateClass{value.Update} + default: + return nil + } +} + +func (c *groupMediaClient) catchUpChannelDifference( + ctx context.Context, + raw *tg.Client, + group DatasetSeedGroupState, + timeout time.Duration, + fanout *groupMediaFanoutTracker, + metrics *metricSet, +) error { + if c == nil || raw == nil { + return errors.New("channel difference client is not ready") + } + pts := int(c.channelPts.Load()) + if pts <= 0 { + return errors.New("channel difference has no baseline pts") + } + targetPTS := int(c.differenceTargetPts.Load()) + force := c.differenceForce.Swap(false) + if !force && targetPTS > 0 && pts >= targetPTS { + return nil + } + for page := 0; page < 256; page++ { + started := time.Now() + opCtx, cancel := context.WithTimeout(ctx, timeout) + difference, err := raw.UpdatesGetChannelDifference(opCtx, &tg.UpdatesGetChannelDifferenceRequest{ + Channel: &tg.InputChannel{ChannelID: group.ChannelID, AccessHash: group.AccessHash}, + Filter: &tg.ChannelMessagesFilterEmpty{}, Pts: pts, Limit: 100, + }) + cancel() + metrics.observe("updates.getChannelDifference", started, err) + if err != nil { + return err + } + + var final bool + var messages []tg.MessageClass + var updates []tg.UpdateClass + pts, final, messages, updates, err = groupMediaDifferencePage(pts, difference) + if err != nil { + return err + } + fanout.observeDifference(c.record.UserID, messages, updates) + c.channelPts.Store(int64(pts)) + if !final { + continue + } + latestTarget := int(c.differenceTargetPts.Load()) + if latestTarget > pts { + return fmt.Errorf("channel difference stopped at pts=%d before nudge pts=%d", pts, latestTarget) + } + return nil + } + return errors.New("channel difference exceeded 256 pages") +} + +func groupMediaDifferencePage(previous int, difference tg.UpdatesChannelDifferenceClass) (int, bool, []tg.MessageClass, []tg.UpdateClass, error) { + pts := previous + final := true + var messages []tg.MessageClass + var updates []tg.UpdateClass + switch value := difference.(type) { + case *tg.UpdatesChannelDifferenceEmpty: + if !value.Final { + return 0, false, nil, nil, errors.New("channelDifferenceEmpty is not final") + } + pts = value.Pts + final = value.Final + case *tg.UpdatesChannelDifference: + pts = value.Pts + final = value.Final + messages = value.NewMessages + updates = value.OtherUpdates + case *tg.UpdatesChannelDifferenceTooLong: + if !value.Final { + return 0, false, nil, nil, errors.New("channelDifferenceTooLong is not final") + } + dialog, ok := value.Dialog.(*tg.Dialog) + if !ok { + return 0, false, nil, nil, fmt.Errorf("channelDifferenceTooLong dialog is %T", value.Dialog) + } + current, ok := dialog.GetPts() + if !ok { + return 0, false, nil, nil, errors.New("channelDifferenceTooLong omitted pts") + } + pts = current + final = value.Final + messages = value.Messages + default: + return 0, false, nil, nil, fmt.Errorf("updates.getChannelDifference returned %T", difference) + } + if pts < previous || (!final && pts == previous) { + return 0, false, nil, nil, fmt.Errorf("channel difference pts did not advance: previous=%d current=%d final=%t", previous, pts, final) + } + return pts, final, messages, updates, nil +} + +func stopGroupMediaClients(clients []*groupMediaClient) { + for _, client := range clients { + if client != nil && client.cancel != nil { + client.cancel() + } + } + deadline := time.NewTimer(30 * time.Second) + defer deadline.Stop() + for _, client := range clients { + if client == nil || client.done == nil { + continue + } + select { + case <-client.done: + case <-deadline.C: + return + } + } +} + +func uploadGroupMedia( + ctx context.Context, + cfg groupMediaConfig, + clients []*groupMediaClient, + group DatasetSeedGroupState, + fanout *groupMediaFanoutTracker, + metrics *metricSet, + runID string, +) ([]groupMediaTarget, []groupMediaTypeReport, []error) { + type task struct { + source groupMediaSource + copy int + client *groupMediaClient + } + tasks := make([]task, 0, len(cfg.Media)*cfg.Copies) + typeIndex := make(map[string]int, len(cfg.Media)) + reports := make([]groupMediaTypeReport, len(cfg.Media)) + for i, source := range cfg.Media { + typeIndex[source.Kind] = i + reports[i] = groupMediaTypeReport{Kind: source.Kind, SourceBytes: int64(len(source.Data)), MessagesExpected: cfg.Copies, DownloadsExpected: cfg.Copies * cfg.Accounts} + for copyIndex := 0; copyIndex < cfg.Copies; copyIndex++ { + tasks = append(tasks, task{source: source, copy: copyIndex, client: clients[len(tasks)%len(clients)]}) + } + } + results := make(chan struct { + target groupMediaTarget + err error + }, len(tasks)) + var wg sync.WaitGroup + for _, upload := range tasks { + upload := upload + wg.Add(1) + go func() { + defer wg.Done() + marker := fmt.Sprintf("telesrv-group-media/%s/%s/%d", runID, upload.source.Kind, upload.copy+1) + target, err := uploadAndSendGroupMedia(ctx, upload.client, upload.source, marker, group, cfg.OperationTimeout, fanout, metrics) + results <- struct { + target groupMediaTarget + err error + }{target: target, err: err} + }() + } + wg.Wait() + close(results) + targets := make([]groupMediaTarget, 0, len(tasks)) + var errs []error + for result := range results { + if result.err != nil { + errs = append(errs, result.err) + continue + } + targets = append(targets, result.target) + report := &reports[typeIndex[result.target.Kind]] + report.MessagesSent++ + report.DownloadFileBytes += result.target.Size + } + sort.Slice(targets, func(i, j int) bool { return targets[i].Marker < targets[j].Marker }) + return targets, reports, errs +} + +func uploadAndSendGroupMedia( + ctx context.Context, + client *groupMediaClient, + source groupMediaSource, + marker string, + group DatasetSeedGroupState, + timeout time.Duration, + fanout *groupMediaFanoutTracker, + metrics *metricSet, +) (groupMediaTarget, error) { + if client == nil { + return groupMediaTarget{}, errors.New("sender is not ready") + } + raw := client.raw.Load() + if raw == nil { + return groupMediaTarget{}, errors.New("sender is not ready") + } + fileID, err := randomNonZeroInt64() + if err != nil { + return groupMediaTarget{}, err + } + const partSize = 512 << 10 + parts := (len(source.Data) + partSize - 1) / partSize + big := len(source.Data) > 10<<20 + for part := 0; part < parts; part++ { + startOffset := part * partSize + endOffset := min(len(source.Data), startOffset+partSize) + started := time.Now() + opCtx, cancel := context.WithTimeout(ctx, timeout) + var saved bool + if big { + saved, err = raw.UploadSaveBigFilePart(opCtx, &tg.UploadSaveBigFilePartRequest{FileID: fileID, FilePart: part, FileTotalParts: parts, Bytes: source.Data[startOffset:endOffset]}) + } else { + saved, err = raw.UploadSaveFilePart(opCtx, &tg.UploadSaveFilePartRequest{FileID: fileID, FilePart: part, Bytes: source.Data[startOffset:endOffset]}) + } + cancel() + metrics.observe("upload.saveFilePart", started, err) + if err != nil || !saved { + if err == nil { + err = fmt.Errorf("upload part %d returned false", part) + } + return groupMediaTarget{}, fmt.Errorf("%s: %w", source.Kind, err) + } + } + var file tg.InputFileClass + if big { + file = &tg.InputFileBig{ID: fileID, Parts: parts, Name: source.Name} + } else { + digest := md5.Sum(source.Data) + file = &tg.InputFile{ID: fileID, Parts: parts, Name: source.Name, MD5Checksum: hex.EncodeToString(digest[:])} + } + media := groupMediaInput(source, file) + fanout.begin(marker) + started := time.Now() + opCtx, cancel := context.WithTimeout(ctx, timeout) + updates, err := raw.MessagesSendMedia(opCtx, &tg.MessagesSendMediaRequest{ + Peer: &tg.InputPeerChannel{ChannelID: group.ChannelID, AccessHash: group.AccessHash}, + Media: media, Message: marker, RandomID: randomGroupMediaID(), + }) + cancel() + metrics.observe("messages.sendMedia", started, err) + if err != nil { + fanout.finish(marker, false) + return groupMediaTarget{}, fmt.Errorf("%s sendMedia: %w", source.Kind, err) + } + fanout.finish(marker, true) + target, err := groupMediaTargetFromUpdates(source.Kind, marker, updates) + if err != nil { + return groupMediaTarget{}, err + } + nudgePTS, _ := groupMediaChannelNudge(updates, group.ChannelID) + livePTS, _ := groupMediaChannelLiveUpdate(updates, group.ChannelID) + client.requestChannelDifference(max(nudgePTS, livePTS)) + return target, nil +} + +func groupMediaInput(source groupMediaSource, file tg.InputFileClass) tg.InputMediaClass { + filename := &tg.DocumentAttributeFilename{FileName: source.Name} + switch source.Kind { + case "photo": + return &tg.InputMediaUploadedPhoto{File: file} + case "video": + video := &tg.DocumentAttributeVideo{Duration: 5, W: 1280, H: 720} + video.SetSupportsStreaming(true) + return &tg.InputMediaUploadedDocument{File: file, MimeType: source.MimeType, Attributes: []tg.DocumentAttributeClass{filename, video}} + case "audio": + audio := &tg.DocumentAttributeAudio{Duration: 20} + audio.SetTitle("telesrv load audio") + audio.SetPerformer("telesrv") + return &tg.InputMediaUploadedDocument{File: file, MimeType: source.MimeType, Attributes: []tg.DocumentAttributeClass{filename, audio}} + case "voice": + voice := &tg.DocumentAttributeAudio{Duration: 20} + voice.SetVoice(true) + return &tg.InputMediaUploadedDocument{File: file, MimeType: source.MimeType, Attributes: []tg.DocumentAttributeClass{filename, voice}} + default: + return &tg.InputMediaUploadedDocument{File: file, MimeType: source.MimeType, ForceFile: true, Attributes: []tg.DocumentAttributeClass{filename}} + } +} + +func groupMediaTargetFromUpdates(kind, marker string, updates tg.UpdatesClass) (groupMediaTarget, error) { + var classes []tg.UpdateClass + switch value := updates.(type) { + case *tg.Updates: + classes = value.Updates + case *tg.UpdatesCombined: + classes = value.Updates + case *tg.UpdateShort: + classes = []tg.UpdateClass{value.Update} + default: + return groupMediaTarget{}, fmt.Errorf("%s sendMedia returned %T", kind, updates) + } + for _, update := range classes { + var message tg.MessageClass + switch value := update.(type) { + case *tg.UpdateNewChannelMessage: + message = value.Message + case *tg.UpdateNewMessage: + message = value.Message + default: + continue + } + full, ok := message.(*tg.Message) + if !ok || full.Message != marker { + continue + } + return groupMediaTargetFromMessage(kind, marker, full) + } + return groupMediaTarget{}, fmt.Errorf("%s sendMedia response omitted marker", kind) +} + +func groupMediaTargetFromMessage(kind, marker string, message *tg.Message) (groupMediaTarget, error) { + if message == nil || message.Message != marker { + return groupMediaTarget{}, errors.New("group media marker does not match message") + } + media, ok := message.GetMedia() + if !ok { + return groupMediaTarget{}, fmt.Errorf("%s message omitted media", kind) + } + switch value := media.(type) { + case *tg.MessageMediaPhoto: + photoClass, ok := value.GetPhoto() + photo, okPhoto := photoClass.(*tg.Photo) + if !ok || !okPhoto { + return groupMediaTarget{}, fmt.Errorf("photo message returned %T", photoClass) + } + var best *tg.PhotoSize + for _, sizeClass := range photo.Sizes { + if size, ok := sizeClass.(*tg.PhotoSize); ok && (best == nil || size.Size > best.Size) { + best = size + } + } + if best == nil || best.Size <= 0 { + return groupMediaTarget{}, errors.New("photo message has no downloadable size") + } + return groupMediaTarget{Kind: kind, Marker: marker, Size: int64(best.Size), Location: photo.AsInputPhotoFileLocation(best.Type)}, nil + case *tg.MessageMediaDocument: + documentClass, ok := value.GetDocument() + document, okDocument := documentClass.(*tg.Document) + if !ok || !okDocument || document.Size <= 0 { + return groupMediaTarget{}, fmt.Errorf("%s message returned %T", kind, documentClass) + } + return groupMediaTarget{Kind: kind, Marker: marker, Size: document.Size, Location: document.AsInputDocumentFileLocation("")}, nil + default: + return groupMediaTarget{}, fmt.Errorf("%s message media is %T", kind, media) + } +} + +func randomGroupMediaID() int64 { + var data [8]byte + if _, err := cryptorand.Read(data[:]); err != nil { + return time.Now().UnixNano() + } + value := int64(0) + for _, b := range data { + value = value<<8 | int64(b) + } + if value == 0 { + return 1 + } + return value +} + +func runGroupMediaDownloads( + ctx context.Context, + clients []*groupMediaClient, + canonicalTargets []groupMediaTarget, + cfg groupMediaConfig, + reports []groupMediaTypeReport, + fanout *groupMediaFanoutTracker, + metrics *metricSet, +) (int64, int64, error) { + byKind := make(map[string]int, len(reports)) + for i := range reports { + byKind[reports[i].Kind] = i + } + type counters struct { + complete atomic.Int64 + bytes atomic.Int64 + errors atomic.Int64 + } + typeCounters := make([]counters, len(reports)) + clientTargets := make([][]groupMediaTarget, len(clients)) + for i, client := range clients { + if client == nil { + return 0, int64(len(clients) * len(canonicalTargets)), errors.New("nil group media download client") + } + targets, err := fanout.targetsForUser(client.record.UserID, canonicalTargets) + if err != nil { + return 0, int64(len(clients) * len(canonicalTargets)), fmt.Errorf("member %d difference targets: %w", client.record.UserID, err) + } + clientTargets[i] = targets + } + start := make(chan struct{}) + var wg sync.WaitGroup + for clientIndex, client := range clients { + clientIndex, client := clientIndex, client + targets := clientTargets[clientIndex] + wg.Add(1) + go func() { + defer wg.Done() + <-start + for offset := range targets { + target := targets[(offset+clientIndex)%len(targets)] + counter := &typeCounters[byKind[target.Kind]] + digest, bytesRead, err := downloadGroupMediaFile(ctx, client.raw.Load(), target, cfg.ChunkBytes, cfg.OperationTimeout, metrics, "upload.getFile") + if err != nil || digest != target.SHA256 { + counter.errors.Add(1) + continue + } + counter.complete.Add(1) + counter.bytes.Add(bytesRead) + } + }() + } + close(start) + wg.Wait() + var totalBytes, totalErrors int64 + for i := range reports { + reports[i].DownloadsComplete = typeCounters[i].complete.Load() + reports[i].DownloadedBytes = typeCounters[i].bytes.Load() + reports[i].DownloadErrors = typeCounters[i].errors.Load() + totalBytes += reports[i].DownloadedBytes + totalErrors += reports[i].DownloadErrors + } + return totalBytes, totalErrors, nil +} + +func downloadGroupMediaFile( + ctx context.Context, + raw *tg.Client, + target groupMediaTarget, + chunk int, + timeout time.Duration, + metrics *metricSet, + operation string, +) ([sha256.Size]byte, int64, error) { + if raw == nil { + return [sha256.Size]byte{}, 0, errors.New("download client is not ready") + } + var offset int64 + digest := sha256.New() + for offset < target.Size { + limit := min(chunk, int(target.Size-offset)) + started := time.Now() + opCtx, cancel := context.WithTimeout(ctx, timeout) + result, err := raw.UploadGetFile(opCtx, &tg.UploadGetFileRequest{Location: target.Location, Offset: offset, Limit: limit}) + cancel() + if err == nil { + file, ok := result.(*tg.UploadFile) + if !ok { + err = fmt.Errorf("upload.getFile returned %T", result) + } else if len(file.Bytes) != limit { + err = fmt.Errorf("upload.getFile bytes=%d want=%d", len(file.Bytes), limit) + } else { + _, _ = digest.Write(file.Bytes) + offset += int64(len(file.Bytes)) + } + } + metrics.observe(operation, started, err) + if err != nil { + return [sha256.Size]byte{}, offset, err + } + } + return sumGroupMediaHash(digest), offset, nil +} + +func sumGroupMediaHash(digest hash.Hash) [sha256.Size]byte { + var out [sha256.Size]byte + copy(out[:], digest.Sum(nil)) + return out +} + +func (t *groupMediaFanoutTracker) begin(marker string) { + t.mu.Lock() + t.expected[marker] = time.Now() + t.mu.Unlock() +} + +func (t *groupMediaFanoutTracker) finish(marker string, success bool) { + t.mu.Lock() + if success { + t.committed[marker] = true + } else { + delete(t.expected, marker) + delete(t.observations, marker) + } + t.mu.Unlock() +} + +func (t *groupMediaFanoutTracker) observeDifference(userID int64, messages []tg.MessageClass, updates []tg.UpdateClass) { + for _, message := range messages { + if full, ok := message.(*tg.Message); ok { + t.observeMessage(full, userID) + } + } + for _, update := range updates { + var message tg.MessageClass + switch value := update.(type) { + case *tg.UpdateNewChannelMessage: + message = value.Message + default: + continue + } + if full, ok := message.(*tg.Message); ok { + t.observeMessage(full, userID) + } + } +} + +func (t *groupMediaFanoutTracker) observeMessage(message *tg.Message, userID int64) { + if message == nil { + return + } + marker := message.Message + if !strings.HasPrefix(marker, t.prefix) { + return + } + if _, ok := t.members[userID]; !ok { + return + } + remainder := strings.TrimPrefix(marker, t.prefix) + kind, _, ok := strings.Cut(remainder, "/") + if !ok || kind == "" { + return + } + target, err := groupMediaTargetFromMessage(kind, marker, message) + if err != nil { + return + } + t.mu.Lock() + if _, ok := t.expected[marker]; !ok { + t.mu.Unlock() + return + } + byUser := t.observations[marker] + if byUser == nil { + byUser = make(map[int64]groupMediaObservation) + t.observations[marker] = byUser + } + observation := byUser[userID] + if observation.first.IsZero() { + observation.first = time.Now() + observation.target = target + } else { + observation.repeat++ + } + byUser[userID] = observation + t.mu.Unlock() +} + +func (t *groupMediaFanoutTracker) targetsForUser(userID int64, canonical []groupMediaTarget) ([]groupMediaTarget, error) { + if t == nil { + return nil, errors.New("nil group media fanout tracker") + } + canonicalByMarker := make(map[string]groupMediaTarget, len(canonical)) + for _, target := range canonical { + canonicalByMarker[target.Marker] = target + } + t.mu.Lock() + defer t.mu.Unlock() + result := make([]groupMediaTarget, 0, len(canonical)) + for marker, expected := range canonicalByMarker { + observation, ok := t.observations[marker][userID] + if !ok || observation.first.IsZero() || observation.target.Location == nil { + return nil, fmt.Errorf("marker %q was not recovered through channel difference", marker) + } + if observation.target.Kind != expected.Kind || observation.target.Size != expected.Size { + return nil, fmt.Errorf("marker %q target identity differs from sender result", marker) + } + target := observation.target + target.SHA256 = expected.SHA256 + result = append(result, target) + } + if len(result) != len(canonical) { + return nil, fmt.Errorf("recovered %d targets, want %d", len(result), len(canonical)) + } + sort.Slice(result, func(i, j int) bool { return result[i].Marker < result[j].Marker }) + return result, nil +} + +func (t *groupMediaFanoutTracker) wait(timeout time.Duration) { + deadline := time.NewTimer(timeout) + ticker := time.NewTicker(50 * time.Millisecond) + defer deadline.Stop() + defer ticker.Stop() + for { + report := t.report() + if report.Expected > 0 && report.Observed == report.Expected { + return + } + select { + case <-deadline.C: + return + case <-ticker.C: + } + } +} + +func (t *groupMediaFanoutTracker) report() groupMediaFanoutReport { + t.mu.Lock() + defer t.mu.Unlock() + report := groupMediaFanoutReport{MissingByMessage: make(map[string]int)} + missingSlots := make(map[int]struct{}) + latencies := make([]time.Duration, 0, len(t.expected)*len(t.members)) + for marker, started := range t.expected { + if !t.committed[marker] { + continue + } + report.Messages++ + report.Expected += int64(len(t.members)) + observed := t.observations[marker] + for _, observation := range observed { + report.Observed++ + report.Duplicate += observation.repeat + if !observation.first.Before(started) { + latencies = append(latencies, observation.first.Sub(started)) + } + } + missing := 0 + for userID, slot := range t.members { + if _, ok := observed[userID]; ok { + continue + } + missing++ + if len(missingSlots) < 64 { + missingSlots[slot] = struct{}{} + } + } + if missing > 0 { + report.MissingByMessage[marker] = missing + } + } + report.Missing = report.Expected - report.Observed + for slot := range missingSlots { + report.MissingMemberSlots = append(report.MissingMemberSlots, slot) + } + sort.Ints(report.MissingMemberSlots) + if len(latencies) > 0 { + report.P50MS = groupMediaQuantile(latencies, .50) + report.P95MS = groupMediaQuantile(latencies, .95) + report.P99MS = groupMediaQuantile(latencies, .99) + report.MaxMS = groupMediaQuantile(latencies, 1) + } + return report +} + +func groupMediaQuantile(values []time.Duration, q float64) float64 { + sorted := append([]time.Duration(nil), values...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] }) + index := int(float64(len(sorted)-1) * q) + return durationMS(sorted[index]) +} + +func (s *groupMediaSampler) run(ctx context.Context, interval time.Duration) { + if s == nil || s.client == nil { + return + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + metrics, err := s.client.scrape(ctx) + if err != nil { + continue + } + s.mu.Lock() + for name, value := range metrics { + if value > s.peak[name] { + s.peak[name] = value + } + } + s.mu.Unlock() + } + } +} + +func (s *groupMediaSampler) snapshot() map[string]float64 { + s.mu.Lock() + defer s.mu.Unlock() + return cloneMetricMap(s.peak) +} + +func cloneMetricMap(source map[string]float64) map[string]float64 { + out := make(map[string]float64, len(source)) + for name, value := range source { + out[name] = value + } + return out +} + +func metricDelta(before, after map[string]float64, name string) float64 { + return metricValue(after, name) - metricValue(before, name) +} + +func writeGroupMediaReport(path string, report *groupMediaLoadReport) error { + if report == nil { + return errors.New("nil group media report") + } + data, err := json.MarshalIndent(report, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + return writeFileAtomic(path, append(data, '\n'), 0o600) +} diff --git a/internal/loadharness/group_media_unit_test.go b/internal/loadharness/group_media_unit_test.go new file mode 100644 index 00000000..2fad7903 --- /dev/null +++ b/internal/loadharness/group_media_unit_test.go @@ -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) + } + } +} diff --git a/internal/mtprotoedge/bulk_rpc_scheduler.go b/internal/mtprotoedge/bulk_rpc_scheduler.go new file mode 100644 index 00000000..1b70a992 --- /dev/null +++ b/internal/mtprotoedge/bulk_rpc_scheduler.go @@ -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) + } +} diff --git a/internal/mtprotoedge/bulk_rpc_scheduler_test.go b/internal/mtprotoedge/bulk_rpc_scheduler_test.go new file mode 100644 index 00000000..2a7bfd1a --- /dev/null +++ b/internal/mtprotoedge/bulk_rpc_scheduler_test.go @@ -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() +} diff --git a/internal/mtprotoedge/circular_history_test.go b/internal/mtprotoedge/circular_history_test.go new file mode 100644 index 00000000..e4c626e9 --- /dev/null +++ b/internal/mtprotoedge/circular_history_test.go @@ -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") + } +} diff --git a/internal/mtprotoedge/client_ip_e2e_test.go b/internal/mtprotoedge/client_ip_e2e_test.go new file mode 100644 index 00000000..6d94b23f --- /dev/null +++ b/internal/mtprotoedge/client_ip_e2e_test.go @@ -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: + } +} diff --git a/internal/mtprotoedge/conn.go b/internal/mtprotoedge/conn.go index 5f77ce5f..27c761fe 100644 --- a/internal/mtprotoedge/conn.go +++ b/internal/mtprotoedge/conn.go @@ -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 @@ -104,10 +105,14 @@ type Conn struct { // Encoded MTProto service frames and control vectors use independent headroom: pong, // new_session_created, bad_msg and msgs_ack must remain admissible when the body budget is // full. Content-related control frames keep this budget while pending for resend. - outboundControlTrackedBudget *outboundTrackedBudget - outboundControlBudgetOnce sync.Once - outboundScratchPool *outboundScratchPool - outboundScratchOnce sync.Once + 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 返回连接的对端 IP(host 部分),未设置时为空字符串。 +func (c *Conn) clientIP() string { return c.remoteAddr } diff --git a/internal/mtprotoedge/conn_mgmt_fixes_test.go b/internal/mtprotoedge/conn_mgmt_fixes_test.go index 3b0cb07c..e939c38d 100644 --- a/internal/mtprotoedge/conn_mgmt_fixes_test.go +++ b/internal/mtprotoedge/conn_mgmt_fixes_test.go @@ -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) diff --git a/internal/mtprotoedge/encrypted.go b/internal/mtprotoedge/encrypted.go index e6fe67a9..a9c2f35e 100644 --- a/internal/mtprotoedge/encrypted.go +++ b/internal/mtprotoedge/encrypted.go @@ -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.ip(admin 面板可见)。 + 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,9 +1344,14 @@ 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) - if err != nil { - return nil, fmt.Errorf("compress rpc result: %w", err) + 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 } } - cs.order = append(cs.order, msgID) + 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() } } diff --git a/internal/mtprotoedge/flush_identity_test.go b/internal/mtprotoedge/flush_identity_test.go index 22f82c57..b1b8eff6 100644 --- a/internal/mtprotoedge/flush_identity_test.go +++ b/internal/mtprotoedge/flush_identity_test.go @@ -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) diff --git a/internal/mtprotoedge/inbound_layer_rpc_test.go b/internal/mtprotoedge/inbound_layer_rpc_test.go index 2209d5c9..08db1c42 100644 --- a/internal/mtprotoedge/inbound_layer_rpc_test.go +++ b/internal/mtprotoedge/inbound_layer_rpc_test.go @@ -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()) } diff --git a/internal/mtprotoedge/inbound_rpc.go b/internal/mtprotoedge/inbound_rpc.go index 7f0c9bb1..a8e20ae0 100644 --- a/internal/mtprotoedge/inbound_rpc.go +++ b/internal/mtprotoedge/inbound_rpc.go @@ -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) } diff --git a/internal/mtprotoedge/layer_rpc_execution.go b/internal/mtprotoedge/layer_rpc_execution.go index 600646e3..7c9fc532 100644 --- a/internal/mtprotoedge/layer_rpc_execution.go +++ b/internal/mtprotoedge/layer_rpc_execution.go @@ -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) diff --git a/internal/mtprotoedge/layer_rpc_execution_test.go b/internal/mtprotoedge/layer_rpc_execution_test.go index 49ebc864..af730b9a 100644 --- a/internal/mtprotoedge/layer_rpc_execution_test.go +++ b/internal/mtprotoedge/layer_rpc_execution_test.go @@ -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) } diff --git a/internal/mtprotoedge/outbound.go b/internal/mtprotoedge/outbound.go index df4b42ef..27991ad9 100644 --- a/internal/mtprotoedge/outbound.go +++ b/internal/mtprotoedge/outbound.go @@ -5,6 +5,7 @@ import ( "context" "crypto/aes" "crypto/cipher" + "crypto/sha256" "encoding/binary" "errors" "fmt" @@ -18,6 +19,7 @@ import ( "github.com/iamxvbaba/td/crypto" "github.com/iamxvbaba/td/mt" "github.com/iamxvbaba/td/proto" + "telesrv/internal/rpcresult" ) var ( @@ -41,6 +43,7 @@ const ( defaultOutboundBulkQueueSize = 16 defaultOutboundTrackedMaxBytes = int64(512 << 20) // 512 MiB / Server defaultOutboundControlMaxBytes = int64(64 << 20) // ack/state/resend vectors / Server + defaultOutboundCriticalMaxBytes = int64(64 << 20) // bootstrap RPC results / Server // requiredControlMaxWait bounds protocol barriers such as new_session_created from // the beginning of encoding through the completed physical write. These frames gate // subsequent session state transitions, so timing out must close the connection instead @@ -53,6 +56,7 @@ const ( // 与 maxTrackedServerMsgIDs 并列;到达任一上限后拒绝新可靠 frame,绝不滚动 // 丢弃尚未 ACK 的旧 frame。 maxTrackedServerBytes = 64 << 20 // 64 MiB + defaultBulkACKWindow = 32 // Encrypted transport adds auth-key/msg-key, plaintext headers, randomized // padding and codec framing. Reject before creating the two encryption buffers. maxOutboundBodyBytes = maxTransportMessageSize - (2 << 10) @@ -81,8 +85,9 @@ const ( // Large responses are scheduled separately so a startup prefetch cannot sit // ahead of an already-prepared session convergence result. The threshold is // applied after layer conversion and adaptive gzip. - bulkOutboundThreshold = 64 << 10 - maxOrdinaryBeforeBulk = 16 + bulkOutboundThreshold = 64 << 10 + maxOrdinaryBeforeBulk = 16 + defaultOutboundOpPoolSize = 4096 ) type outboundOp struct { @@ -107,6 +112,64 @@ type outboundOp struct { terminal func(error) } +// outboundOpPool makes the wide tagged operation object demand-allocated while +// per-Conn channels retain only one pointer per ring slot. The pool is bounded +// and Server-owned: an idle connection therefore pays 8 bytes rather than the +// complete outboundOp size per queue slot, while a past burst cannot pin an +// unbounded number of operation graphs or their referenced payloads. +type outboundOpPool struct { + idle chan *outboundOp +} + +func newOutboundOpPool(maxIdle int) *outboundOpPool { + if maxIdle <= 0 { + maxIdle = 1 + } + return &outboundOpPool{idle: make(chan *outboundOp, maxIdle)} +} + +func (p *outboundOpPool) acquire() *outboundOp { + if p != nil { + select { + case op := <-p.idle: + return op + default: + } + } + return &outboundOp{} +} + +func (p *outboundOpPool) release(op *outboundOp) { + if op == nil { + return + } + *op = outboundOp{} + if p == nil { + return + } + select { + case p.idle <- op: + default: + } +} + +var fallbackOutboundOpPool = newOutboundOpPool(defaultOutboundOpPoolSize) + +func (c *Conn) operationPool() *outboundOpPool { + if c != nil && c.outboundOpPool != nil { + return c.outboundOpPool + } + return fallbackOutboundOpPool +} + +func (c *Conn) acquireOutboundOp() *outboundOp { + return c.operationPool().acquire() +} + +func (c *Conn) releaseOutboundOp(op *outboundOp) { + c.operationPool().release(op) +} + // outboundBodyReservation bridges the only gap between producing an immutable // encoded body and handing it to the outbound actor. Exact RPC results acquire // this retained-byte reservation before releasing the process-wide encode slot, @@ -145,23 +208,22 @@ func (r *outboundBodyReservation) release() { budget.release(bytes) } -func (r *outboundBodyReservation) take(encoded *encodedOutboundMessage) (outboundOp, error) { +func (r *outboundBodyReservation) take(encoded *encodedOutboundMessage, pool *outboundOpPool) (*outboundOp, error) { if r == nil { - return outboundOp{}, errors.New("invalid outbound body reservation") + return nil, errors.New("invalid outbound body reservation") } r.mu.Lock() defer r.mu.Unlock() if r.budget == nil || r.bytes <= 0 || r.taken || r.releaseRequested || encoded == nil || len(encoded.body) != r.bytes { - return outboundOp{}, errors.New("invalid outbound body reservation") - } - op := outboundOp{ - kind: outboundSend, - msgType: proto.MessageServerResponse, - encoded: encoded, - priority: classifyOutboundPriority(encoded, false), - reservedBytes: r.bytes, - reservationBudget: r.budget, + return nil, errors.New("invalid outbound body reservation") } + op := pool.acquire() + op.kind = outboundSend + op.msgType = proto.MessageServerResponse + op.encoded = encoded + op.priority = classifyOutboundPriority(encoded, false) + op.reservedBytes = r.bytes + op.reservationBudget = r.budget r.bytes = 0 r.budget = nil r.taken = true @@ -214,6 +276,13 @@ type encodedOutboundMessage struct { layer *outboundLayerBinding compressed bool uncompressedBytes int + // replaySource is present only for a fresh result proven to come from an + // immutable source. innerDigest covers the exact profile-encoded inner TL + // value; the req_msg_id prefix may be retargeted without changing this proof. + replaySource rpcresult.ReplaySource + innerDigest [32]byte + logicalBytes int + bulkCredit *outboundBulkCredit // replayMsgID/replaySeqNo identify an existing logical-session frame. They // are populated only by the receipt ledger's outbox lookup, never by a newly // encoded result. A replay writes that exact frame instead of allocating a @@ -222,6 +291,61 @@ type encodedOutboundMessage struct { replaySeqNo int32 } +func (m *encodedOutboundMessage) wireSize() int { + if m == nil { + return 0 + } + if m.logicalBytes > 0 { + return m.logicalBytes + } + return len(m.body) +} + +func (m *encodedOutboundMessage) materializeRPCResultBody(ctx context.Context, reqMsgID int64) ([]byte, error) { + body, _, err := m.materializeRPCResultBodyInto(ctx, reqMsgID, nil) + return body, err +} + +// materializeRPCResultBodyInto rebuilds a descriptor-backed rpc_result directly +// into scratch. It avoids the former inner allocation followed by a second +// envelope allocation/copy. usedScratch is true only while body aliases scratch. +func (m *encodedOutboundMessage) materializeRPCResultBodyInto(ctx context.Context, reqMsgID int64, scratch []byte) (body []byte, usedScratch bool, err error) { + if m == nil || m.typeID != proto.ResultTypeID || reqMsgID == 0 { + return nil, false, errors.New("invalid rpc_result materialization") + } + if len(m.body) >= 12 { + body := m.body + if int64(binary.LittleEndian.Uint64(body[4:12])) != reqMsgID { + body = append([]byte(nil), body...) + binary.LittleEndian.PutUint64(body[4:12], uint64(reqMsgID)) + } + return body, false, nil + } + if m.replaySource == nil || m.compressed { + return nil, false, errors.New("rpc_result body is not materializable") + } + if ctx == nil { + ctx = context.Background() + } + var envelope bin.Buffer + envelope.Buf = scratch[:0] + envelope.PutID(proto.ResultTypeID) + envelope.PutLong(reqMsgID) + if err := m.replaySource.EncodeInner(ctx, &envelope); err != nil { + return nil, false, fmt.Errorf("materialize immutable rpc_result: %w", err) + } + body = envelope.Raw() + innerBytes := body[12:] + if len(innerBytes) != m.uncompressedBytes || sha256.Sum256(innerBytes) != m.innerDigest { + return nil, false, errors.New("immutable rpc_result inner digest mismatch") + } + if m.logicalBytes > 0 && len(body) != m.logicalBytes { + return nil, false, errors.New("immutable rpc_result body length mismatch") + } + usedScratch = len(scratch) == 0 && cap(scratch) > 0 && len(body) > 0 && &body[0] == &scratch[:cap(scratch)][0] + return body, usedScratch, nil +} + type rpcResultDeliveryState uint32 const ( @@ -712,13 +836,16 @@ func (m *encodedOutboundMessage) prepareDeliveryHook(executor *rpcDeliveryHookEx } func cloneRPCResultForRequest(encoded *encodedOutboundMessage, reqMsgID int64, shareDelivery bool) (*encodedOutboundMessage, error) { - if encoded == nil || encoded.typeID != proto.ResultTypeID || len(encoded.body) < 12 || reqMsgID == 0 { + return cloneRPCResultForRequestContext(context.Background(), encoded, reqMsgID, shareDelivery) +} + +func cloneRPCResultForRequestContext(ctx context.Context, encoded *encodedOutboundMessage, reqMsgID int64, shareDelivery bool) (*encodedOutboundMessage, error) { + if encoded == nil || encoded.typeID != proto.ResultTypeID || reqMsgID == 0 { return nil, errors.New("invalid rpc_result retarget") } - body := encoded.body - if int64(binary.LittleEndian.Uint64(body[4:12])) != reqMsgID { - body = append([]byte(nil), body...) - binary.LittleEndian.PutUint64(body[4:12], uint64(reqMsgID)) + body, err := encoded.materializeRPCResultBody(ctx, reqMsgID) + if err != nil { + return nil, err } var coordinator *rpcResultDeliveryCoordinator if encoded.delivery != nil { @@ -739,6 +866,9 @@ func cloneRPCResultForRequest(encoded *encodedOutboundMessage, reqMsgID int64, s priority: encoded.priority, delivery: delivery, compressed: encoded.compressed, layer: encoded.layer, layerInvariant: encoded.layerInvariant, uncompressedBytes: encoded.uncompressedBytes, + replaySource: encoded.replaySource, + innerDigest: encoded.innerDigest, + logicalBytes: encoded.logicalBytes, replayMsgID: replayMsgID, replaySeqNo: replaySeqNo, }, nil } @@ -752,21 +882,47 @@ func (c *Conn) cloneRPCResultForRequestReserved( reqMsgID int64, shareDelivery bool, ) (*encodedOutboundMessage, *outboundBodyReservation, error) { - if c == nil || encoded == nil || encoded.typeID != proto.ResultTypeID || len(encoded.body) < 12 || reqMsgID == 0 { + return c.cloneRPCResultForRequestReservedContext(context.Background(), encoded, reqMsgID, shareDelivery) +} + +func (c *Conn) cloneRPCResultForRequestReservedContext( + ctx context.Context, + encoded *encodedOutboundMessage, + reqMsgID int64, + shareDelivery bool, +) (*encodedOutboundMessage, *outboundBodyReservation, error) { + if c == nil || encoded == nil || encoded.typeID != proto.ResultTypeID || reqMsgID == 0 { return nil, nil, errors.New("invalid rpc_result retarget") } - bytes := len(encoded.body) - if bytes > maxOutboundBodyBytes { - return nil, nil, fmt.Errorf("%w: body=%d limit=%d", ErrOutboundMessageTooLarge, bytes, maxOutboundBodyBytes) - } - budget := c.outboundMessageBudget(encoded.typeID, false) - if !budget.reserve(bytes) { - return nil, nil, ErrOutboundTrackedBudget - } - reserved := &outboundBodyReservation{budget: budget, bytes: bytes} - clone, err := cloneRPCResultForRequest(encoded, reqMsgID, shareDelivery) + var ( + clone *encodedOutboundMessage + reserved *outboundBodyReservation + ) + // Cloning is a logical-session retention operation, not a write on this + // physical generation. In particular, an initConnection retarget may need to + // publish the exact alias after the source socket has already failed. The + // caller context still bounds descriptor materialization, but closing the + // source Conn must not race the immutable result out of the replay ledger. + err := withOutboundEncodeSlot(ctx, nil, func() error { + var err error + clone, err = cloneRPCResultForRequestContext(ctx, encoded, reqMsgID, shareDelivery) + if err != nil { + return err + } + bytes := len(clone.body) + if bytes > maxOutboundBodyBytes { + clone = nil + return fmt.Errorf("%w: body=%d limit=%d", ErrOutboundMessageTooLarge, bytes, maxOutboundBodyBytes) + } + budget := c.outboundMessageBudgetForPriority(encoded.typeID, encoded.priority, false) + if !budget.reserve(bytes) { + clone = nil + return ErrOutboundTrackedBudget + } + reserved = &outboundBodyReservation{budget: budget, bytes: bytes} + return nil + }) if err != nil { - reserved.release() return nil, nil, err } return clone, reserved, nil @@ -793,6 +949,10 @@ type outboundFrame struct { delivery *rpcResultDelivery compressed bool uncompressedBytes int + replaySource rpcresult.ReplaySource + innerDigest [32]byte + logicalBytes int + bulkCredit *outboundBulkCredit // layer is retained only for proactive session-bound frames so a later // msg_resend_req cannot replay bytes from an obsolete profile epoch. layer *outboundLayerBinding @@ -801,22 +961,65 @@ type outboundFrame struct { sends int } +const outboundReplayDescriptorCharge = 1 << 10 + +func (f *outboundFrame) logicalSize() int { + if f == nil { + return 0 + } + if f.logicalBytes > 0 { + return f.logicalBytes + } + return len(f.body) +} + +func (f *outboundFrame) materialized(ctx context.Context, scratch []byte) (*outboundFrame, bool, error) { + if f == nil { + return nil, false, errors.New("nil outbound replay frame") + } + if len(f.body) > 0 { + return f, false, nil + } + encoded := &encodedOutboundMessage{ + typeID: f.typeID, + reqMsgID: f.reqMsgID, + compressed: f.compressed, + uncompressedBytes: f.uncompressedBytes, + replaySource: f.replaySource, + innerDigest: f.innerDigest, + logicalBytes: f.logicalBytes, + } + body, usedScratch, err := encoded.materializeRPCResultBodyInto(ctx, f.reqMsgID, scratch) + if err != nil { + return nil, false, err + } + copyFrame := *f + copyFrame.body = body + return ©Frame, usedScratch, nil +} + type outboundState struct { - mu sync.Mutex - pending map[int64]*outboundFrame - order []int64 - byRequest map[int64]int64 - acked map[int64]struct{} - ackOrder []int64 - totalBytes int - maxMessages int - maxBytes int - budget *outboundTrackedBudget + mu sync.Mutex + pending map[int64]*outboundFrame + order []int64 + byRequest map[int64]int64 + acked map[int64]struct{} + ackOrder []int64 + ackOrderHead int + totalBytes int + maxMessages int + maxBytes int + budget *outboundTrackedBudget // sentContentMessages is logical-session state, not physical-connection // state. Reserving a new content frame advances it before the first write so // a failed write can be replayed with the same seq_no after reconnect. sentContentMessages int32 lastMsgID int64 + bulkMu sync.Mutex + bulkInUse int + bulkMax int + bulkWaiters []*outboundBulkCredit + bulkClosed bool // persistent becomes true once this state is owned by a logical session. // Directly constructed Conns may start with actor-local ownership and be // adopted only from the terminal callback after a failed first write; the @@ -824,6 +1027,146 @@ type outboundState struct { persistent atomic.Bool } +type outboundBulkCredit struct { + state *outboundState + granted bool + released bool + notified bool + notify func(bool) +} + +type outboundBulkCreditLease struct { + credit *outboundBulkCredit + transferred atomic.Bool +} + +func (l *outboundBulkCreditLease) transfer() *outboundBulkCredit { + if l == nil || l.credit == nil || !l.transferred.CompareAndSwap(false, true) { + return nil + } + return l.credit +} + +func (l *outboundBulkCreditLease) releaseIfOwned() { + if l == nil || l.credit == nil || l.transferred.Load() { + return + } + l.credit.release() +} + +func (s *outboundState) reserveBulkCredit() *outboundBulkCreditLease { + if s == nil { + return nil + } + credit := &outboundBulkCredit{state: s} + s.bulkMu.Lock() + if s.bulkMax <= 0 { + s.bulkMax = defaultBulkACKWindow + } + if s.bulkClosed { + credit.released = true + } else if s.bulkInUse < s.bulkMax { + s.bulkInUse++ + credit.granted = true + } else { + s.bulkWaiters = append(s.bulkWaiters, credit) + } + s.bulkMu.Unlock() + return &outboundBulkCreditLease{credit: credit} +} + +func (c *outboundBulkCredit) subscribe(notify func(bool)) { + if c == nil || c.state == nil || notify == nil { + return + } + s := c.state + s.bulkMu.Lock() + c.notify = notify + granted, released := c.granted, c.released + shouldNotify := (granted || released) && !c.notified + if shouldNotify { + c.notified = true + } + s.bulkMu.Unlock() + if !shouldNotify { + return + } + if granted { + notify(true) + } else { + notify(false) + } +} + +func (c *outboundBulkCredit) release() { + if c == nil || c.state == nil { + return + } + s := c.state + var notifications []func(bool) + s.bulkMu.Lock() + if c.released { + s.bulkMu.Unlock() + return + } + c.released = true + if c.granted { + c.granted = false + s.bulkInUse-- + } + for !s.bulkClosed && s.bulkInUse < s.bulkMax && len(s.bulkWaiters) > 0 { + next := s.bulkWaiters[0] + s.bulkWaiters[0] = nil + s.bulkWaiters = s.bulkWaiters[1:] + if next == nil || next.released { + continue + } + next.granted = true + s.bulkInUse++ + if next.notify != nil && !next.notified { + next.notified = true + notifications = append(notifications, next.notify) + } + } + s.bulkMu.Unlock() + for _, notify := range notifications { + notify(true) + } +} + +func (s *outboundState) closeBulkWindow() { + if s == nil { + return + } + var notifications []func(bool) + s.bulkMu.Lock() + s.bulkClosed = true + for _, credit := range s.bulkWaiters { + if credit == nil || credit.released { + continue + } + credit.released = true + if credit.notify != nil && !credit.notified { + credit.notified = true + notifications = append(notifications, credit.notify) + } + } + s.bulkWaiters = nil + s.bulkMu.Unlock() + for _, notify := range notifications { + notify(false) + } +} + +func (m *encodedOutboundMessage) releaseBulkCredit() { + if m == nil || m.bulkCredit == nil { + return + } + credit := m.bulkCredit + m.bulkCredit = nil + credit.release() +} + // outboundTrackedBudget 是 body/control/write 三类预算共用的原子 byte-budget primitive。 // 每个实例只负责一类:普通 encoded body、encoded service frame + 控制向量,或 write // scratch;同一 reservation 不跨实例释放。 @@ -1004,6 +1347,7 @@ func newOutboundStateWithLimits(budget *outboundTrackedBudget, maxMessages, maxB maxMessages: maxMessages, maxBytes: maxBytes, budget: budget, + bulkMax: defaultBulkACKWindow, } } @@ -1024,10 +1368,10 @@ func (c *Conn) startOutbound() { criticalSize := min(defaultOutboundCriticalQueueSize, max(1, c.outboundQueueSize/8)) bulkSize := min(defaultOutboundBulkQueueSize, max(1, c.outboundQueueSize/8)) normalSize := c.outboundQueueSize - criticalSize - bulkSize - c.outbound = make(chan outboundOp, normalSize) - c.outboundControl = make(chan outboundOp, c.outboundControlQueueSize) - c.outboundCritical = make(chan outboundOp, criticalSize) - c.outboundBulk = make(chan outboundOp, bulkSize) + c.outbound = make(chan *outboundOp, normalSize) + c.outboundControl = make(chan *outboundOp, c.outboundControlQueueSize) + c.outboundCritical = make(chan *outboundOp, criticalSize) + c.outboundBulk = make(chan *outboundOp, bulkSize) c.outboundStop = make(chan struct{}) c.outboundDone = make(chan struct{}) // Publish the actor state before starting its goroutine. The pointer remains @@ -1244,6 +1588,7 @@ func (c *Conn) sendBestEffort(ctx context.Context, t proto.MessageType, msg bin. } if !c.beginOutboundEnqueue() { op.releaseReservation(c.outboundTrackedBudget) + c.releaseOutboundOp(op) return ErrConnClosed } defer c.endOutboundEnqueue() @@ -1257,11 +1602,13 @@ func (c *Conn) sendBestEffort(ctx context.Context, t proto.MessageType, msg bin. return nil case <-c.outboundStop: op.releaseReservation(c.outboundTrackedBudget) + c.releaseOutboundOp(op) return ErrConnClosed default: } if timeout == 0 { op.releaseReservation(c.outboundTrackedBudget) + c.releaseOutboundOp(op) c.metrics.OutboundDropped("push_queue_full") return ErrOutboundQueueFull } @@ -1280,13 +1627,16 @@ func (c *Conn) sendBestEffort(ctx context.Context, t proto.MessageType, msg bin. return nil case <-timeoutC: op.releaseReservation(c.outboundTrackedBudget) + c.releaseOutboundOp(op) c.metrics.OutboundDropped("push_queue_timeout") return ErrOutboundQueueFull case <-ctx.Done(): op.releaseReservation(c.outboundTrackedBudget) + c.releaseOutboundOp(op) return ctx.Err() case <-c.outboundStop: op.releaseReservation(c.outboundTrackedBudget) + c.releaseOutboundOp(op) return ErrConnClosed } } @@ -1298,6 +1648,7 @@ func (c *Conn) send(ctx context.Context, t proto.MessageType, msg bin.Encoder, c func (c *Conn) SendEncoded(ctx context.Context, t proto.MessageType, encoded *encodedOutboundMessage) error { if encoded != nil { if err := encoded.prepareDeliveryHook(c.deliveryHookExecutor()); err != nil { + encoded.releaseBulkCredit() return err } } @@ -1333,19 +1684,23 @@ func (c *Conn) enqueueEncodedDeliveryReserved( reserved *outboundBodyReservation, ) error { if c.outbound == nil || c.outboundControl == nil || c.outboundCritical == nil || c.outboundBulk == nil { + if encoded != nil { + encoded.releaseBulkCredit() + } return ErrConnClosed } if encoded != nil { if err := encoded.prepareDeliveryHook(c.deliveryHookExecutor()); err != nil { + encoded.releaseBulkCredit() return err } } - var op outboundOp + var op *outboundOp var err error if reserved == nil { op, err = c.newOutboundSendOp(ctx, t, nil, encoded, false) } else { - op, err = reserved.take(encoded) + op, err = reserved.take(encoded, c.operationPool()) if err == nil { op.msgType = t } @@ -1353,16 +1708,19 @@ func (c *Conn) enqueueEncodedDeliveryReserved( if err != nil { if encoded != nil { encoded.markReplayable() + encoded.releaseBulkCredit() } c.failOutboundBudget(err) return err } if !c.beginOutboundEnqueue() { - if reserved == nil || !reserved.reclaim(&op) { + if reserved == nil || !reserved.reclaim(op) { op.releaseReservation(c.outboundTrackedBudget) } + c.releaseOutboundOp(op) if encoded != nil { encoded.markReplayable() + encoded.releaseBulkCredit() } return ErrConnClosed } @@ -1371,12 +1729,14 @@ func (c *Conn) enqueueEncodedDeliveryReserved( op.enqueuedAt = time.Now() op.terminal = terminal if err := c.enqueueOutboundRegistered(ctx, op); err != nil { - if reserved == nil || !reserved.reclaim(&op) { + if reserved == nil || !reserved.reclaim(op) { op.releaseReservation(c.outboundTrackedBudget) } + c.releaseOutboundOp(op) c.endOutboundEnqueue() if encoded != nil { encoded.markReplayable() + encoded.releaseBulkCredit() } return err } @@ -1431,12 +1791,12 @@ func (c *Conn) sendOutboundWithTerminalReserved( } return ErrConnClosed } - var op outboundOp + var op *outboundOp var err error if reserved == nil { op, err = c.newOutboundSendOp(ctx, t, msg, encoded, control) } else { - op, err = reserved.take(encoded) + op, err = reserved.take(encoded, c.operationPool()) if err == nil { op.msgType = t op.msg = msg @@ -1451,9 +1811,10 @@ func (c *Conn) sendOutboundWithTerminalReserved( return err } if !c.beginOutboundEnqueue() { - if reserved == nil || !reserved.reclaim(&op) { + if reserved == nil || !reserved.reclaim(op) { op.releaseReservation(c.outboundTrackedBudget) } + c.releaseOutboundOp(op) if terminal != nil { terminal(ErrConnClosed) } @@ -1462,12 +1823,14 @@ func (c *Conn) sendOutboundWithTerminalReserved( op.control = control op.ctx = ctx op.enqueuedAt = time.Now() - op.done = make(chan outboundResult, 1) + done := make(chan outboundResult, 1) + op.done = done op.terminal = terminal if err := c.enqueueOutboundRegistered(ctx, op); err != nil { - if reserved == nil || !reserved.reclaim(&op) { + if reserved == nil || !reserved.reclaim(op) { op.releaseReservation(c.outboundTrackedBudget) } + c.releaseOutboundOp(op) c.endOutboundEnqueue() if terminal != nil { terminal(err) @@ -1476,21 +1839,21 @@ func (c *Conn) sendOutboundWithTerminalReserved( } c.endOutboundEnqueue() select { - case res := <-op.done: + case res := <-done: return res.err case <-ctx.Done(): // A physical write can complete at the same instant as the caller's // deadline. Prefer the actor's terminal result when it is already // available so required-control callers do not poison a healthy Conn. select { - case res := <-op.done: + case res := <-done: return res.err default: } return ctx.Err() case <-c.outboundStop: select { - case res := <-op.done: + case res := <-done: return res.err default: } @@ -1517,6 +1880,7 @@ func (c *Conn) SendAsync(ctx context.Context, t proto.MessageType, msg bin.Encod } if !c.beginOutboundEnqueue() { op.releaseReservation(c.outboundTrackedBudget) + c.releaseOutboundOp(op) return ErrConnClosed } defer c.endOutboundEnqueue() @@ -1529,9 +1893,11 @@ func (c *Conn) SendAsync(ctx context.Context, t proto.MessageType, msg bin.Encod return nil case <-c.outboundStop: op.releaseReservation(c.outboundTrackedBudget) + c.releaseOutboundOp(op) return ErrConnClosed default: op.releaseReservation(c.outboundTrackedBudget) + c.releaseOutboundOp(op) c.metrics.OutboundDropped("control_queue_full") return nil } @@ -1549,6 +1915,7 @@ func (c *Conn) AckServerMessages(ids []int64) { } if !c.beginOutboundEnqueue() { op.releaseReservation(c.outboundTrackedBudget) + c.releaseOutboundOp(op) return } defer c.endOutboundEnqueue() @@ -1556,8 +1923,10 @@ func (c *Conn) AckServerMessages(ids []int64) { case c.outboundControl <- op: case <-c.outboundStop: op.releaseReservation(c.outboundTrackedBudget) + c.releaseOutboundOp(op) default: op.releaseReservation(c.outboundTrackedBudget) + c.releaseOutboundOp(op) c.metrics.OutboundDropped("ack_queue_full") } } @@ -1574,13 +1943,15 @@ func (c *Conn) OutgoingStateInfo(ctx context.Context, ids []int64) ([]byte, erro return nil, err } op.ctx = ctx - op.done = make(chan outboundResult, 1) + done := make(chan outboundResult, 1) + op.done = done if err := c.enqueueOutbound(ctx, op); err != nil { op.releaseReservation(c.outboundTrackedBudget) + c.releaseOutboundOp(op) return nil, err } select { - case res := <-op.done: + case res := <-done: return res.info, res.err case <-ctx.Done(): return nil, ctx.Err() @@ -1600,13 +1971,15 @@ func (c *Conn) ResendMessages(ctx context.Context, ids []int64) ([]byte, error) return nil, err } op.ctx = ctx - op.done = make(chan outboundResult, 1) + done := make(chan outboundResult, 1) + op.done = done if err := c.enqueueOutbound(ctx, op); err != nil { op.releaseReservation(c.outboundTrackedBudget) + c.releaseOutboundOp(op) return nil, err } select { - case res := <-op.done: + case res := <-done: return res.info, res.err case <-ctx.Done(): return nil, ctx.Err() @@ -1620,18 +1993,19 @@ func (c *Conn) ResendByRequest(ctx context.Context, reqMsgID int64) (bool, error if c.outbound == nil { return false, ErrConnClosed } - op := outboundOp{ - kind: outboundResendByRequest, - control: true, - ctx: ctx, - reqMsgID: reqMsgID, - done: make(chan outboundResult, 1), - } + op := c.acquireOutboundOp() + done := make(chan outboundResult, 1) + op.kind = outboundResendByRequest + op.control = true + op.ctx = ctx + op.reqMsgID = reqMsgID + op.done = done if err := c.enqueueOutbound(ctx, op); err != nil { + c.releaseOutboundOp(op) return false, err } select { - case res := <-op.done: + case res := <-done: return res.resent, res.err case <-ctx.Done(): return false, ctx.Err() @@ -1640,7 +2014,7 @@ func (c *Conn) ResendByRequest(ctx context.Context, reqMsgID int64) (bool, error } } -func (c *Conn) enqueueOutbound(ctx context.Context, op outboundOp) error { +func (c *Conn) enqueueOutbound(ctx context.Context, op *outboundOp) error { if !c.beginOutboundEnqueue() { return ErrConnClosed } @@ -1648,7 +2022,7 @@ func (c *Conn) enqueueOutbound(ctx context.Context, op outboundOp) error { return c.enqueueOutboundRegistered(ctx, op) } -func (c *Conn) enqueueOutboundRegistered(ctx context.Context, op outboundOp) error { +func (c *Conn) enqueueOutboundRegistered(ctx context.Context, op *outboundOp) error { if ctx == nil { ctx = context.Background() } @@ -1676,7 +2050,7 @@ func (c *Conn) enqueueOutboundRegistered(ctx context.Context, op outboundOp) err } } -func (c *Conn) outboundQueue(op outboundOp) chan outboundOp { +func (c *Conn) outboundQueue(op *outboundOp) chan *outboundOp { if op.control || op.priority == outboundPriorityControl { return c.outboundControl } @@ -1736,11 +2110,13 @@ func (c *Conn) outboundLoop() { if c.isRetired() { op.finish(outboundResult{err: ErrConnClosed}) op.releaseReservation(c.outboundTrackedBudget) + c.releaseOutboundOp(op) c.signalOutboundStop() c.drainOutbound() return } c.handleOutboundOp(state, op) + c.releaseOutboundOp(op) if c.isRetired() { c.signalOutboundStop() c.drainOutbound() @@ -1752,13 +2128,13 @@ func (c *Conn) outboundLoop() { // nextOutboundOp applies the connection-wide egress policy without introducing // another writer. Required protocol controls stay strict, convergence RPCs pass // ordinary/bulk work, and a bounded ordinary burst guarantees bulk progress. -func (c *Conn) nextOutboundOp(ordinarySinceBulk *int) (outboundOp, bool) { - try := func(q <-chan outboundOp) (outboundOp, bool) { +func (c *Conn) nextOutboundOp(ordinarySinceBulk *int) (*outboundOp, bool) { + try := func(q <-chan *outboundOp) (*outboundOp, bool) { select { case op := <-q: return op, true default: - return outboundOp{}, false + return nil, false } } if op, ok := try(c.outboundControl); ok { @@ -1784,7 +2160,7 @@ func (c *Conn) nextOutboundOp(ordinarySinceBulk *int) (outboundOp, bool) { select { case <-c.outboundStop: - return outboundOp{}, false + return nil, false case op := <-c.outboundControl: return op, true case op := <-c.outboundCritical: @@ -1808,22 +2184,29 @@ func (c *Conn) drainOutbound() { case op := <-c.outboundControl: op.finish(outboundResult{err: ErrConnClosed}) op.releaseReservation(c.outboundTrackedBudget) + c.releaseOutboundOp(op) case op := <-c.outboundCritical: op.finish(outboundResult{err: ErrConnClosed}) op.releaseReservation(c.outboundTrackedBudget) + c.releaseOutboundOp(op) case op := <-c.outbound: op.finish(outboundResult{err: ErrConnClosed}) op.releaseReservation(c.outboundTrackedBudget) + c.releaseOutboundOp(op) case op := <-c.outboundBulk: op.finish(outboundResult{err: ErrConnClosed}) op.releaseReservation(c.outboundTrackedBudget) + c.releaseOutboundOp(op) default: return } } } -func (c *Conn) handleOutboundOp(state *outboundState, op outboundOp) { +func (c *Conn) handleOutboundOp(state *outboundState, op *outboundOp) { + if op == nil { + return + } // An operation can sit in a bounded queue across the protocol expiry instant. // It must be failed and released without touching the wire. if c.authKeyProtocolUnavailableNow() { @@ -1874,7 +2257,7 @@ func (c *Conn) handleOutboundOp(state *outboundState, op outboundOp) { op.finish(result) } -func (c *Conn) handleOutboundSend(state *outboundState, op outboundOp) error { +func (c *Conn) handleOutboundSend(state *outboundState, op *outboundOp) error { var binding *outboundLayerBinding if op.encoded != nil { binding = op.encoded.layer @@ -1939,8 +2322,16 @@ func (c *Conn) handleOutboundSend(state *outboundState, op outboundOp) error { } needsAck := err == nil && frame != nil && frameNeedsAck(frame.typeID) replaying := false + admitted := false + physicalFrame := frame if needsAck && frame.replayMsgID() != 0 { if existing := state.pending[frame.msgID]; existing != nil { + // The replay producer materialized and reserved an exact transient body. + // Keep the descriptor-backed logical owner compact and write a shallow + // physical attempt with the original msg_id/seq_no. + physical := *existing + physical.body = frame.body + physicalFrame = &physical frame = existing replaying = true } @@ -1960,6 +2351,7 @@ func (c *Conn) handleOutboundSend(state *outboundState, op outboundOp) error { err = admitErr } else { reserved = 0 + admitted = true } } } @@ -1973,13 +2365,26 @@ func (c *Conn) handleOutboundSend(state *outboundState, op outboundOp) error { c.metrics.OutboundDropped("stale_layer_epoch") } if err == nil { - err = c.writeFrame(op.ctx, frame) + err = c.writeFrame(op.ctx, physicalFrame) + } + physicalBytes := 0 + if physicalFrame != nil { + physicalBytes = len(physicalFrame.body) + } + if admitted && frame != nil && frame.replaySource != nil && op.encoded != nil { + // The terminal closure and joined-flight publication may still reference + // this encoder. Make that reference descriptor-backed before the frame + // releases its retained body charge. + op.encoded.body = nil + state.compactImmutableFrame(frame) + } + if !admitted && !replaying && op.encoded != nil { + op.encoded.releaseBulkCredit() } queueWait := time.Since(op.enqueuedAt) - bytes := 0 + bytes := physicalBytes typeID := uint32(0) if frame != nil { - bytes = len(frame.body) typeID = frame.typeID } c.metrics.OutboundSend(typeID, queueWait, bytes, err) @@ -2009,7 +2414,18 @@ func (c *Conn) handleOutboundResend(state *outboundState, ctx context.Context, i } return info, err } - if err := c.writeFrame(ctx, frame); err != nil { + physical, reservation, bodyLease, materializeErr := c.materializeFrameForWrite(ctx, frame) + if materializeErr != nil { + if lockedLayer { + c.layerProfileMu.RUnlock() + } + c.metrics.OutboundResend(resent, materializeErr) + return info, materializeErr + } + err := c.writeFrame(ctx, physical) + reservation.release() + bodyLease.release() + if err != nil { if lockedLayer { c.layerProfileMu.RUnlock() } @@ -2036,7 +2452,15 @@ func (c *Conn) handleOutboundResendByRequest(state *outboundState, ctx context.C if !ok { return false, nil } - if err := c.writeFrame(ctx, frame); err != nil { + physical, reservation, bodyLease, err := c.materializeFrameForWrite(ctx, frame) + if err != nil { + c.metrics.OutboundResend(0, err) + return false, err + } + err = c.writeFrame(ctx, physical) + reservation.release() + bodyLease.release() + if err != nil { c.metrics.OutboundResend(0, err) return false, err } @@ -2046,7 +2470,49 @@ func (c *Conn) handleOutboundResendByRequest(state *outboundState, ctx context.C return true, nil } -func (op outboundOp) finish(res outboundResult) { +func (c *Conn) materializeFrameForWrite(ctx context.Context, frame *outboundFrame) (*outboundFrame, *outboundBodyReservation, outboundReplayBodyLease, error) { + if frame == nil || len(frame.body) > 0 { + return frame, nil, outboundReplayBodyLease{}, nil + } + var ( + physical *outboundFrame + reserved *outboundBodyReservation + bodyLease outboundReplayBodyLease + ) + err := withOutboundEncodeSlot(ctx, c.outboundStop, func() error { + pool := c.replayBodyPool() + scratch, class := pool.acquire(frame.logicalSize()) + var err error + var usedScratch bool + physical, usedScratch, err = frame.materialized(ctx, scratch) + if err != nil { + pool.release(class, scratch) + return err + } + if usedScratch { + bodyLease = outboundReplayBodyLease{pool: pool, class: class, buf: physical.body} + } else { + pool.release(class, scratch) + } + budget := c.outboundMessageBudgetForPriority(frame.typeID, frame.priority, false) + if !budget.reserve(len(physical.body)) { + bodyLease.release() + physical = nil + return ErrOutboundTrackedBudget + } + reserved = &outboundBodyReservation{budget: budget, bytes: len(physical.body)} + return nil + }) + if err != nil { + return nil, nil, outboundReplayBodyLease{}, err + } + return physical, reserved, bodyLease, nil +} + +func (op *outboundOp) finish(res outboundResult) { + if op == nil { + return + } if op.terminal != nil { op.terminal(res.err) } @@ -2063,6 +2529,9 @@ func (op *outboundOp) releaseReservation(budget *outboundTrackedBudget) { if op == nil || op.reservedBytes <= 0 { return } + if op.encoded != nil { + op.encoded.releaseBulkCredit() + } if op.reservationBudget != nil { budget = op.reservationBudget } @@ -2076,22 +2545,22 @@ func (op *outboundOp) releaseReservation(budget *outboundTrackedBudget) { budget.release(bytes) } -func (c *Conn) newOutboundVectorOp(kind outboundOpKind, ids []int64) (outboundOp, error) { +func (c *Conn) newOutboundVectorOp(kind outboundOpKind, ids []int64) (*outboundOp, error) { bytes := len(ids) * 8 budget := c.ensureOutboundControlTrackedBudget() if !budget.reserve(bytes) { - return outboundOp{}, ErrOutboundTrackedBudget + return nil, ErrOutboundTrackedBudget } - return outboundOp{ - kind: kind, - control: true, - ids: append([]int64(nil), ids...), - reservedBytes: bytes, - reservationBudget: budget, - }, nil + op := c.acquireOutboundOp() + op.kind = kind + op.control = true + op.ids = append([]int64(nil), ids...) + op.reservedBytes = bytes + op.reservationBudget = budget + return op, nil } -func (c *Conn) newOutboundSendOp(ctx context.Context, t proto.MessageType, msg bin.Encoder, encoded *encodedOutboundMessage, priorityControl bool) (outboundOp, error) { +func (c *Conn) newOutboundSendOp(ctx context.Context, t proto.MessageType, msg bin.Encoder, encoded *encodedOutboundMessage, priorityControl bool) (*outboundOp, error) { var budget *outboundTrackedBudget if encoded == nil { var bytes int @@ -2108,7 +2577,7 @@ func (c *Conn) newOutboundSendOp(ctx context.Context, t proto.MessageType, msg b if bytes > maxOutboundBodyBytes { return fmt.Errorf("%w: body=%d limit=%d", ErrOutboundMessageTooLarge, bytes, maxOutboundBodyBytes) } - budget = c.outboundMessageBudget(encoded.typeID, priorityControl) + budget = c.outboundMessageBudgetForPriority(encoded.typeID, encoded.priority, priorityControl) // Keep the transient encode slot until the completed body has entered the // retained-byte budget. Otherwise goroutines could successively finish an // encode, be descheduled before reserve, and accumulate an unbounded number @@ -2119,41 +2588,41 @@ func (c *Conn) newOutboundSendOp(ctx context.Context, t proto.MessageType, msg b return nil }) if err != nil { - return outboundOp{}, err + return nil, err } - return outboundOp{ - kind: outboundSend, - msgType: t, - encoded: encoded, - priority: classifyOutboundPriority(encoded, priorityControl), - reservedBytes: bytes, - reservationBudget: budget, - }, nil + op := c.acquireOutboundOp() + op.kind = outboundSend + op.msgType = t + op.encoded = encoded + op.priority = classifyOutboundPriority(encoded, priorityControl) + op.reservedBytes = bytes + op.reservationBudget = budget + return op, nil } if encoded == nil { - return outboundOp{}, errors.New("nil encoded outbound message") + return nil, errors.New("nil encoded outbound message") } // Catch correction-before-enqueue synchronously. The actor repeats the same // validation immediately before framing to close the bounded queue race. if err := validateOutboundLayerBinding(c, encoded); err != nil { - return outboundOp{}, err + return nil, err } bytes := len(encoded.body) if bytes > maxOutboundBodyBytes { - return outboundOp{}, fmt.Errorf("%w: body=%d limit=%d", ErrOutboundMessageTooLarge, bytes, maxOutboundBodyBytes) + return nil, fmt.Errorf("%w: body=%d limit=%d", ErrOutboundMessageTooLarge, bytes, maxOutboundBodyBytes) } - budget = c.outboundMessageBudget(encoded.typeID, priorityControl) + budget = c.outboundMessageBudgetForPriority(encoded.typeID, encoded.priority, priorityControl) if !budget.reserve(bytes) { - return outboundOp{}, ErrOutboundTrackedBudget + return nil, ErrOutboundTrackedBudget } - return outboundOp{ - kind: outboundSend, - msgType: t, - encoded: encoded, - priority: classifyOutboundPriority(encoded, priorityControl), - reservedBytes: bytes, - reservationBudget: budget, - }, nil + op := c.acquireOutboundOp() + op.kind = outboundSend + op.msgType = t + op.encoded = encoded + op.priority = classifyOutboundPriority(encoded, priorityControl) + op.reservedBytes = bytes + op.reservationBudget = budget + return op, nil } func classifyOutboundPriority(encoded *encodedOutboundMessage, control bool) outboundPriority { @@ -2170,9 +2639,16 @@ func classifyOutboundPriority(encoded *encodedOutboundMessage, control bool) out } func (c *Conn) outboundMessageBudget(typeID uint32, priorityControl bool) *outboundTrackedBudget { + return c.outboundMessageBudgetForPriority(typeID, outboundPriorityNormal, priorityControl) +} + +func (c *Conn) outboundMessageBudgetForPriority(typeID uint32, priority outboundPriority, priorityControl bool) *outboundTrackedBudget { if priorityControl || encodedControlFrame(typeID) { return c.ensureOutboundControlTrackedBudget() } + if priority == outboundPriorityCritical { + return c.ensureOutboundCriticalTrackedBudget() + } return c.ensureOutboundTrackedBudget() } @@ -2209,6 +2685,15 @@ func (c *Conn) ensureOutboundControlTrackedBudget() *outboundTrackedBudget { return c.outboundControlTrackedBudget } +func (c *Conn) ensureOutboundCriticalTrackedBudget() *outboundTrackedBudget { + c.outboundCriticalBudgetOnce.Do(func() { + if c.outboundCriticalTrackedBudget == nil { + c.outboundCriticalTrackedBudget = newOutboundTrackedBudget(defaultOutboundCriticalMaxBytes) + } + }) + return c.outboundCriticalTrackedBudget +} + func (c *Conn) buildFrame(ctx context.Context, t proto.MessageType, msg bin.Encoder, encoded *encodedOutboundMessage) (*outboundFrame, error) { return c.buildFrameWithState(ctx, t, msg, encoded, c.outboundState) } @@ -2257,6 +2742,10 @@ func (c *Conn) buildFrameWithState( delivery: encoded.delivery, compressed: encoded.compressed, uncompressedBytes: encoded.uncompressedBytes, + replaySource: encoded.replaySource, + innerDigest: encoded.innerDigest, + logicalBytes: encoded.logicalBytes, + bulkCredit: encoded.bulkCredit, }, nil } @@ -2643,7 +3132,8 @@ func (s *outboundState) admitReserved(frame *outboundFrame) error { if _, exists := s.pending[frame.msgID]; exists { return fmt.Errorf("mtprotoedge: duplicate outbound msg_id inserted into resend tracking") } - if len(s.pending) >= s.maxMessages || s.totalBytes > s.maxBytes-len(frame.body) { + logicalBytes := frame.logicalSize() + if len(s.pending) >= s.maxMessages || logicalBytes > s.maxBytes || s.totalBytes > s.maxBytes-logicalBytes { return ErrOutboundTrackedBudget } s.insertReserved(frame) @@ -2659,7 +3149,7 @@ func (s *outboundState) insertReserved(frame *outboundFrame) { } s.pending[frame.msgID] = frame s.order = append(s.order, frame.msgID) - s.totalBytes += len(frame.body) + s.totalBytes += frame.logicalSize() if frame.reqMsgID != 0 { if s.byRequest == nil { s.byRequest = make(map[int64]int64) @@ -2671,6 +3161,28 @@ func (s *outboundState) insertReserved(frame *outboundFrame) { } } +// compactImmutableFrame drops the first-write body while preserving the +// logical ACK-window charge and exact replay proof. The existing reservation is +// reduced atomically under the logical-session actor lock. +func (s *outboundState) compactImmutableFrame(frame *outboundFrame) bool { + if s == nil || frame == nil || frame.replaySource == nil || len(frame.body) == 0 || frame.compressed { + return false + } + charge := max(outboundReplayDescriptorCharge, frame.replaySource.RetainedBytes()) + if charge <= 0 || charge >= frame.reservedBytes { + return false + } + budget := frame.reservationBudget + if budget == nil { + budget = s.budget + } + released := frame.reservedBytes - charge + frame.body = nil + frame.reservedBytes = charge + budget.release(released) + return true +} + // addReserved is retained as a focused-test helper. Production uses // admitReserved and never evicts an unacknowledged frame. func (s *outboundState) addReserved(frame *outboundFrame) int { @@ -2704,7 +3216,7 @@ func (s *outboundState) ackWithDetails(ids []int64) []outboundAcknowledgement { } detail := outboundAcknowledgement{ reqMsgID: frame.reqMsgID, - bytes: len(frame.body), + bytes: frame.logicalSize(), sentAt: frame.sentAt, } if !s.removePending(id) { @@ -2745,12 +3257,14 @@ func (s *outboundState) markAcked(id int64) { s.acked = make(map[int64]struct{}) } s.acked[id] = struct{}{} - s.ackOrder = append(s.ackOrder, id) - for len(s.ackOrder) > maxTrackedAckedMsgIDs { - oldest := s.ackOrder[0] - s.ackOrder = s.ackOrder[1:] - delete(s.acked, oldest) + if len(s.ackOrder) < maxTrackedAckedMsgIDs { + s.ackOrder = append(s.ackOrder, id) + return } + oldest := s.ackOrder[s.ackOrderHead] + s.ackOrder[s.ackOrderHead] = id + s.ackOrderHead = (s.ackOrderHead + 1) % len(s.ackOrder) + delete(s.acked, oldest) } // shrinkPending remains only for focused legacy state tests. Production @@ -2774,7 +3288,7 @@ func (s *outboundState) removePending(id int64) bool { return false } delete(s.pending, id) - bytes := len(frame.body) + bytes := frame.logicalSize() s.totalBytes -= bytes if frame.reqMsgID != 0 { if mapped, exists := s.byRequest[frame.reqMsgID]; exists && mapped == id { @@ -2783,20 +3297,34 @@ func (s *outboundState) removePending(id int64) bool { } // Clear the body reference before making these bytes available to another connection. frame.body = nil + frame.replaySource = nil + if frame.bulkCredit != nil { + frame.bulkCredit.release() + frame.bulkCredit = nil + } frame.releaseReservation(s.budget) return true } func (s *outboundState) releaseAll() { + s.closeBulkWindow() s.mu.Lock() defer s.mu.Unlock() for _, frame := range s.pending { frame.body = nil + frame.replaySource = nil + if frame.bulkCredit != nil { + frame.bulkCredit.release() + frame.bulkCredit = nil + } frame.releaseReservation(s.budget) } s.pending = nil s.order = nil s.byRequest = nil + s.acked = nil + s.ackOrder = nil + s.ackOrderHead = 0 s.totalBytes = 0 } @@ -2827,7 +3355,7 @@ func (s *outboundState) rpcResult(reqMsgID int64) (*encodedOutboundMessage, bool defer s.mu.Unlock() msgID := s.byRequest[reqMsgID] frame := s.pending[msgID] - if frame == nil || frame.typeID != proto.ResultTypeID || len(frame.body) == 0 { + if frame == nil || frame.typeID != proto.ResultTypeID || (len(frame.body) == 0 && frame.replaySource == nil) { return nil, false } return &encodedOutboundMessage{ @@ -2840,6 +3368,9 @@ func (s *outboundState) rpcResult(reqMsgID int64) (*encodedOutboundMessage, bool layerInvariant: frame.layerInvariant, compressed: frame.compressed, uncompressedBytes: frame.uncompressedBytes, + replaySource: frame.replaySource, + innerDigest: frame.innerDigest, + logicalBytes: frame.logicalBytes, replayMsgID: frame.msgID, replaySeqNo: frame.seqNo, }, true diff --git a/internal/mtprotoedge/outbound_replay_body_pool.go b/internal/mtprotoedge/outbound_replay_body_pool.go new file mode 100644 index 00000000..abcf5164 --- /dev/null +++ b/internal/mtprotoedge/outbound_replay_body_pool.go @@ -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 +} diff --git a/internal/mtprotoedge/outbound_required_control_test.go b/internal/mtprotoedge/outbound_required_control_test.go index 1f802313..6c7ecabe 100644 --- a/internal/mtprotoedge/outbound_required_control_test.go +++ b/internal/mtprotoedge/outbound_required_control_test.go @@ -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() diff --git a/internal/mtprotoedge/outbound_test.go b/internal/mtprotoedge/outbound_test.go index 5b1e1675..1d7c74a4 100644 --- a/internal/mtprotoedge/outbound_test.go +++ b/internal/mtprotoedge/outbound_test.go @@ -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,13 +561,17 @@ 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) { srv := New(Options{ - OutboundQueueSize: 7, - OutboundControlQueueSize: 3, - OutboundTrackedGlobalMaxBytes: 20, + 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) diff --git a/internal/mtprotoedge/rpc_result_egress.go b/internal/mtprotoedge/rpc_result_egress.go index 9b828b3e..a62718ef 100644 --- a/internal/mtprotoedge/rpc_result_egress.go +++ b/internal/mtprotoedge/rpc_result_egress.go @@ -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 { diff --git a/internal/mtprotoedge/rpc_result_egress_test.go b/internal/mtprotoedge/rpc_result_egress_test.go index 588df4c7..1eab519c 100644 --- a/internal/mtprotoedge/rpc_result_egress_test.go +++ b/internal/mtprotoedge/rpc_result_egress_test.go @@ -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) diff --git a/internal/mtprotoedge/rpc_result_retention_budget_test.go b/internal/mtprotoedge/rpc_result_retention_budget_test.go index 307309ba..f6d6459b 100644 --- a/internal/mtprotoedge/rpc_result_retention_budget_test.go +++ b/internal/mtprotoedge/rpc_result_retention_budget_test.go @@ -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) diff --git a/internal/mtprotoedge/rpc_rewrap.go b/internal/mtprotoedge/rpc_rewrap.go index 857d6711..b57a4954 100644 --- a/internal/mtprotoedge/rpc_rewrap.go +++ b/internal/mtprotoedge/rpc_rewrap.go @@ -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())) } diff --git a/internal/mtprotoedge/runtime_metrics.go b/internal/mtprotoedge/runtime_metrics.go index 85a9faa6..f296404c 100644 --- a/internal/mtprotoedge/runtime_metrics.go +++ b/internal/mtprotoedge/runtime_metrics.go @@ -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 diff --git a/internal/mtprotoedge/server.go b/internal/mtprotoedge/server.go index e7571a2d..635bb832 100644 --- a/internal/mtprotoedge/server.go +++ b/internal/mtprotoedge/server.go @@ -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) @@ -662,26 +667,29 @@ func (s *Server) newConnWithLease(lease *physicalTransportLease, key crypto.Auth func (s *Server) buildConn(tc transport.Conn, lease *physicalTransportLease, key crypto.AuthKey, sessionID, salt int64) *Conn { c := &Conn{ - transport: tc, - transportLease: lease, - writer: tc, - cipher: s.cipher, - msgID: proto.NewMessageIDGen(s.clock.Now), - writeTimeout: s.writeTimeout, - metrics: s.metrics, - now: s.clock.Now, - authKeyID: key.ID, - authKeyHex: hex.EncodeToString(key.ID[:]), - sessionID: sessionID, - salt: salt, - key: key, - createdAt: s.clock.Now(), - outboundQueueSize: s.outboundQueueSize, - outboundControlQueueSize: s.outboundControlQueueSize, - outboundTrackedBudget: s.outboundTrackedBudget, - outboundControlTrackedBudget: s.outboundControlBudget, - outboundScratchPool: s.outboundScratchPool, - rpcDeliveryHooks: s.rpcDeliveryHooks, + transport: tc, + transportLease: lease, + writer: tc, + cipher: s.cipher, + msgID: proto.NewMessageIDGen(s.clock.Now), + writeTimeout: s.writeTimeout, + metrics: s.metrics, + now: s.clock.Now, + authKeyID: key.ID, + authKeyHex: hex.EncodeToString(key.ID[:]), + sessionID: sessionID, + salt: salt, + key: key, + createdAt: s.clock.Now(), + outboundQueueSize: s.outboundQueueSize, + 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 // msgs_ack server msg_id through its tracked resend frame. The actor has @@ -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. diff --git a/internal/mtprotoedge/session_barrier_integration_test.go b/internal/mtprotoedge/session_barrier_integration_test.go index 6020dc38..05273c0a 100644 --- a/internal/mtprotoedge/session_barrier_integration_test.go +++ b/internal/mtprotoedge/session_barrier_integration_test.go @@ -77,7 +77,7 @@ func TestBadServerSaltRetainsOneProvisionalConnUntilCorrected(t *testing.T) { cs := newConnState() var plain bin.Buffer - firstConn, err := s.handleEncrypted(context.Background(), tr, cs, nil, &stored, firstWrong, &plain) + firstConn, err := s.handleEncrypted(context.Background(), tr, cs, nil, "", &stored, firstWrong, &plain) if err != nil { t.Fatalf("first bad salt: %v", err) } @@ -92,7 +92,7 @@ func TestBadServerSaltRetainsOneProvisionalConnUntilCorrected(t *testing.T) { secondWrong, _ := encryptedRPCFrameWithAuthoritativeSaltForBarrierTest( t, key, wrongSalt, serverSalt, sessionID, secondID, 3, ) - secondConn, err := s.handleEncrypted(context.Background(), tr, cs, firstConn, nil, secondWrong, &plain) + secondConn, err := s.handleEncrypted(context.Background(), tr, cs, firstConn, "", nil, secondWrong, &plain) if err != nil { t.Fatalf("second bad salt: %v", err) } @@ -123,7 +123,7 @@ func TestBadServerSaltRetainsOneProvisionalConnUntilCorrected(t *testing.T) { corrected, _ := encryptedRPCFrameWithAuthoritativeSaltForBarrierTest( t, key, serverSalt, serverSalt, sessionID, firstID, 1, ) - activeConn, err := s.handleEncrypted(context.Background(), tr, cs, secondConn, nil, corrected, &plain) + activeConn, err := s.handleEncrypted(context.Background(), tr, cs, secondConn, "", nil, corrected, &plain) if err != nil { t.Fatalf("corrected retry: %v", err) } @@ -171,7 +171,7 @@ func TestWrongSaltSessionChangeTransfersPhysicalOwnership(t *testing.T) { } cs := newConnState() var plain bin.Buffer - oldConn, err := s.handleEncrypted(context.Background(), tr, cs, nil, &stored, firstFrame, &plain) + oldConn, err := s.handleEncrypted(context.Background(), tr, cs, nil, "", &stored, firstFrame, &plain) if err != nil { t.Fatalf("activate first session: %v", err) } @@ -185,7 +185,7 @@ func TestWrongSaltSessionChangeTransfersPhysicalOwnership(t *testing.T) { wrongFrame, _ := encryptedRPCFrameWithAuthoritativeSaltForBarrierTest( t, key, wrongSalt, serverSalt, secondSID, secondID, 1, ) - newConn, err := s.handleEncrypted(context.Background(), tr, cs, oldConn, nil, wrongFrame, &plain) + newConn, err := s.handleEncrypted(context.Background(), tr, cs, oldConn, "", nil, wrongFrame, &plain) if err != nil { t.Fatalf("new session bad salt: %v", err) } @@ -205,7 +205,7 @@ func TestWrongSaltSessionChangeTransfersPhysicalOwnership(t *testing.T) { corrected, _ := encryptedRPCFrameWithAuthoritativeSaltForBarrierTest( t, key, serverSalt, serverSalt, secondSID, secondID, 1, ) - activated, err := s.handleEncrypted(context.Background(), tr, cs, newConn, nil, corrected, &plain) + activated, err := s.handleEncrypted(context.Background(), tr, cs, newConn, "", nil, corrected, &plain) if err != nil { t.Fatalf("activate transferred session: %v", err) } @@ -318,7 +318,7 @@ func TestHandleEncryptedRequiredSessionBarrierPrecedesStateRegistrationAndRPC(t } done := make(chan result, 1) go func() { - conn, err := s.handleEncrypted(context.Background(), tr, cs, nil, &stored, frame, &plain) + conn, err := s.handleEncrypted(context.Background(), tr, cs, nil, "", &stored, frame, &plain) done <- result{conn: conn, err: err} }() @@ -394,7 +394,7 @@ func TestHandleEncryptedRequiredSessionBarrierFailureIsAtomic(t *testing.T) { done := make(chan error, 1) go func() { - _, err := s.handleEncrypted(context.Background(), tr, cs, nil, &stored, frame, &plain) + _, err := s.handleEncrypted(context.Background(), tr, cs, nil, "", &stored, frame, &plain) done <- err }() select { @@ -458,7 +458,7 @@ func TestCrossConnectionInflightRPCHasOneBusinessOwnerAndReplaysResult(t *testin firstTransport := &collectingSessionTransport{} firstState := newConnState() var firstPlain bin.Buffer - firstConn, err := s.handleEncrypted(context.Background(), firstTransport, firstState, nil, &stored, firstFrame, &firstPlain) + firstConn, err := s.handleEncrypted(context.Background(), firstTransport, firstState, nil, "", &stored, firstFrame, &firstPlain) if err != nil { t.Fatalf("first handleEncrypted: %v", err) } @@ -478,7 +478,7 @@ func TestCrossConnectionInflightRPCHasOneBusinessOwnerAndReplaysResult(t *testin } secondDone := make(chan handleResult, 1) go func() { - conn, handleErr := s.handleEncrypted(context.Background(), secondTransport, secondState, nil, &stored, secondFrame, &secondPlain) + conn, handleErr := s.handleEncrypted(context.Background(), secondTransport, secondState, nil, "", &stored, secondFrame, &secondPlain) secondDone <- handleResult{conn: conn, err: handleErr} }() @@ -566,7 +566,7 @@ func TestCrossConnectionInflightAbortRetriesOnlyAfterOldOwnerStops(t *testing.T) firstTransport := &collectingSessionTransport{} firstState := newConnState() var firstPlain bin.Buffer - firstConn, err := s.handleEncrypted(context.Background(), firstTransport, firstState, nil, &stored, firstFrame, &firstPlain) + firstConn, err := s.handleEncrypted(context.Background(), firstTransport, firstState, nil, "", &stored, firstFrame, &firstPlain) if err != nil { t.Fatalf("first handleEncrypted: %v", err) } @@ -580,7 +580,7 @@ func TestCrossConnectionInflightAbortRetriesOnlyAfterOldOwnerStops(t *testing.T) secondTransport := &collectingSessionTransport{} secondState := newConnState() var secondPlain bin.Buffer - secondConn, err := s.handleEncrypted(context.Background(), secondTransport, secondState, nil, &stored, secondFrame, &secondPlain) + secondConn, err := s.handleEncrypted(context.Background(), secondTransport, secondState, nil, "", &stored, secondFrame, &secondPlain) if err != nil && !errors.Is(err, ErrConnClosed) { t.Fatalf("second handleEncrypted: %v", err) } @@ -605,7 +605,7 @@ func TestCrossConnectionInflightAbortRetriesOnlyAfterOldOwnerStops(t *testing.T) thirdTransport := &collectingSessionTransport{} thirdState := newConnState() var thirdPlain bin.Buffer - thirdConn, err := s.handleEncrypted(context.Background(), thirdTransport, thirdState, nil, &stored, thirdFrame, &thirdPlain) + thirdConn, err := s.handleEncrypted(context.Background(), thirdTransport, thirdState, nil, "", &stored, thirdFrame, &thirdPlain) if err != nil { t.Fatalf("third handleEncrypted: %v", err) } diff --git a/internal/mtprotoedge/session_manager_test.go b/internal/mtprotoedge/session_manager_test.go index 10a14495..92ae6465 100644 --- a/internal/mtprotoedge/session_manager_test.go +++ b/internal/mtprotoedge/session_manager_test.go @@ -219,8 +219,8 @@ func TestSessionManagerBestEffortFanoutPreparesOncePerProfile(t *testing.T) { c := &Conn{ sessionID: int64(i + 1), authKeyID: [8]byte{byte(i + 1)}, - outbound: make(chan outboundOp, 1), - outboundControl: make(chan outboundOp, 1), + outbound: make(chan *outboundOp, 1), + outboundControl: make(chan *outboundOp, 1), outboundStop: make(chan struct{}), metrics: NopMetrics{}, } @@ -270,8 +270,8 @@ func TestSessionManagerMixedLayerFanoutUsesProfileBoundBodies(t *testing.T) { c := &Conn{ sessionID: int64(profile), authKeyID: authKeyID, - outbound: make(chan outboundOp, 1), - outboundControl: make(chan outboundOp, 1), + outbound: make(chan *outboundOp, 1), + outboundControl: make(chan *outboundOp, 1), outboundStop: make(chan struct{}), metrics: NopMetrics{}, } @@ -390,11 +390,11 @@ func TestSessionManagerBestEffortFanoutUsesOneBudgetAndDropsOnlySlowConsumers(t authKeyID: [8]byte{byte(i + 1)}, transport: tr, metrics: NopMetrics{}, - outbound: make(chan outboundOp, 1), - outboundControl: make(chan outboundOp, 1), + outbound: make(chan *outboundOp, 1), + outboundControl: make(chan *outboundOp, 1), outboundStop: make(chan struct{}), } - c.outbound <- outboundOp{} + c.outbound <- &outboundOp{} c.userID.Store(userID) c.userIDResolved.Store(true) c.receivesUpdates.Store(true) @@ -409,8 +409,8 @@ func TestSessionManagerBestEffortFanoutUsesOneBudgetAndDropsOnlySlowConsumers(t sessionID: 99, authKeyID: [8]byte{99}, metrics: NopMetrics{}, - outbound: make(chan outboundOp, 1), - outboundControl: make(chan outboundOp, 1), + outbound: make(chan *outboundOp, 1), + outboundControl: make(chan *outboundOp, 1), outboundStop: make(chan struct{}), } healthy.userID.Store(userID) @@ -656,8 +656,8 @@ func TestForceCloseBatchTimeoutStillClosesProducerAndRPCGates(t *testing.T) { c := &Conn{ transport: tr, metrics: NopMetrics{}, - outbound: make(chan outboundOp, 1), - outboundControl: make(chan outboundOp, 1), + outbound: make(chan *outboundOp, 1), + outboundControl: make(chan *outboundOp, 1), outboundStop: make(chan struct{}), } c.startInboundRPCScheduler(scheduler, 1, 1, time.Second) @@ -743,8 +743,8 @@ func TestPushToUserAuthKeyUsesOneDeadlineAndDropsOnlySlowPFSConnections(t *testi sessionID: sessionID, metrics: NopMetrics{}, transport: transport, - outbound: make(chan outboundOp, 1), - outboundControl: make(chan outboundOp, 1), + outbound: make(chan *outboundOp, 1), + outboundControl: make(chan *outboundOp, 1), outboundStop: make(chan struct{}), } c.receivesUpdates.Store(true) @@ -752,7 +752,7 @@ func TestPushToUserAuthKeyUsesOneDeadlineAndDropsOnlySlowPFSConnections(t *testi t.Fatalf("freeze profile: %v", err) } if queueFull { - c.outbound <- outboundOp{} + c.outbound <- &outboundOp{} } sm.Register(c) sm.BindAuthKeyForSession(raw, sessionID, business) @@ -961,8 +961,8 @@ func TestPushToSessionForAuthKeyImmediateBypassesReadinessQueue(t *testing.T) { c := &Conn{ sessionID: 42, authKeyID: raw, - outbound: make(chan outboundOp, 1), - outboundControl: make(chan outboundOp, 1), + outbound: make(chan *outboundOp, 1), + outboundControl: make(chan *outboundOp, 1), outboundStop: make(chan struct{}), } if err := c.FreezeLayerProfile(tlprofile.Profile227); err != nil { @@ -1006,8 +1006,8 @@ func TestSessionManagerWithholdsUpdatesReadinessUntilExactProfile(t *testing.T) c := &Conn{ authKeyID: key.authKeyID, sessionID: key.sessionID, - outbound: make(chan outboundOp, 1), - outboundControl: make(chan outboundOp, 1), + outbound: make(chan *outboundOp, 1), + outboundControl: make(chan *outboundOp, 1), outboundStop: make(chan struct{}), metrics: NopMetrics{}, outboundTrackedBudget: newOutboundTrackedBudget(1 << 20), @@ -1049,7 +1049,7 @@ func TestSessionManagerWithholdsUpdatesReadinessUntilExactProfile(t *testing.T) } c.membershipsSynced.Store(true) sm.SetReceivesUpdatesForAuthKey(key.authKeyID, key.sessionID, true) - var op outboundOp + var op *outboundOp select { case op = <-c.outbound: case <-time.After(time.Second): @@ -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), diff --git a/internal/mtprotoedge/shutdown_gate_test.go b/internal/mtprotoedge/shutdown_gate_test.go index fae24f9c..f17282d2 100644 --- a/internal/mtprotoedge/shutdown_gate_test.go +++ b/internal/mtprotoedge/shutdown_gate_test.go @@ -22,8 +22,8 @@ func TestTerminalFailurePathsCloseGatesBeforeBlockingTransportClose(t *testing.T c := &Conn{ transport: tr, metrics: NopMetrics{}, - outbound: make(chan outboundOp, 1), - outboundControl: make(chan outboundOp, 1), + outbound: make(chan *outboundOp, 1), + outboundControl: make(chan *outboundOp, 1), outboundStop: make(chan struct{}), } c.startInboundRPCScheduler(scheduler, 1, 1, time.Second) diff --git a/internal/mtprotoedge/structural_limits_test.go b/internal/mtprotoedge/structural_limits_test.go index 6e3ece56..cf30af10 100644 --- a/internal/mtprotoedge/structural_limits_test.go +++ b/internal/mtprotoedge/structural_limits_test.go @@ -224,8 +224,8 @@ func TestContainerInvalidSequenceTailIsAtomic(t *testing.T) { cs := newConnState() c := &Conn{ metrics: NopMetrics{}, - outbound: make(chan outboundOp, 4), - outboundControl: make(chan outboundOp, 4), + outbound: make(chan *outboundOp, 4), + outboundControl: make(chan *outboundOp, 4), outboundStop: make(chan struct{}), } var acks []int64 diff --git a/internal/mtprotoedge/transient_push_test.go b/internal/mtprotoedge/transient_push_test.go index 6db7a0cb..e34d0546 100644 --- a/internal/mtprotoedge/transient_push_test.go +++ b/internal/mtprotoedge/transient_push_test.go @@ -21,8 +21,8 @@ func TestPushTransientSkipsNotReadySession(t *testing.T) { c := &Conn{ sessionID: 7, authKeyID: [8]byte{7}, - outbound: make(chan outboundOp, 4), - outboundControl: make(chan outboundOp, 4), + outbound: make(chan *outboundOp, 4), + outboundControl: make(chan *outboundOp, 4), outboundStop: make(chan struct{}), } c.userID.Store(userID) @@ -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) diff --git a/internal/observability/metrics/registry.go b/internal/observability/metrics/registry.go index fc4c60dd..3f8f1b80 100644 --- a/internal/observability/metrics/registry.go +++ b/internal/observability/metrics/registry.go @@ -227,9 +227,12 @@ func (r *Registry) add(name string, value uint64, labels ...Label) { } func (r *Registry) addGauge(name string, delta int64, labels ...Label) { - if r == nil || delta == 0 { + if r == nil { return } + // A zero delta still touches the series: callers use it to declare an + // idle gauge exists (e.g. XxxPending(0) at startup) so /metrics exposes + // "0" immediately instead of omitting the line until first activity. r.gauge(newSeriesKey(name, labels...)).value.Add(delta) } diff --git a/internal/rpc/aicompose_webpage.go b/internal/rpc/aicompose_webpage.go index 081fea46..148b0824 100644 --- a/internal/rpc/aicompose_webpage.go +++ b/internal/rpc/aicompose_webpage.go @@ -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, diff --git a/internal/rpc/channels_updates.go b/internal/rpc/channels_updates.go index 1290a0de..a93ea0da 100644 --- a/internal/rpc/channels_updates.go +++ b/internal/rpc/channels_updates.go @@ -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) diff --git a/internal/rpc/context.go b/internal/rpc/context.go index c8f5e4c0..689b2e61 100644 --- a/internal/rpc/context.go +++ b/internal/rpc/context.go @@ -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) +} diff --git a/internal/rpc/convert_auth.go b/internal/rpc/convert_auth.go index ea3b0f55..65df42a8 100644 --- a/internal/rpc/convert_auth.go +++ b/internal/rpc/convert_auth.go @@ -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 } diff --git a/internal/rpc/convert_messages.go b/internal/rpc/convert_messages.go index 122253b6..5ce7a2a0 100644 --- a/internal/rpc/convert_messages.go +++ b/internal/rpc/convert_messages.go @@ -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) diff --git a/internal/rpc/errors.go b/internal/rpc/errors.go index b74026f5..91bb6a48 100644 --- a/internal/rpc/errors.go +++ b/internal/rpc/errors.go @@ -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 { diff --git a/internal/rpc/help.go b/internal/rpc/help.go index c3b48dc1..49628d6d 100644 --- a/internal/rpc/help.go +++ b/internal/rpc/help.go @@ -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) diff --git a/internal/rpc/layer_dispatch.go b/internal/rpc/layer_dispatch.go index 1088b94e..105522bf 100644 --- a/internal/rpc/layer_dispatch.go +++ b/internal/rpc/layer_dispatch.go @@ -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{ diff --git a/internal/rpc/messages_external_reply_test.go b/internal/rpc/messages_external_reply_test.go new file mode 100644 index 00000000..1f37bd81 --- /dev/null +++ b/internal/rpc/messages_external_reply_test.go @@ -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") + } +} diff --git a/internal/rpc/messages_forward.go b/internal/rpc/messages_forward.go index 4382890b..a7aaee86 100644 --- a/internal/rpc/messages_forward.go +++ b/internal/rpc/messages_forward.go @@ -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): diff --git a/internal/rpc/messages_history.go b/internal/rpc/messages_history.go index b70149d9..12572601 100644 --- a/internal/rpc/messages_history.go +++ b/internal/rpc/messages_history.go @@ -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. diff --git a/internal/rpc/messages_history_rpc_test.go b/internal/rpc/messages_history_rpc_test.go index 9a7e6f65..ca13b41b 100644 --- a/internal/rpc/messages_history_rpc_test.go +++ b/internal/rpc/messages_history_rpc_test.go @@ -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, diff --git a/internal/rpc/messages_reactions_test.go b/internal/rpc/messages_reactions_test.go index d47d2afb..da082a9a 100644 --- a/internal/rpc/messages_reactions_test.go +++ b/internal/rpc/messages_reactions_test.go @@ -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", diff --git a/internal/rpc/messages_read_filter.go b/internal/rpc/messages_read_filter.go new file mode 100644 index 00000000..afac0680 --- /dev/null +++ b/internal/rpc/messages_read_filter.go @@ -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) + } +} diff --git a/internal/rpc/messages_read_filter_test.go b/internal/rpc/messages_read_filter_test.go new file mode 100644 index 00000000..2211072b --- /dev/null +++ b/internal/rpc/messages_read_filter_test.go @@ -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) + } +} diff --git a/internal/rpc/messages_register.go b/internal/rpc/messages_register.go index 6fed4a9b..ea33dfdc 100644 --- a/internal/rpc/messages_register.go +++ b/internal/rpc/messages_register.go @@ -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 { diff --git a/internal/rpc/messages_saved_forward_boundary_test.go b/internal/rpc/messages_saved_forward_boundary_test.go new file mode 100644 index 00000000..ff4cb96d --- /dev/null +++ b/internal/rpc/messages_saved_forward_boundary_test.go @@ -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) + } + } +} diff --git a/internal/rpc/messages_send.go b/internal/rpc/messages_send.go index d83668a5..a075f777 100644 --- a/internal/rpc/messages_send.go +++ b/internal/rpc/messages_send.go @@ -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 diff --git a/internal/rpc/presence.go b/internal/rpc/presence.go index 273121bb..e1b8b9d0 100644 --- a/internal/rpc/presence.go +++ b/internal/rpc/presence.go @@ -51,7 +51,9 @@ type presenceTracker struct { byUser map[int64]map[presenceSessionKey]domain.UserStatus // offlineTimers 跟踪每个 user 挂起的 offline 广播去抖定时器,使重新上线能取消它, // 避免断连风暴下 O(N) 个裸 time.AfterFunc 在 runtime timer heap 长期堆积。 - offlineTimers map[int64]*time.Timer + offlineTimers map[int64]*time.Timer + offlineRunning int64 + lastSeenDirectRunning int64 } func newPresenceTracker() *presenceTracker { @@ -62,8 +64,8 @@ func newPresenceTracker() *presenceTracker { } } -// armOfflineTimer 安排(或重置)某 user 的 offline 广播去抖定时器。定时器触发时先把自己 -// 从 map 移除再执行 fire(fire 内部会查在线态做最终去抖),全程不持 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() + 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) diff --git a/internal/rpc/presence_last_seen_batch.go b/internal/rpc/presence_last_seen_batch.go index 90243671..a80ed1aa 100644 --- a/internal/rpc/presence_last_seen_batch.go +++ b/internal/rpc/presence_last_seen_batch.go @@ -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)) } diff --git a/internal/rpc/presence_last_seen_batch_test.go b/internal/rpc/presence_last_seen_batch_test.go index d9d6ed93..2b3600fa 100644 --- a/internal/rpc/presence_last_seen_batch_test.go +++ b/internal/rpc/presence_last_seen_batch_test.go @@ -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 diff --git a/internal/rpc/presence_timer_generation_test.go b/internal/rpc/presence_timer_generation_test.go new file mode 100644 index 00000000..2601c50d --- /dev/null +++ b/internal/rpc/presence_timer_generation_test.go @@ -0,0 +1,39 @@ +package rpc + +import ( + "sync/atomic" + "testing" + "time" +) + +func TestPresenceOfflineExpiredCallbackCannotEraseReplacement(t *testing.T) { + p := newPresenceTracker() + var fired atomic.Int64 + p.armOfflineTimer(7, 10*time.Millisecond, func() { fired.Add(1) }) + p.mu.Lock() + old := p.offlineTimers[7] + // The real AfterFunc has expired and is waiting for the tracker lock. + time.Sleep(100 * time.Millisecond) + if old.Stop() { + p.mu.Unlock() + t.Fatal("old callback has not started") + } + // Reproduce the replacement critical section before the old callback + // reacquires the lock. No scheduler timing or production test hook. + replacement := time.AfterFunc(time.Hour, func() { fired.Add(100) }) + defer replacement.Stop() + p.offlineTimers[7] = replacement + p.mu.Unlock() + // Synchronize on the callback lock acquisition without holding it. + time.Sleep(20 * time.Millisecond) + p.mu.RLock() + current := p.offlineTimers[7] + p.mu.RUnlock() + if current != replacement || fired.Load() != 0 { + t.Fatalf("stale callback: replacement retained=%v, business callbacks=%d", current == replacement, fired.Load()) + } + p.cancelOfflineTimer(7) + if replacement.Stop() { + t.Fatal("replacement was no longer cancelable through the tracker") + } +} diff --git a/internal/rpc/presence_work.go b/internal/rpc/presence_work.go new file mode 100644 index 00000000..369a4c22 --- /dev/null +++ b/internal/rpc/presence_work.go @@ -0,0 +1,41 @@ +package rpc + +// PresenceWorkSnapshot measures pending grace callbacks and their last-seen +// writers, including online last-seen work in the shared batch. It is not a +// durability receipt or an inventory of all router background work. +type PresenceWorkSnapshot struct { + WaitingTimers int64 + RunningCallbacks int64 + DirectWrites int64 + PendingLastSeen int64 +} + +// PresenceWorkSnapshot is bounded and identity-free. Holding the tracker lock +// across the batch read prevents a false zero at callback -> batch handoff: +// submission precedes the callback's locked running-count decrement. +func (r *Router) PresenceWorkSnapshot() PresenceWorkSnapshot { + if r == nil || r.presence == nil { + return PresenceWorkSnapshot{} + } + p := r.presence + p.mu.RLock() + defer p.mu.RUnlock() + s := PresenceWorkSnapshot{ + WaitingTimers: int64(len(p.offlineTimers)), + RunningCallbacks: p.offlineRunning, + DirectWrites: p.lastSeenDirectRunning, + } + if r.lastSeenBatch != nil { + s.PendingLastSeen = r.lastSeenBatch.pending.Load() + } + return s +} + +func (p *presenceTracker) changeDirectLastSeenRunning(delta int64) { + if p == nil { + return + } + p.mu.Lock() + p.lastSeenDirectRunning += delta + p.mu.Unlock() +} diff --git a/internal/rpc/presence_work_test.go b/internal/rpc/presence_work_test.go new file mode 100644 index 00000000..07dbdedf --- /dev/null +++ b/internal/rpc/presence_work_test.go @@ -0,0 +1,202 @@ +package rpc + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "go.uber.org/zap" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +func waitPresenceWork(t *testing.T, r *Router, want PresenceWorkSnapshot) { + t.Helper() + deadline := time.Now().Add(time.Second) + for { + got := r.PresenceWorkSnapshot() + if got == want { + return + } + if time.Now().After(deadline) { + t.Fatalf("presence work = %+v, want %+v", got, want) + } + time.Sleep(time.Millisecond) + } +} + +func TestPresenceWorkRunningCallbackSurvivesReplacementAndCancel(t *testing.T) { + r := &Router{presence: newPresenceTracker()} + started, release := make(chan struct{}), make(chan struct{}) + r.presence.armOfflineTimer(7, 0, func() { close(started); <-release }) + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("callback did not start") + } + defer func() { + close(release) + waitPresenceWork(t, r, PresenceWorkSnapshot{}) + }() + waitPresenceWork(t, r, PresenceWorkSnapshot{RunningCallbacks: 1}) + r.presence.armOfflineTimer(7, time.Hour, func() { t.Error("canceled replacement fired") }) + waitPresenceWork(t, r, PresenceWorkSnapshot{WaitingTimers: 1, RunningCallbacks: 1}) + r.presence.cancelOfflineTimer(7) + waitPresenceWork(t, r, PresenceWorkSnapshot{RunningCallbacks: 1}) +} + +func TestPresenceWorkWaitingCancellationAndOtherDevice(t *testing.T) { + r := &Router{presence: newPresenceTracker()} + key := presenceSessionKey{sessionID: 2} + r.presence.setSessionStatus(key, 7, domain.UserStatus{Kind: domain.UserStatusOnline, Expires: 100}) + r.SessionOfflineAt([8]byte{}, 1, 7, false, 50) + waitPresenceWork(t, r, PresenceWorkSnapshot{}) + if _, ok := r.presence.statusFor(7, 60); !ok { + t.Fatal("departure erased the other device") + } + r.presence.armOfflineTimer(7, time.Hour, func() { t.Error("canceled timer fired") }) + waitPresenceWork(t, r, PresenceWorkSnapshot{WaitingTimers: 1}) + r.presence.cancelOfflineTimer(7) + waitPresenceWork(t, r, PresenceWorkSnapshot{}) +} + +type heldPresenceBatch struct { + started chan struct{} + release chan struct{} + once sync.Once +} + +func (s *heldPresenceBatch) UpdateLastSeenBatch(ctx context.Context, _ []store.UserLastSeenUpdate) error { + s.once.Do(func() { close(s.started) }) + select { + case <-s.release: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func TestPresenceWorkCallbackToBatchHasNoUnobservedHandoff(t *testing.T) { + u := &heldPresenceBatch{started: make(chan struct{}), release: make(chan struct{})} + metrics := &capturePresenceLastSeenMetrics{} + d := newPresenceLastSeenBatchDispatcher(u, presenceLastSeenBatchConfig{MaxSize: 1}, zap.NewNop(), metrics) + r := &Router{presence: newPresenceTracker(), lastSeenBatch: d} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { d.Run(ctx); close(done) }() + defer func() { close(u.release); cancel(); <-done }() + callbackStarted, submit := make(chan struct{}), make(chan struct{}) + r.presence.armOfflineTimer(7, 0, func() { + close(callbackStarted) + <-submit + if err := d.submit(store.UserLastSeenUpdate{UserID: 7, LastSeenAt: 99}); err != nil { + t.Error(err) + } + }) + <-callbackStarted + var observing atomic.Bool + observing.Store(true) + snapshotsDone := make(chan struct{}) + go func() { + defer close(snapshotsDone) + for observing.Load() { + s := r.PresenceWorkSnapshot() + if s.RunningCallbacks+s.PendingLastSeen != 1 && s.RunningCallbacks+s.PendingLastSeen != 2 { + t.Errorf("unobserved callback handoff: %+v", s) + return + } + } + }() + close(submit) + select { + case <-u.started: + case <-time.After(time.Second): + t.Fatal("batch did not begin") + } + waitPresenceWork(t, r, PresenceWorkSnapshot{PendingLastSeen: 1}) + observing.Store(false) + <-snapshotsDone + if d.pending.Load() != metrics.pending.Load() || metrics.pending.Load() != 1 { + t.Fatal("snapshot and exported batch pending disagree") + } +} + +type heldDirectLastSeen struct{ started, release chan struct{} } + +func (u heldDirectLastSeen) UpdateLastSeen(ctx context.Context, _ int64, _ int) error { + close(u.started) + select { + case <-u.release: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func TestPresenceWorkDirectWriteRemainsVisibleAfterParentReturns(t *testing.T) { + u := heldDirectLastSeen{make(chan struct{}), make(chan struct{})} + r := &Router{presence: newPresenceTracker(), log: zap.NewNop()} + r.persistReservedLastSeenAsync(context.Background(), u, 7, 99) + <-u.started + waitPresenceWork(t, r, PresenceWorkSnapshot{DirectWrites: 1}) + close(u.release) + waitPresenceWork(t, r, PresenceWorkSnapshot{}) +} + +func TestPresenceWorkConcurrentArmCancel(t *testing.T) { + p := newPresenceTracker() + var workers sync.WaitGroup + for i := 0; i < 8; i++ { + workers.Go(func() { + for j := 0; j < 100; j++ { + p.armOfflineTimer(int64(j%4+1), time.Hour, func() { t.Error("canceled timer fired") }) + p.cancelOfflineTimer(int64(j%4 + 1)) + } + }) + } + workers.Wait() + for user := int64(1); user <= 4; user++ { + p.cancelOfflineTimer(user) + } + waitPresenceWork(t, &Router{presence: p}, PresenceWorkSnapshot{}) +} + +func TestPresenceWorkBatchRetryDrainAndOverflowAccounting(t *testing.T) { + u := &capturePresenceLastSeenUpdater{failFirst: 1, called: make(chan struct{}, 8)} + m := &capturePresenceLastSeenMetrics{} + d := newPresenceLastSeenBatchDispatcher(u, presenceLastSeenBatchConfig{MaxSize: 1, QueueSize: 1, DrainTimeout: time.Second}, zap.NewNop(), m) + if err := d.submit(store.UserLastSeenUpdate{UserID: 7, LastSeenAt: 99}); err != nil { + t.Fatal(err) + } + if err := d.submit(store.UserLastSeenUpdate{UserID: 8, LastSeenAt: 100}); !errors.Is(err, errPresenceLastSeenBatchFull) { + t.Fatalf("overflow = %v", err) + } + if d.pending.Load() != 1 || m.pending.Load() != 1 { + t.Fatal("rejected work changed pending") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + d.Run(ctx) + if d.pending.Load() != 0 || m.pending.Load() != 0 || m.failures.Load() != 1 || m.dropped.Load() != 0 { + t.Fatalf("drain accounting: pending=%d metric=%d failures=%d dropped=%d", d.pending.Load(), m.pending.Load(), m.failures.Load(), m.dropped.Load()) + } +} + +func TestPresenceWorkFailedDrainIsCountedNotSuccessful(t *testing.T) { + u := &capturePresenceLastSeenUpdater{failFirst: 1000, called: make(chan struct{}, 8)} + m := &capturePresenceLastSeenMetrics{} + d := newPresenceLastSeenBatchDispatcher(u, presenceLastSeenBatchConfig{MaxSize: 1, DrainTimeout: 10 * time.Millisecond}, zap.NewNop(), m) + if err := d.submit(store.UserLastSeenUpdate{UserID: 7, LastSeenAt: 99}); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + d.Run(ctx) + if d.pending.Load() != 0 || m.pending.Load() != 0 || m.dropped.Load() != 1 || m.failures.Load() < 1 { + t.Fatalf("failed drain accounting: pending=%d metric=%d failures=%d dropped=%d", d.pending.Load(), m.pending.Load(), m.failures.Load(), m.dropped.Load()) + } +} diff --git a/internal/rpc/router.go b/internal/rpc/router.go index e4121412..cb5422aa 100644 --- a/internal/rpc/router.go +++ b/internal/rpc/router.go @@ -1078,6 +1078,9 @@ func (r *Router) persistAuthKeyClientInfo(ctx context.Context, info clientSessio return } domainInfo := domainAuthKeyClientInfo(info) + if ip, ok := ClientIPFrom(ctx); ok { + domainInfo.IP = ip + } if domainInfo.Layer == 0 && domainInfo.DeviceModel == "" && domainInfo.Platform == "" && domainInfo.SystemVersion == "" && domainInfo.APIID == 0 && domainInfo.AppVersion == "" { return diff --git a/internal/rpc/router_dispatch_test.go b/internal/rpc/router_dispatch_test.go index a82df0c2..7ba717e7 100644 --- a/internal/rpc/router_dispatch_test.go +++ b/internal/rpc/router_dispatch_test.go @@ -508,6 +508,31 @@ func TestObservedClientLayerNeverLeaksAcrossAuthKeySessions(t *testing.T) { } } +func TestPersistAuthKeyClientInfoCarriesClientIP(t *testing.T) { + authKeyID := [8]byte{0x68, 0x25, 0x7a, 0x09} + auth := &captureAuthService{} + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Auth: auth, + }, zaptest.NewLogger(t), clock.System) + ctx := WithAuthKeyID( + WithRawAuthKeyID(WithClientIP(context.Background(), "203.0.113.17"), authKeyID), + authKeyID, + ) + + r.persistAuthKeyClientInfo(ctx, clientSessionInfo{ + layer: currentClientLayer, + hasClientInfo: true, + clientInfo: ClientInfo{ + APIID: 2040, DeviceModel: "Pixel 9", SystemVersion: "SDK 36", AppVersion: "12.8.7", + }, + }) + + got := auth.authKeyClientInfos[authKeyID].IP + if got != "203.0.113.17" { + t.Fatalf("persisted client IP = %q, want %q", got, "203.0.113.17") + } +} + func TestInvokeWithLayerPersistsClientLayerUpgrade(t *testing.T) { authKeyID := [8]byte{0x68, 0x25, 0x7a, 0x02} userID := int64(1780269504) diff --git a/internal/rpc/rpc_testkit_auth_test.go b/internal/rpc/rpc_testkit_auth_test.go index 227e3630..716a974a 100644 --- a/internal/rpc/rpc_testkit_auth_test.go +++ b/internal/rpc/rpc_testkit_auth_test.go @@ -315,6 +315,9 @@ func (s *captureAuthService) UpdateAuthKeyClientInfo(_ context.Context, authKeyI if info.AppVersion != "" { current.AppVersion = info.AppVersion } + if info.IP != "" { + current.IP = info.IP + } s.authKeyClientInfos[authKeyID] = current for i := range s.authorizations { if s.authorizations[i].AuthKeyID != authKeyID { @@ -335,6 +338,9 @@ func (s *captureAuthService) UpdateAuthKeyClientInfo(_ context.Context, authKeyI if info.AppVersion != "" { s.authorizations[i].AppVersion = info.AppVersion } + if info.IP != "" { + s.authorizations[i].IP = info.IP + } } return nil } diff --git a/internal/rpc/rpc_testkit_users_test.go b/internal/rpc/rpc_testkit_users_test.go index 72582071..342aacc2 100644 --- a/internal/rpc/rpc_testkit_users_test.go +++ b/internal/rpc/rpc_testkit_users_test.go @@ -15,11 +15,12 @@ type mapUsersService struct { type countingMapUsersService struct { mapUsersService - selfCalls int - byIDCalls int - byIDsCalls int - lastByIDs []int64 - byIDsBatches [][]int64 + selfCalls int + byIDCalls int + byIDsCalls int + baseByIDsCalls int + lastByIDs []int64 + byIDsBatches [][]int64 } func (s staticUsersService) Self(context.Context, int64) (domain.User, error) { @@ -104,6 +105,11 @@ func (s *countingMapUsersService) ByIDs(ctx context.Context, currentUserID int64 return s.mapUsersService.ByIDs(ctx, currentUserID, userIDs) } +func (s *countingMapUsersService) BaseUsersByIDs(ctx context.Context, userIDs []int64) ([]domain.User, error) { + s.baseByIDsCalls++ + return s.mapUsersService.ByIDs(ctx, 1, userIDs) +} + type captureUsersService struct { user domain.User userID int64 diff --git a/internal/rpc/story_peer_projection_rpc_test.go b/internal/rpc/story_peer_projection_rpc_test.go index 67f9376f..269b5ab1 100644 --- a/internal/rpc/story_peer_projection_rpc_test.go +++ b/internal/rpc/story_peer_projection_rpc_test.go @@ -782,6 +782,7 @@ func TestMessagesHistoryAndSearchProjectStoriesMaxID(t *testing.T) { }} r := New(Config{}, Deps{ Messages: messages, + Users: mapUsersService{users: map[int64]domain.User{owner.ID: owner, viewer.ID: viewer}}, Stories: appstories.NewService(storyStore), }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) peer := &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash} diff --git a/internal/rpc/update_peer_refs.go b/internal/rpc/update_peer_refs.go index 41f8195c..b311e75e 100644 --- a/internal/rpc/update_peer_refs.go +++ b/internal/rpc/update_peer_refs.go @@ -343,6 +343,10 @@ func collectMessagePeerRefs(msg domain.Message, currentChannelID int64, userIDs, if msg.ReplyTo != nil { addDomainPeerRef(msg.ReplyTo.Peer, currentChannelID, userIDs, channelIDs) collectMessageEntityUserRefs(msg.ReplyTo.QuoteEntities, userIDs) + if external := msg.ReplyTo.External; external != nil { + addDomainPeerRef(external.From.From, currentChannelID, userIDs, channelIDs) + collectMessagePeerRefs(domain.Message{Media: external.Media, Entities: external.Entities}, currentChannelID, userIDs, channelIDs) + } } if msg.Media != nil && msg.Media.Contact != nil && msg.Media.Contact.UserID != 0 { userIDs[msg.Media.Contact.UserID] = struct{}{} @@ -429,6 +433,10 @@ func collectChannelMessagePeerRefs(msg domain.ChannelMessage, currentChannelID i if msg.ReplyTo != nil { addDomainPeerRef(msg.ReplyTo.Peer, currentChannelID, userIDs, channelIDs) collectMessageEntityUserRefs(msg.ReplyTo.QuoteEntities, userIDs) + if external := msg.ReplyTo.External; external != nil { + addDomainPeerRef(external.From.From, currentChannelID, userIDs, channelIDs) + collectMessagePeerRefs(domain.Message{Media: external.Media, Entities: external.Entities}, currentChannelID, userIDs, channelIDs) + } } if msg.Media != nil && msg.Media.Contact != nil && msg.Media.Contact.UserID != 0 { userIDs[msg.Media.Contact.UserID] = struct{}{} diff --git a/internal/rpc/webview_registry.go b/internal/rpc/webview_registry.go index 3e7b3a3e..7f03f221 100644 --- a/internal/rpc/webview_registry.go +++ b/internal/rpc/webview_registry.go @@ -159,11 +159,6 @@ func clonePeerPtr(in *domain.Peer) *domain.Peer { return &out } -func cloneMessageReply(in *domain.MessageReply) *domain.MessageReply { - if in == nil { - return nil - } - out := *in - out.QuoteEntities = append([]domain.MessageEntity(nil), in.QuoteEntities...) - return &out +func cloneMessageReply(reply *domain.MessageReply) *domain.MessageReply { + return domain.CloneMessageReply(reply) } diff --git a/internal/rpcresult/replay.go b/internal/rpcresult/replay.go new file mode 100644 index 00000000..d48c236c --- /dev/null +++ b/internal/rpcresult/replay.go @@ -0,0 +1,72 @@ +package rpcresult + +import ( + "context" + "sync" + + "github.com/iamxvbaba/td/bin" +) + +// ReplaySource rebuilds the exact inner result bytes for one already-executed +// RPC from immutable data. Implementations must not rerun authorization, +// metadata lookup, or business handlers. +type ReplaySource interface { + EncodeInner(context.Context, *bin.Buffer) error + RetainedBytes() int +} + +// Carrier is implemented by a generated result wrapper when a fresh execution +// proved that its result can be replayed from immutable data. +type Carrier interface { + ExactReplaySource() ReplaySource +} + +// ValueSource is the RPC-side half of a replay source. It may use canonical +// RPC values internally; the edge sees only the encoded ReplaySource above. +type ValueSource interface { + Value(context.Context) (any, error) + RetainedBytes() int +} + +type captureKey struct{} + +type Capture struct { + mu sync.Mutex + source ValueSource +} + +func WithCapture(ctx context.Context) (context.Context, *Capture) { + capture := &Capture{} + return context.WithValue(ctx, captureKey{}, capture), capture +} + +// Publish records the one immutable source produced by the admitted terminal. +// Wrapper or nested handlers must not replace a source already published by +// the same one-shot dispatch. +func Publish(ctx context.Context, source ValueSource) bool { + if source == nil { + return false + } + capture, _ := ctx.Value(captureKey{}).(*Capture) + if capture == nil { + return false + } + capture.mu.Lock() + defer capture.mu.Unlock() + if capture.source != nil { + return false + } + capture.source = source + return true +} + +func (c *Capture) Take() ValueSource { + if c == nil { + return nil + } + c.mu.Lock() + source := c.source + c.source = nil + c.mu.Unlock() + return source +} diff --git a/internal/store/delivery_effect.go b/internal/store/delivery_effect.go new file mode 100644 index 00000000..93474d4f --- /dev/null +++ b/internal/store/delivery_effect.go @@ -0,0 +1,50 @@ +package store + +import ( + "errors" + "fmt" + + "telesrv/internal/domain" +) + +var ErrDeliveryOutboxRequired = errors.New("durable delivery boundary required") + +// DeliveryEffect is the account-scoped durable update intent built from an +// immutable aggregate snapshot. Main persists it into user_update_events and +// dispatch_outbox in the aggregate transaction. +type DeliveryEffect struct { + Kind DeliveryEffectKind + TargetUserID int64 + ExcludeAuthKeyID [8]byte + ExcludeSessionID int64 + Event domain.UpdateEvent +} + +type DeliveryEffectKind uint8 + +const ( + DeliveryEffectInvalid DeliveryEffectKind = iota + DeliveryEffectAccountPTS +) + +type DeliveryEffectsBuilder[T any] func(T) ([]DeliveryEffect, error) + +func AccountPTSDeliveryEffect(userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) DeliveryEffect { + return DeliveryEffect{ + Kind: DeliveryEffectAccountPTS, TargetUserID: userID, Event: event, + ExcludeAuthKeyID: excludeAuthKeyID, ExcludeSessionID: excludeSessionID, + } +} + +func (e DeliveryEffect) Validate() error { + if e.Kind != DeliveryEffectAccountPTS || e.TargetUserID <= 0 { + return fmt.Errorf("invalid account PTS delivery effect") + } + if (e.ExcludeAuthKeyID == ([8]byte{})) != (e.ExcludeSessionID == 0) { + return fmt.Errorf("delivery effect exclusion requires both raw auth key and session id") + } + if e.Event.PtsCount <= 0 || e.Event.Pts != 0 { + return fmt.Errorf("account PTS delivery effect requires unallocated positive pts_count") + } + return nil +} diff --git a/internal/store/memory/auth.go b/internal/store/memory/auth.go index c52fa777..f431135c 100644 --- a/internal/store/memory/auth.go +++ b/internal/store/memory/auth.go @@ -435,6 +435,9 @@ func mergeAuthorizationClientInfo(a *domain.Authorization, info domain.AuthKeyCl if info.AppVersion != "" { a.AppVersion = info.AppVersion } + if info.IP != "" { + a.IP = info.IP + } a.ActiveAt = time.Now() } diff --git a/internal/store/memory/bot_branding_test.go b/internal/store/memory/bot_branding_test.go new file mode 100644 index 00000000..1d9818fc --- /dev/null +++ b/internal/store/memory/bot_branding_test.go @@ -0,0 +1,43 @@ +package memory + +import ( + "context" + "testing" + + "telesrv/internal/branding" + "telesrv/internal/domain" +) + +func TestBuiltInBotSeedsUseConfiguredProductName(t *testing.T) { + previous := branding.Current() + t.Cleanup(func() { + if err := branding.Configure(previous); err != nil { + t.Fatalf("restore branding: %v", err) + } + }) + cfg := previous + cfg.ProductName = "Example Chat" + if err := branding.Configure(cfg); err != nil { + t.Fatalf("configure branding: %v", err) + } + + ctx := context.Background() + users := NewUserStore() + bots := NewBotStore(users) + for _, test := range []struct { + id int64 + want string + }{ + {id: domain.ChatBotUserID, want: domain.ChatBotDescription()}, + {id: domain.StickersBotUserID, want: domain.StickersBotDescription()}, + } { + user, found, err := users.ByID(ctx, test.id) + if err != nil || !found || user.About != test.want { + t.Fatalf("bot user %d = %+v found=%v err=%v, want about %q", test.id, user, found, err, test.want) + } + profile, found, err := bots.GetBot(ctx, test.id) + if err != nil || !found || profile.Description != test.want { + t.Fatalf("bot profile %d = %+v found=%v err=%v, want description %q", test.id, profile, found, err, test.want) + } + } +} diff --git a/internal/store/memory/media_pagination_test.go b/internal/store/memory/media_pagination_test.go new file mode 100644 index 00000000..34cccb3c --- /dev/null +++ b/internal/store/memory/media_pagination_test.go @@ -0,0 +1,93 @@ +package memory + +import ( + "context" + "reflect" + "telesrv/internal/domain" + "testing" +) + +func TestMediaPaginationBoundaries(t *testing.T) { + ctx := context.Background() + messages := NewMessageStore() + channels := NewChannelStore() + owner, other := int64(71), int64(72) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: owner, Title: "media boundaries", Megagroup: true, MemberUserIDs: []int64{other}, Date: 1700000000}) + if err != nil { + t.Fatal(err) + } + channel := created.Channel.ID + + var privateIDs, channelIDs []int + for i := 1; i <= 12; i++ { + var media *domain.MessageMedia + if i%2 == 1 { + media = &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &domain.Photo{ID: int64(i), AccessHash: 99}} + } + a, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: owner, RecipientUserID: other, RandomID: int64(i), Message: "media boundary", Media: media, Date: 1700000000 + i}) + if err != nil { + t.Fatal(err) + } + c, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{UserID: owner, ChannelID: channel, RandomID: int64(i), Message: "media boundary", Media: media, Date: 1700000000 + i}) + if err != nil { + t.Fatal(err) + } + if media != nil { + privateIDs = append([]int{a.SenderMessage.ID}, privateIDs...) + channelIDs = append([]int{c.Message.ID}, channelIDs...) + } + } + for _, side := range []string{"private", "channel"} { + ids := privateIDs + if side == "channel" { + ids = channelIDs + } + for _, tc := range []struct { + name string + f domain.MediaSearchRequest + want []int + count int + }{ + {"all", domain.MediaSearchRequest{Limit: 100}, ids, 6}, + {"strict-range", domain.MediaSearchRequest{Limit: 100, MinID: ids[4], MaxID: ids[1]}, ids[2:4], 2}, + {"strict-count", domain.MediaSearchRequest{Limit: 0, MinID: ids[4], MaxID: ids[1], OffsetID: ids[2], AddOffset: -2}, nil, 2}, + {"around-existing", domain.MediaSearchRequest{Limit: 4, OffsetID: ids[2], AddOffset: -2}, ids[1:5], 6}, + {"after-missing", domain.MediaSearchRequest{Limit: 2, OffsetID: ids[4] + 1, AddOffset: -2}, ids[2:4], 6}, + {"forward-gap", domain.MediaSearchRequest{Limit: 2, OffsetID: ids[4], AddOffset: -4}, ids[1:3], 6}, + {"empty-forward", domain.MediaSearchRequest{Limit: 2, OffsetID: ids[0] + 1, AddOffset: -2}, nil, 6}, + {"around-top", domain.MediaSearchRequest{Limit: 4, OffsetID: ids[0] + 1, AddOffset: -2}, ids[:2], 6}, + {"around-zero", domain.MediaSearchRequest{Limit: 4, AddOffset: -2}, ids[:2], 6}, + {"empty-backward", domain.MediaSearchRequest{Limit: 2, AddOffset: 100}, nil, 6}, + } { + t.Run(side+"/"+tc.name, func(t *testing.T) { + f := tc.f + f.Categories = []domain.MediaCategory{domain.MediaCategoryPhoto, domain.MediaCategoryPhoto} + f.Query = "boundary" + var got []int + var count int + if side == "private" { + r, err := messages.SearchPrivateMedia(ctx, owner, other, f) + if err != nil { + t.Fatal(err) + } + count = r.Count + for _, m := range r.Messages { + got = append(got, m.ID) + } + } else { + r, err := channels.SearchChannelMedia(ctx, owner, channel, f) + if err != nil { + t.Fatal(err) + } + count = r.Count + for _, m := range r.Messages { + got = append(got, m.ID) + } + } + if count != tc.count || !reflect.DeepEqual(append([]int{}, got...), append([]int{}, tc.want...)) { + t.Fatalf("ids=%v count=%d want %v/%d", got, count, tc.want, tc.count) + } + }) + } + } +} diff --git a/internal/store/memory/media_search.go b/internal/store/memory/media_search.go index 7bd2e589..11dc104d 100644 --- a/internal/store/memory/media_search.go +++ b/internal/store/memory/media_search.go @@ -67,30 +67,16 @@ func pageMediaIDs(ids []int, req domain.MediaSearchRequest) ([]int, int) { sort.Sort(sort.Reverse(sort.IntSlice(ids))) inRange := make([]int, 0, len(ids)) for _, id := range ids { - if req.MaxID != 0 && id > req.MaxID { + if req.MaxID != 0 && id >= req.MaxID { continue } - if req.MinID != 0 && id < req.MinID { + if req.MinID != 0 && id <= req.MinID { continue } inRange = append(inRange, id) } count := len(inRange) - page := make([]int, 0, len(inRange)) - for _, id := range inRange { - if req.OffsetID != 0 && id >= req.OffsetID { - continue - } - page = append(page, id) - } - off := req.AddOffset - if off < 0 { - off = 0 - } - if off > len(page) { - off = len(page) - } - page = page[off:] + limit := req.Limit if limit == 0 { return nil, count @@ -98,9 +84,15 @@ func pageMediaIDs(ids []int, req domain.MediaSearchRequest) ([]int, int) { if limit < 0 || limit > 100 { limit = 100 } - if len(page) > limit { - page = page[:limit] + pivot := 0 + if req.OffsetID > 0 { + pivot = sort.Search(len(inRange), func(i int) bool { return inRange[i] < req.OffsetID }) } + start := pivot + domain.ClampMessageHistoryAddOffset(req.AddOffset) + end := start + limit + start = min(max(start, 0), len(inRange)) + end = min(max(end, start), len(inRange)) + page := inRange[start:end] return page, count } diff --git a/internal/store/memory/message_helpers.go b/internal/store/memory/message_helpers.go index 4ecdfe5a..b627e8c2 100644 --- a/internal/store/memory/message_helpers.go +++ b/internal/store/memory/message_helpers.go @@ -186,12 +186,7 @@ func richMessagesEqual(a, b *domain.MessageRichMessage) bool { } func cloneMessageReply(reply *domain.MessageReply) *domain.MessageReply { - if reply == nil { - return nil - } - clone := *reply - clone.QuoteEntities = append([]domain.MessageEntity(nil), reply.QuoteEntities...) - return &clone + return domain.CloneMessageReply(reply) } func cloneMessageForward(forward *domain.MessageForward) *domain.MessageForward { diff --git a/internal/store/memory/message_history.go b/internal/store/memory/message_history.go index 1413799c..536d24c8 100644 --- a/internal/store/memory/message_history.go +++ b/internal/store/memory/message_history.go @@ -295,7 +295,7 @@ func (s *MessageStore) memoryHistoryClearAnchorLocked(userID int64, peer domain. func filterMessageList(messages []domain.Message, filter domain.MessageFilter) domain.MessageList { filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) sort.SliceStable(messages, func(i, j int) bool { - return messageLess(messages[i], messages[j]) + return messages[i].ID > messages[j].ID }) query := strings.ToLower(filter.Query) @@ -308,6 +308,9 @@ func filterMessageList(messages []domain.Message, filter domain.MessageFilter) d if filter.HasPeer && msg.Peer != filter.Peer { continue } + if filter.SenderUserID != 0 && msg.From != (domain.Peer{Type: domain.PeerTypeUser, ID: filter.SenderUserID}) { + continue + } if filter.RestrictPeerIDs { if msg.Peer.Type != domain.PeerTypeUser { continue @@ -346,6 +349,9 @@ func filterMessageList(messages []domain.Message, filter domain.MessageFilter) d base = append(base, msg) } + if filter.CountOnly { + return domain.MessageList{Count: len(base)} + } limit := filter.Limit if limit <= 0 { limit = 100 @@ -384,7 +390,7 @@ func pageMessageHistory(base []domain.Message, filter domain.MessageFilter, limi } switch messageHistoryLoadType(filter.AddOffset, limit) { case messageHistoryLoadForward: - return cloneMessages(forwardMessageHistory(base, filter, limit)) + return cloneMessages(forwardMessageHistory(base, filter, limit, -filter.AddOffset-limit)) case messageHistoryLoadAround: forwardLimit := -filter.AddOffset if forwardLimit > limit { @@ -395,10 +401,10 @@ func pageMessageHistory(base []domain.Message, filter domain.MessageFilter, limi backwardLimit = 0 } page := make([]domain.Message, 0, limit) - page = append(page, forwardMessageHistory(base, filter, forwardLimit)...) - page = append(page, backwardMessageHistory(base, filter, backwardLimit, true)...) + page = append(page, forwardMessageHistory(base, filter, forwardLimit, 0)...) + page = append(page, backwardMessageHistory(base, filter, backwardLimit)...) sort.SliceStable(page, func(i, j int) bool { - return messageLess(page[i], page[j]) + return page[i].ID > page[j].ID }) return cloneMessages(page) default: @@ -406,7 +412,7 @@ func pageMessageHistory(base []domain.Message, filter domain.MessageFilter, limi if start < 0 { start = 0 } - candidates := backwardMessageHistory(base, filter, limit+start, false) + candidates := backwardMessageHistory(base, filter, limit+start) if start >= len(candidates) { return nil } @@ -432,13 +438,13 @@ func messageHistoryLoadType(addOffset, limit int) messageHistoryLoad { return messageHistoryLoadForward } -func backwardMessageHistory(base []domain.Message, filter domain.MessageFilter, limit int, includeOffset bool) []domain.Message { +func backwardMessageHistory(base []domain.Message, filter domain.MessageFilter, limit int) []domain.Message { if limit <= 0 { return nil } out := make([]domain.Message, 0, limit) for _, msg := range base { - if !messageBeforeHistoryOffset(msg, filter, includeOffset) { + if !messageBeforeHistoryOffset(msg, filter) { continue } out = append(out, msg) @@ -449,7 +455,7 @@ func backwardMessageHistory(base []domain.Message, filter domain.MessageFilter, return out } -func forwardMessageHistory(base []domain.Message, filter domain.MessageFilter, limit int) []domain.Message { +func forwardMessageHistory(base []domain.Message, filter domain.MessageFilter, limit, skip int) []domain.Message { if limit <= 0 { return nil } @@ -459,30 +465,28 @@ func forwardMessageHistory(base []domain.Message, filter domain.MessageFilter, l if !messageAfterHistoryOffset(msg, filter) { continue } + if skip > 0 { + skip-- + continue + } out = append(out, msg) if len(out) == limit { break } } sort.SliceStable(out, func(i, j int) bool { - return messageLess(out[i], out[j]) + return out[i].ID > out[j].ID }) return out } -func messageBeforeHistoryOffset(msg domain.Message, filter domain.MessageFilter, includeOffset bool) bool { +func messageBeforeHistoryOffset(msg domain.Message, filter domain.MessageFilter) bool { if filter.OffsetDate > 0 { - if includeOffset { - return msg.Date <= filter.OffsetDate - } return msg.Date < filter.OffsetDate } if filter.OffsetID <= 0 { return true } - if includeOffset { - return msg.ID <= filter.OffsetID - } return msg.ID < filter.OffsetID } @@ -493,7 +497,7 @@ func messageAfterHistoryOffset(msg domain.Message, filter domain.MessageFilter) if filter.OffsetID <= 0 { return false } - return msg.ID > filter.OffsetID + return msg.ID >= filter.OffsetID } func messageLess(a, b domain.Message) bool { diff --git a/internal/store/memory/message_search_test.go b/internal/store/memory/message_search_test.go new file mode 100644 index 00000000..669cdba2 --- /dev/null +++ b/internal/store/memory/message_search_test.go @@ -0,0 +1,51 @@ +package memory + +import ( + "reflect" + "telesrv/internal/domain" + "testing" +) + +func TestMessageSearchSenderAndCountFilters(t *testing.T) { + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 22} + var messages []domain.Message + for i := 1; i <= 6; i++ { + sender := int64(11) + if i%2 == 0 { + sender = 22 + } + messages = append(messages, domain.Message{ID: i, OwnerUserID: 11, Peer: peer, From: domain.Peer{Type: domain.PeerTypeUser, ID: sender}, Body: "count needle", Date: 100 + i, Pinned: i == 3}) + } + for _, tt := range []struct { + name string + f domain.MessageFilter + ids []int + count int + }{ + {"sender", domain.MessageFilter{SenderUserID: 11, Limit: 10}, []int{5, 3, 1}, 3}, + {"sender-around", domain.MessageFilter{SenderUserID: 11, OffsetID: 3, AddOffset: -1, Limit: 3}, []int{3, 1}, 3}, + {"count-ignores-page", domain.MessageFilter{SenderUserID: 11, CountOnly: true, OffsetID: 1, AddOffset: 100}, nil, 3}, + {"count-intersection", domain.MessageFilter{SenderUserID: 11, CountOnly: true, MinDate: 101, MaxDate: 106, MinID: 1, MaxID: 5}, nil, 1}, + {"count-pinned", domain.MessageFilter{SenderUserID: 11, CountOnly: true, PinnedOnly: true}, nil, 1}, + {"count-pinned-other", domain.MessageFilter{SenderUserID: 22, CountOnly: true, PinnedOnly: true}, nil, 0}, + {"count-peer-isolation", domain.MessageFilter{CountOnly: true, RestrictPeerIDs: true, PeerIDs: []int64{33}}, nil, 0}, + } { + t.Run(tt.name, func(t *testing.T) { + f := tt.f + f.HasPeer = true + f.Peer = peer + f.Query = "needle" + got := filterMessageList(cloneMessages(messages), f) + var ids []int + for _, m := range got.Messages { + ids = append(ids, m.ID) + } + if !reflect.DeepEqual(ids, tt.ids) || got.Count != tt.count { + t.Fatalf("ids=%v count=%d want %v/%d", ids, got.Count, tt.ids, tt.count) + } + if f.CountOnly && len(got.Users) > 0 { + t.Fatal("count projected users") + } + }) + } +} diff --git a/internal/store/memory/message_send.go b/internal/store/memory/message_send.go index 4db7baac..8df04653 100644 --- a/internal/store/memory/message_send.go +++ b/internal/store/memory/message_send.go @@ -52,8 +52,26 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate if err != nil { return domain.SendPrivateTextResult{}, err } + // Match TogglePrivateNoForwards lock order; only external private-source + // replies read pair protection while holding the message mutation lock. + if r := req.ReplyTo; r != nil && r.Peer.Type == domain.PeerTypeUser && r.Peer.ID > 0 && r.Peer.ID != req.RecipientUserID { + s.noForwardsMu.Lock() + defer s.noForwardsMu.Unlock() + } s.mu.Lock() defer s.mu.Unlock() + return s.sendPrivateTextLocked(req, fingerprint, nil) +} + +// A prefix stages related state events before the message. apply is infallible +// and only runs after all validation and receipt encoding have succeeded. +type memoryPrivateSendPrefix struct { + events []domain.UpdateEvent + apply func([]domain.UpdateEvent) +} + +// Caller holds s.mu; this helper owns the dialog/event locks through commit. +func (s *MessageStore) sendPrivateTextLocked(req domain.SendPrivateTextRequest, fingerprint []byte, prefix *memoryPrivateSendPrefix) (domain.SendPrivateTextResult, error) { if replay, found, err := s.lookupPrivateSendReplayLocked(domain.PrivateSendReplayRequest{ SenderUserID: req.SenderUserID, RecipientUserID: req.RecipientUserID, @@ -69,10 +87,37 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate if err != nil { return domain.SendPrivateTextResult{}, err } + events := s.updateEvents + if events == nil { + return domain.SendPrivateTextResult{}, store.ErrDeliveryOutboxRequired + } + events.mu.Lock() + defer events.mu.Unlock() + // Stage counters as well as the message: failed encoding cannot consume PTS. + allocatedPTS := make(map[int64]int) + nextPTS := func(userID int64) int { + current, ok := allocatedPTS[userID] + if !ok { + current = s.nextPts[userID] + for _, event := range events.events[userID] { + if event.Pts > current { + current = event.Pts + } + } + } + allocatedPTS[userID] = current + 1 + return current + 1 + } + var prefixEvents []domain.UpdateEvent + if prefix != nil { + for _, event := range prefix.events { + event.Pts = nextPTS(event.UserID) + prefixEvents = append(prefixEvents, event) + } + } uid := s.nextUID - s.nextUID++ sender := domain.Message{ - ID: s.nextBoxIDLocked(req.SenderUserID), + ID: s.nextBox[req.SenderUserID] + 1, UID: uid, RandomID: req.RandomID, OwnerUserID: req.SenderUserID, @@ -92,7 +137,7 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate RichMessage: cloneRichMessage(req.RichMessage), ReplyTo: cloneMessageReply(senderReply), Forward: cloneMessageForward(req.Forward), - Pts: s.nextPtsLocked(req.SenderUserID), + Pts: nextPTS(req.SenderUserID), // voice/round 在发送者副本上同样保持"未听",由对端内容已读清除。 MediaUnread: req.Media.HasUnreadPayload() && req.SenderUserID != req.RecipientUserID, } @@ -105,7 +150,7 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate } if req.SenderUserID != req.RecipientUserID && !req.RecipientBlocked { recipient = sender - recipient.ID = s.nextBoxIDLocked(req.RecipientUserID) + recipient.ID = s.nextBox[req.RecipientUserID] + 1 recipient.OwnerUserID = req.RecipientUserID recipient.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID} recipient.Out = false @@ -115,7 +160,7 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate // 让双盒各持独立快照(与 postgres 每盒独立 decode 对齐,I3/I2)。 recipient.ReplyMarkup = cloneReplyMarkup(sender.ReplyMarkup) recipient.RichMessage = cloneRichMessage(sender.RichMessage) - recipient.Pts = s.nextPtsLocked(req.RecipientUserID) + recipient.Pts = nextPTS(req.RecipientUserID) recipient.MediaUnread = req.Media.HasUnreadPayload() } var senderSnapshot []byte @@ -125,6 +170,25 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate return domain.SendPrivateTextResult{}, err } } + // No fallible work follows: publish the prefix and message under all locks. + if prefix != nil { + prefix.apply(prefixEvents) + } + for _, event := range prefixEvents { + auth, session := [8]byte{}, int64(0) + if event.UserID == req.SenderUserID { + auth, session = req.OriginAuthKeyID, req.OriginSessionID + } + appendMemorySendEventLocked(events, event.UserID, event, auth, session) + } + s.nextUID++ + s.nextBox[req.SenderUserID] = sender.ID + if recipient.ID != 0 { + s.nextBox[recipient.OwnerUserID] = recipient.ID + } + for userID, pts := range allocatedPTS { + s.nextPts[userID] = pts + } s.m[req.SenderUserID] = append(s.m[req.SenderUserID], sender) if req.SenderUserID != req.RecipientUserID && !req.RecipientBlocked { s.m[req.RecipientUserID] = append(s.m[req.RecipientUserID], recipient) @@ -144,6 +208,22 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate s.upsertMemoryDialogsLocked(sender, sender) } } + originUserID := req.OriginUserID + if originUserID == 0 { + originUserID = req.SenderUserID + } + senderExcludeAuthKeyID, senderExcludeSessionID := [8]byte{}, int64(0) + if originUserID == req.SenderUserID { + senderExcludeAuthKeyID, senderExcludeSessionID = req.OriginAuthKeyID, req.OriginSessionID + } + appendMemorySendEventLocked(events, req.SenderUserID, newMessageEvent(sender), senderExcludeAuthKeyID, senderExcludeSessionID) + if recipient.ID != 0 && recipient.OwnerUserID != sender.OwnerUserID { + recipientExcludeAuthKeyID, recipientExcludeSessionID := [8]byte{}, int64(0) + if originUserID == req.RecipientUserID { + recipientExcludeAuthKeyID, recipientExcludeSessionID = req.OriginAuthKeyID, req.OriginSessionID + } + appendMemorySendEventLocked(events, req.RecipientUserID, newMessageEvent(recipient), recipientExcludeAuthKeyID, recipientExcludeSessionID) + } return domain.SendPrivateTextResult{ SenderMessage: cloneMessage(sender), RecipientMessage: cloneMessage(recipient), @@ -152,6 +232,25 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate }, nil } +func (s *MessageStore) nextPtsWithEventsLocked(events *UpdateEventStore, userID int64) int { + current := s.nextPts[userID] + for _, event := range events.events[userID] { + if event.Pts > current { + current = event.Pts + } + } + current++ + s.nextPts[userID] = current + return current +} + +func appendMemorySendEventLocked(events *UpdateEventStore, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) { + event = events.appendLocked(userID, event, false) + events.dispatches[userID] = append(events.dispatches[userID], memoryUpdateDispatch{ + Pts: event.Pts, ExcludeAuthKeyID: excludeAuthKeyID, ExcludeSessionID: excludeSessionID, + }) +} + // LookupPrivateSendReplay returns an existing immutable/current replay receipt without running // any send permission, reply resolution or allocation path. func (s *MessageStore) LookupPrivateSendReplay(_ context.Context, req domain.PrivateSendReplayRequest) (domain.SendPrivateTextResult, bool, error) { @@ -246,7 +345,7 @@ func (s *MessageStore) resolveMemoryReplyLocked(req domain.SendPrivateTextReques if peer.ID == 0 { peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID} } - if peer.Type != domain.PeerTypeUser || peer.ID != req.RecipientUserID { + if peer.Type != domain.PeerTypeUser || req.ReplyTo.External != nil { return nil, nil, domain.ErrReplyMessageIDInvalid } var target domain.Message @@ -262,6 +361,29 @@ func (s *MessageStore) resolveMemoryReplyLocked(req domain.SendPrivateTextReques senderReply := cloneMessageReply(req.ReplyTo) senderReply.MessageID = target.ID senderReply.Peer = peer + if peer.ID != req.RecipientUserID { + protected := target.NoForwards || (target.Media != nil && target.Media.TTLSeconds > 0) + if pair, ok := noForwardsPair(req.SenderUserID, peer.ID); ok { + protected = protected || s.privateNoForwards[pair].Enabled() + } + if protected { + return nil, nil, domain.ErrChatForwardsRestricted + } + if err := domain.ValidateExternalReplyQuote(req.ReplyTo, target.Body); err != nil { + return nil, nil, err + } + var err error + senderReply.External, err = domain.NewMessageReplyExternal(target) + if err != nil { + return nil, nil, err + } + recipientReply := cloneMessageReply(senderReply) + if req.SenderUserID != req.RecipientUserID { + recipientReply.MessageID = 0 + recipientReply.TopMessageID = 0 + } + return senderReply, recipientReply, nil + } if req.SenderUserID == req.RecipientUserID { return senderReply, cloneMessageReply(senderReply), nil } diff --git a/internal/store/memory/message_store.go b/internal/store/memory/message_store.go index 830b56cc..dffed249 100644 --- a/internal/store/memory/message_store.go +++ b/internal/store/memory/message_store.go @@ -21,6 +21,7 @@ type MessageStore struct { loginCodeDeliveries map[[32]byte]loginCodeDeliveryRecord albumGroups map[albumGroupKey]albumGroupRecord dialogs *DialogStore + updateEvents *UpdateEventStore // polls 是共享 poll 权威(投票校验与读路径 enrichment);nil 时 poll 链路按未接入处理。 polls *PollStore // savedPins 是收藏夹子会话置顶顺序(下标即 pinned_order,越小越前)。 @@ -37,6 +38,10 @@ func (s *MessageStore) AttachPollStore(polls *PollStore) { s.polls = polls } +func (s *MessageStore) AttachUpdateEventStore(events *UpdateEventStore) { + s.updateEvents = events +} + type readOutboxDateKey struct { ownerUserID int64 peerID int64 @@ -60,6 +65,7 @@ func NewMessageStore(dialogs ...*DialogStore) *MessageStore { savedPins: make(map[int64][]domain.Peer), privateNoForwards: make(map[privateNoForwardsPair]domain.PrivateNoForwardsState), privateNoForwardsRequests: make(map[int64]memoryNoForwardsRequest), + updateEvents: NewUpdateEventStore(), } if len(dialogs) > 0 { s.dialogs = dialogs[0] diff --git a/internal/store/memory/message_test.go b/internal/store/memory/message_test.go index c6e43e79..bc21e11f 100644 --- a/internal/store/memory/message_test.go +++ b/internal/store/memory/message_test.go @@ -569,7 +569,7 @@ func TestMessageStoreListByUserSupportsForwardAndAroundHistoryOffsets(t *testing if err != nil { t.Fatalf("around history: %v", err) } - if got := messageIDs(around.Messages); !sameInts(got, []int{6, 5, 4, 3, 2, 1}) { + if got := messageIDs(around.Messages); !sameInts(got, []int{5, 4, 3, 2, 1}) { t.Fatalf("around ids = %v, want unread/newer side plus older context", got) } @@ -583,8 +583,8 @@ func TestMessageStoreListByUserSupportsForwardAndAroundHistoryOffsets(t *testing if err != nil { t.Fatalf("forward history: %v", err) } - if got := messageIDs(forward.Messages); !sameInts(got, []int{6, 5, 4}) { - t.Fatalf("forward ids = %v, want messages newer than offset", got) + if got := messageIDs(forward.Messages); !sameInts(got, []int{5, 4, 3}) { + t.Fatalf("forward ids = %v, want forward window including offset anchor", got) } hugePositive, err := messages.ListByUser(ctx, bobID, domain.MessageFilter{ @@ -610,8 +610,8 @@ func TestMessageStoreListByUserSupportsForwardAndAroundHistoryOffsets(t *testing if err != nil { t.Fatalf("huge negative add_offset history: %v", err) } - if got := messageIDs(hugeNegative.Messages); !sameInts(got, []int{6, 5, 4}) { - t.Fatalf("huge negative add_offset ids = %v, want clamped forward page", got) + if got := messageIDs(hugeNegative.Messages); !sameInts(got, nil) { + t.Fatalf("huge negative add_offset ids = %v, want empty page after clamped forward gap", got) } } diff --git a/internal/store/memory/updates.go b/internal/store/memory/updates.go index 7124744a..62f829f3 100644 --- a/internal/store/memory/updates.go +++ b/internal/store/memory/updates.go @@ -16,8 +16,15 @@ type UpdateStateStore struct { // UpdateEventStore 是 store.UpdateEventStore 的内存实现。 type UpdateEventStore struct { - mu sync.RWMutex - events map[int64][]domain.UpdateEvent + mu sync.RWMutex + events map[int64][]domain.UpdateEvent + dispatches map[int64][]memoryUpdateDispatch +} + +type memoryUpdateDispatch struct { + Pts int + ExcludeAuthKeyID [8]byte + ExcludeSessionID int64 } type updateStateKey struct { @@ -27,29 +34,46 @@ type updateStateKey struct { // NewUpdateEventStore 创建内存 UpdateEventStore。 func NewUpdateEventStore() *UpdateEventStore { - return &UpdateEventStore{events: make(map[int64][]domain.UpdateEvent)} + return &UpdateEventStore{ + events: make(map[int64][]domain.UpdateEvent), + dispatches: make(map[int64][]memoryUpdateDispatch), + } } func (s *UpdateEventStore) Append(_ context.Context, userID int64, event domain.UpdateEvent) error { - _, err := s.append(userID, event, false) + _, err := s.append(userID, event, false, false, [8]byte{}, 0) return err } func (s *UpdateEventStore) AppendAllocated(_ context.Context, userID int64, event domain.UpdateEvent) (domain.UpdateEvent, error) { - return s.append(userID, event, true) + return s.append(userID, event, true, false, [8]byte{}, 0) } -func (s *UpdateEventStore) AppendAllocatedWithDispatch(_ context.Context, userID int64, event domain.UpdateEvent, _ [8]byte, _ int64) (domain.UpdateEvent, error) { - return s.append(userID, event, true) +func (s *UpdateEventStore) AppendAllocatedWithDispatch(_ context.Context, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, error) { + return s.append(userID, event, true, true, excludeAuthKeyID, excludeSessionID) } -func (s *UpdateEventStore) append(userID int64, event domain.UpdateEvent, allocate bool) (domain.UpdateEvent, error) { +func (s *UpdateEventStore) append(userID int64, event domain.UpdateEvent, allocate, withDispatch bool, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, error) { + if withDispatch && (excludeAuthKeyID == ([8]byte{})) != (excludeSessionID == 0) { + return domain.UpdateEvent{}, fmt.Errorf("update dispatch exclusion requires both raw auth key and session id") + } + s.mu.Lock() + defer s.mu.Unlock() + event = s.appendLocked(userID, event, allocate) + if withDispatch { + s.dispatches[userID] = append(s.dispatches[userID], memoryUpdateDispatch{ + Pts: event.Pts, ExcludeAuthKeyID: excludeAuthKeyID, ExcludeSessionID: excludeSessionID, + }) + } + return event, nil +} + +func (s *UpdateEventStore) appendLocked(userID int64, event domain.UpdateEvent, allocate bool) domain.UpdateEvent { if event.PtsCount <= 0 { event.PtsCount = 1 } event.UserID = userID event = cloneUpdateEvent(event) - s.mu.Lock() if allocate { current := 0 for _, item := range s.events[userID] { @@ -60,8 +84,7 @@ func (s *UpdateEventStore) append(userID int64, event domain.UpdateEvent, alloca event.Pts = current + event.PtsCount } s.events[userID] = append(s.events[userID], event) - s.mu.Unlock() - return event, nil + return event } func (s *UpdateEventStore) ListAfter(_ context.Context, userID int64, pts, limit int) ([]domain.UpdateEvent, error) { diff --git a/internal/store/message_pin.go b/internal/store/message_pin.go new file mode 100644 index 00000000..07961c4e --- /dev/null +++ b/internal/store/message_pin.go @@ -0,0 +1,31 @@ +package store + +import ( + "crypto/rand" + "encoding/binary" + "fmt" + + "telesrv/internal/domain" +) + +// PrivatePinServiceRequest prepares the service receipt for a shared pin. +// Pin state, checked under the store's pair lock, is the command's idempotency +// boundary. A new receipt key allows unpin/repin even at the same timestamp. +func PrivatePinServiceRequest(req domain.PinPrivateMessageRequest) (domain.SendPrivateTextRequest, error) { + var key [8]byte + var id int64 + for id == 0 { + if _, err := rand.Read(key[:]); err != nil { + return domain.SendPrivateTextRequest{}, fmt.Errorf("pin service receipt key: %w", err) + } + id = int64(binary.LittleEndian.Uint64(key[:])) + } + return domain.SendPrivateTextRequest{ + SenderUserID: req.OwnerUserID, RecipientUserID: req.Peer.ID, RandomID: id, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, + ServiceAction: &domain.MessageServiceAction{Kind: domain.MessageServiceActionPinMessage}}, + ReplyTo: &domain.MessageReply{MessageID: req.MessageID, Peer: req.Peer}, + Silent: req.Silent, Date: req.Date, RecipientBlocked: req.RecipientBlocked, + OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, + }, nil +} diff --git a/internal/store/postgres/active_channel_ids_batch.go b/internal/store/postgres/active_channel_ids_batch.go index d7682534..f25ae5e9 100644 --- a/internal/store/postgres/active_channel_ids_batch.go +++ b/internal/store/postgres/active_channel_ids_batch.go @@ -95,6 +95,9 @@ func newActiveChannelIDsPageBatcher( queue: make(chan activeChannelIDsBatchRequest, cfg.QueueSize), stop: make(chan struct{}), done: make(chan struct{}), cancel: cancel, } + if cfg.Metrics != nil { + cfg.Metrics.ActiveChannelIDsPending(0) + } go b.run(workerCtx) return b, nil } diff --git a/internal/store/postgres/active_channel_ids_batch_test.go b/internal/store/postgres/active_channel_ids_batch_test.go index 0016e743..f041ccb6 100644 --- a/internal/store/postgres/active_channel_ids_batch_test.go +++ b/internal/store/postgres/active_channel_ids_batch_test.go @@ -3,13 +3,34 @@ package postgres import ( "context" "errors" + "net/http/httptest" "slices" + "strings" "sync" "sync/atomic" "testing" "time" + + obsmetrics "telesrv/internal/observability/metrics" ) +func TestActiveChannelIDsPageBatcherExportsIdlePending(t *testing.T) { + registry := obsmetrics.New() + backend := &fakeActiveChannelIDsBatchBackend{} + batcher, err := newActiveChannelIDsPageBatcher(backend, ActiveChannelIDsBatchConfig{ + MaxSize: 1, MaxWait: time.Millisecond, QueueSize: 1, QueryTimeout: time.Second, Metrics: registry, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(batcher.Close) + recorder := httptest.NewRecorder() + registry.ServeHTTP(recorder, httptest.NewRequest("GET", "/metrics", nil)) + if !strings.Contains(recorder.Body.String(), "telesrv_active_channel_ids_pending 0\n") || backend.calls.Load() != 0 { + t.Fatalf("idle owner must expose zero pending without loading a page: %s", recorder.Body.String()) + } +} + func TestSelectDistinctActiveChannelIDsBatchDefersDuplicate(t *testing.T) { selector := activeChannelIDsSelector{userID: 1, limit: 1000} first := activeChannelIDsBatchRequest{selector: selector} diff --git a/internal/store/postgres/authorization.go b/internal/store/postgres/authorization.go index 9f1e94ed..9d8ee9a8 100644 --- a/internal/store/postgres/authorization.go +++ b/internal/store/postgres/authorization.go @@ -215,10 +215,11 @@ UPDATE authorizations SET system_version = CASE WHEN $5 <> '' THEN $5 ELSE system_version END, api_id = CASE WHEN $6 <> 0 THEN $6 ELSE api_id END, app_version = CASE WHEN $7 <> '' THEN $7 ELSE app_version END, + ip = CASE WHEN $8 <> '' THEN $8 ELSE ip END, active_at = now() WHERE auth_key_id = $1`, authKeyIDToInt64(id), int32(info.Layer), info.DeviceModel, info.Platform, - info.SystemVersion, int32(info.APIID), info.AppVersion, + info.SystemVersion, int32(info.APIID), info.AppVersion, info.IP, ); err != nil { return fmt.Errorf("update authorization client info: %w", err) } diff --git a/internal/store/postgres/blob_storage_lock.go b/internal/store/postgres/blob_storage_lock.go index 04ac9db6..dc21b41f 100644 --- a/internal/store/postgres/blob_storage_lock.go +++ b/internal/store/postgres/blob_storage_lock.go @@ -27,7 +27,7 @@ func AcquireBlobMigrationLock(ctx context.Context, dsn string) (*BlobStorageLock } func acquireBlobStorageLock(ctx context.Context, dsn string, shared bool) (*BlobStorageLock, error) { - conn, err := pgx.Connect(ctx, dsn) + conn, err := connectPostgresAdmitted(ctx, dsn) if err != nil { return nil, fmt.Errorf("connect for blob storage lock: %w", err) } diff --git a/internal/store/postgres/bootstrap_update_job_batch.go b/internal/store/postgres/bootstrap_update_job_batch.go index 8d83389c..6dc4ec94 100644 --- a/internal/store/postgres/bootstrap_update_job_batch.go +++ b/internal/store/postgres/bootstrap_update_job_batch.go @@ -99,6 +99,9 @@ func newBatchedBootstrapUpdateJobStore( queue: make(chan bootstrapReadyBatchRequest, cfg.QueueSize), stop: make(chan struct{}), done: make(chan struct{}), cancel: cancel, } + if cfg.Metrics != nil { + cfg.Metrics.BootstrapReadyPending(0) + } go s.run(workerCtx) return s, nil } diff --git a/internal/store/postgres/bootstrap_update_job_batch_test.go b/internal/store/postgres/bootstrap_update_job_batch_test.go index 2fa36917..2a226960 100644 --- a/internal/store/postgres/bootstrap_update_job_batch_test.go +++ b/internal/store/postgres/bootstrap_update_job_batch_test.go @@ -3,14 +3,34 @@ package postgres import ( "context" "errors" + "net/http/httptest" + "strings" "sync" "sync/atomic" "testing" "time" "telesrv/internal/domain" + obsmetrics "telesrv/internal/observability/metrics" ) +func TestBatchedBootstrapUpdateJobStoreExportsIdlePending(t *testing.T) { + registry := obsmetrics.New() + backend := &fakeBootstrapReadyBackend{} + batcher, err := newBatchedBootstrapUpdateJobStore(backend, BootstrapReadyBatchConfig{ + MaxSize: 1, MaxWait: time.Millisecond, QueueSize: 1, QueryTimeout: time.Second, Metrics: registry, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(batcher.Close) + recorder := httptest.NewRecorder() + registry.ServeHTTP(recorder, httptest.NewRequest("GET", "/metrics", nil)) + if !strings.Contains(recorder.Body.String(), "telesrv_bootstrap_ready_pending 0\n") || backend.calls.Load() != 0 { + t.Fatalf("idle owner must expose zero pending without a readiness query: %s", recorder.Body.String()) + } +} + func TestSelectDistinctBootstrapReadyBatchDefersSameFence(t *testing.T) { first := bootstrapReadyBatchRequest{userID: 1, authKeyID: [8]byte{1}, sessionID: 10} duplicate := bootstrapReadyBatchRequest{userID: 1, authKeyID: [8]byte{1}, sessionID: 11} diff --git a/internal/store/postgres/channel_difference_cursor_integration_test.go b/internal/store/postgres/channel_difference_cursor_integration_test.go new file mode 100644 index 00000000..4c34bfe8 --- /dev/null +++ b/internal/store/postgres/channel_difference_cursor_integration_test.go @@ -0,0 +1,90 @@ +package postgres + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" +) + +func TestChannelDifferenceValidCursorBeforeRowCacheInvalidationPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + owner, err := NewUserStore(pool).Create(ctx, domain.User{ + AccessHash: 731, Phone: "+1994" + randomSuffix(t) + "01", FirstName: "CursorOwner", + }) + if err != nil { + t.Fatal(err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + cache := NewChannelRowCache(16) + channels := NewChannelStore(pool, WithChannelRowCache(cache)) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, Title: "Cursor notification window", Megagroup: true, Date: 1701000300, + }) + if err != nil { + t.Fatal(err) + } + channelID = created.Channel.ID + // Populate the real read-through cache, then commit a real send. Keeping + // the listener paused models the interval between commit/push and NOTIFY + // consumption; no persisted cursor or event is fabricated. + if _, _, _, err := channels.getChannelForViewer(ctx, pool, owner.ID, channelID); err != nil { + t.Fatal(err) + } + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, ChannelID: channelID, RandomID: 1701000301, Message: "committed cursor", Date: 1701000301, + }) + if err != nil { + t.Fatal(err) + } + if cached, ok := cache.get(channelID); !ok || cached.Pts != created.Channel.Pts { + t.Fatalf("expected pre-notification cached pts %d, got %d (cached=%v)", created.Channel.Pts, cached.Pts, ok) + } + for _, pts := range []int{-1, sent.Event.Pts + 1} { + if _, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ + UserID: owner.ID, ChannelID: channelID, Pts: pts, Limit: 100, + }); !errors.Is(err, domain.ErrPersistentTimestamp) { + t.Fatalf("invalid cursor %d error = %v, want ErrPersistentTimestamp", pts, err) + } + } + diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ + UserID: owner.ID, ChannelID: channelID, Pts: sent.Event.Pts, Limit: 100, + }) + if err != nil { + t.Fatalf("valid committed cursor rejected before cache invalidation: %v", err) + } + if !diff.Final || diff.Pts != sent.Event.Pts || len(diff.Events) != 0 || diff.TooLong { + t.Fatalf("current committed cursor difference = %+v", diff) + } + // A retry must still load the durable tail, not turn every now-valid + // cursor into an empty response at the latest PTS. + second, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, ChannelID: channelID, RandomID: 1701000302, Message: "second cursor", Date: 1701000302, + }) + if err != nil { + t.Fatal(err) + } + third, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, ChannelID: channelID, RandomID: 1701000303, Message: "durable tail", Date: 1701000303, + }) + if err != nil { + t.Fatal(err) + } + diff, err = channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ + UserID: owner.ID, ChannelID: channelID, Pts: second.Event.Pts, Limit: 100, + }) + if err != nil { + t.Fatal(err) + } + if !diff.Final || diff.Pts != third.Event.Pts || len(diff.NewMessages) != 1 || diff.NewMessages[0].ID != third.Message.ID { + t.Fatalf("committed cursor retry lost durable tail: %+v", diff) + } +} diff --git a/internal/store/postgres/channel_difference_integration_test.go b/internal/store/postgres/channel_difference_integration_test.go index 8a0d8577..512e9f16 100644 --- a/internal/store/postgres/channel_difference_integration_test.go +++ b/internal/store/postgres/channel_difference_integration_test.go @@ -436,7 +436,7 @@ func TestChannelStoreSendFailureBeforePtsAllocationDoesNotRecordNoopGap(t *testi if err := pool.QueryRow(ctx, ` SELECT count(*)::int FROM channel_update_events -WHERE channel_id = $1 AND pts = 2`, channelID).Scan(&gapRows); err != nil { +WHERE channel_id = $1 AND pts > $2`, channelID, created.Channel.Pts).Scan(&gapRows); err != nil { t.Fatalf("count events after failed send: %v", err) } if gapRows != 0 { @@ -453,20 +453,20 @@ WHERE channel_id = $1 AND pts = 2`, channelID).Scan(&gapRows); err != nil { if err != nil { t.Fatalf("send owner after gap: %v", err) } - if sent.Event.Pts != 2 { - t.Fatalf("next channel pts = %d, want 2 after failed send before pts allocation", sent.Event.Pts) + if sent.Event.Pts != created.Channel.Pts+1 { + t.Fatalf("next channel pts = %d, want %d after failed send before pts allocation", sent.Event.Pts, created.Channel.Pts+1) } diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ UserID: owner.ID, ChannelID: channelID, - Pts: 1, + Pts: created.Channel.Pts, Limit: 10, }) if err != nil { t.Fatalf("list channel difference: %v", err) } - if diff.Pts != 2 || len(diff.Events) != 1 || diff.Events[0].Type != domain.ChannelUpdateNewMessage || diff.Events[0].Pts != 2 { - t.Fatalf("diff after failed send = %+v, want only message pts=2", diff) + if diff.Pts != sent.Event.Pts || len(diff.Events) != 1 || diff.Events[0].Type != domain.ChannelUpdateNewMessage || diff.Events[0].Pts != sent.Event.Pts { + t.Fatalf("diff after failed send = %+v, want only message pts=%d", diff, sent.Event.Pts) } } diff --git a/internal/store/postgres/channel_listener.go b/internal/store/postgres/channel_listener.go index da34868b..447aa685 100644 --- a/internal/store/postgres/channel_listener.go +++ b/internal/store/postgres/channel_listener.go @@ -6,7 +6,6 @@ import ( "strings" "time" - "github.com/jackc/pgx/v5" "go.uber.org/zap" ) @@ -66,7 +65,7 @@ func (l *ChannelChangeListener) Run(ctx context.Context) { // listenAndConsume 建立一条连接、LISTEN、flush,然后消费通知直到出错或 ctx 取消。 // 成功消费到通知前不重置退避,由 Run 控制重连节奏。 func (l *ChannelChangeListener) listenAndConsume(ctx context.Context) error { - conn, err := pgx.Connect(ctx, l.dsn) + conn, err := connectPostgresAdmitted(ctx, l.dsn) if err != nil { return err } diff --git a/internal/store/postgres/delivery_effect.go b/internal/store/postgres/delivery_effect.go new file mode 100644 index 00000000..5542ee09 --- /dev/null +++ b/internal/store/postgres/delivery_effect.go @@ -0,0 +1,28 @@ +package postgres + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/store" + "telesrv/internal/store/postgres/sqlcgen" +) + +func applyDeliveryEffectsTx(ctx context.Context, tx pgx.Tx, effects []store.DeliveryEffect) ([]store.DeliveryEffect, error) { + qtx := sqlcgen.New(tx) + events := NewUpdateEventStore(tx) + for i := range effects { + effect := &effects[i] + if err := effect.Validate(); err != nil { + return nil, fmt.Errorf("delivery effect %d: %w", i, err) + } + event, err := events.appendInTx(ctx, tx, qtx, effect.TargetUserID, effect.Event, true, effect.ExcludeAuthKeyID, effect.ExcludeSessionID, true) + if err != nil { + return nil, fmt.Errorf("apply account PTS delivery effect %d: %w", i, err) + } + effect.Event = event + } + return effects, nil +} diff --git a/internal/store/postgres/dialog.go b/internal/store/postgres/dialog.go index f62226b3..4f03c022 100644 --- a/internal/store/postgres/dialog.go +++ b/internal/store/postgres/dialog.go @@ -209,6 +209,7 @@ func (s *DialogStore) listByUser(ctx context.Context, userID int64, filter domai row.MessageQuoteText, row.MessageQuoteEntitiesJson, row.MessageQuoteOffset, + row.MessageReplyExternalJson, row.MessageFwdFromPeerType, row.MessageFwdFromPeerID, row.MessageFwdFromName, @@ -551,6 +552,7 @@ func (s *DialogStore) ListByPeers(ctx context.Context, userID int64, peers []dom row.MessageQuoteText, row.MessageQuoteEntitiesJson, row.MessageQuoteOffset, + row.MessageReplyExternalJson, row.MessageFwdFromPeerType, row.MessageFwdFromPeerID, row.MessageFwdFromName, diff --git a/internal/store/postgres/login_code_delivery.go b/internal/store/postgres/login_code_delivery.go index 166f21a5..05d2febd 100644 --- a/internal/store/postgres/login_code_delivery.go +++ b/internal/store/postgres/login_code_delivery.go @@ -173,7 +173,10 @@ func (s *MessageStore) DeliverLoginCodeMessage(ctx context.Context, req domain.L if err != nil { return domain.LoginCodeDeliveryResult{}, fmt.Errorf("create login code recipient box: %w", err) } - msg := messageFromBoxRow(boxRow) + msg, err := messageFromBoxRow(boxRow) + if err != nil { + return domain.LoginCodeDeliveryResult{}, err + } if err := qtx.UpsertInboxDialog(ctx, sqlcgen.UpsertInboxDialogParams{ UserID: req.UserID, diff --git a/internal/store/postgres/media_pagination_test.go b/internal/store/postgres/media_pagination_test.go new file mode 100644 index 00000000..6d30c0fa --- /dev/null +++ b/internal/store/postgres/media_pagination_test.go @@ -0,0 +1,106 @@ +package postgres + +import ( + "context" + "reflect" + "telesrv/internal/domain" + "testing" +) + +func TestMediaPaginationBoundaries(t *testing.T) { + ctx := context.Background() + pool := testPool(t) + users := NewUserStore(pool) + suffix := randomSuffix(t) + a, err := users.Create(ctx, domain.User{AccessHash: 61, Phone: "+1677" + suffix + "01", FirstName: "Media A"}) + if err != nil { + t.Fatal(err) + } + b, err := users.Create(ctx, domain.User{AccessHash: 62, Phone: "+1677" + suffix + "02", FirstName: "Media B"}) + if err != nil { + t.Fatal(err) + } + owner, other := a.ID, b.ID + messages := newTestMessageStore(pool) + channels := newTestChannelStore(pool) + t.Cleanup(func() { _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id=ANY($1::bigint[])", []int64{owner, other}) }) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: owner, Title: "media boundaries", Megagroup: true, MemberUserIDs: []int64{other}, Date: 1700000000}) + if err != nil { + t.Fatal(err) + } + channel := created.Channel.ID + t.Cleanup(func() { _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id=$1", channel) }) + + var privateIDs, channelIDs []int + for i := 1; i <= 12; i++ { + var media *domain.MessageMedia + if i%2 == 1 { + media = &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &domain.Photo{ID: int64(i), AccessHash: 99}} + } + a, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: owner, RecipientUserID: other, RandomID: int64(i), Message: "media boundary", Media: media, Date: 1700000000 + i}) + if err != nil { + t.Fatal(err) + } + c, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{UserID: owner, ChannelID: channel, RandomID: int64(i), Message: "media boundary", Media: media, Date: 1700000000 + i}) + if err != nil { + t.Fatal(err) + } + if media != nil { + privateIDs = append([]int{a.SenderMessage.ID}, privateIDs...) + channelIDs = append([]int{c.Message.ID}, channelIDs...) + } + } + for _, side := range []string{"private", "channel"} { + ids := privateIDs + if side == "channel" { + ids = channelIDs + } + for _, tc := range []struct { + name string + f domain.MediaSearchRequest + want []int + count int + }{ + {"all", domain.MediaSearchRequest{Limit: 100}, ids, 6}, + {"strict-range", domain.MediaSearchRequest{Limit: 100, MinID: ids[4], MaxID: ids[1]}, ids[2:4], 2}, + {"strict-count", domain.MediaSearchRequest{Limit: 0, MinID: ids[4], MaxID: ids[1], OffsetID: ids[2], AddOffset: -2}, nil, 2}, + {"around-existing", domain.MediaSearchRequest{Limit: 4, OffsetID: ids[2], AddOffset: -2}, ids[1:5], 6}, + {"after-missing", domain.MediaSearchRequest{Limit: 2, OffsetID: ids[4] + 1, AddOffset: -2}, ids[2:4], 6}, + {"forward-gap", domain.MediaSearchRequest{Limit: 2, OffsetID: ids[4], AddOffset: -4}, ids[1:3], 6}, + {"empty-forward", domain.MediaSearchRequest{Limit: 2, OffsetID: ids[0] + 1, AddOffset: -2}, nil, 6}, + {"around-top", domain.MediaSearchRequest{Limit: 4, OffsetID: ids[0] + 1, AddOffset: -2}, ids[:2], 6}, + {"around-zero", domain.MediaSearchRequest{Limit: 4, AddOffset: -2}, ids[:2], 6}, + {"empty-backward", domain.MediaSearchRequest{Limit: 2, AddOffset: 100}, nil, 6}, + } { + t.Run(side+"/"+tc.name, func(t *testing.T) { + f := tc.f + f.Categories = []domain.MediaCategory{domain.MediaCategoryPhoto, domain.MediaCategoryPhoto} + f.Query = "boundary" + var got []int + var count int + if side == "private" { + r, err := messages.SearchPrivateMedia(ctx, owner, other, f) + if err != nil { + t.Fatal(err) + } + count = r.Count + for _, m := range r.Messages { + got = append(got, m.ID) + } + } else { + r, err := channels.SearchChannelMedia(ctx, owner, channel, f) + if err != nil { + t.Fatal(err) + } + count = r.Count + for _, m := range r.Messages { + got = append(got, m.ID) + } + } + if count != tc.count || !reflect.DeepEqual(append([]int{}, got...), append([]int{}, tc.want...)) { + t.Fatalf("ids=%v count=%d want %v/%d", got, count, tc.want, tc.count) + } + }) + } + } +} diff --git a/internal/store/postgres/media_search.go b/internal/store/postgres/media_search.go index e156082f..d144b636 100644 --- a/internal/store/postgres/media_search.go +++ b/internal/store/postgres/media_search.go @@ -29,13 +29,57 @@ func mediaSearchPaging(req domain.MediaSearchRequest) (limit, offset int) { if limit < 0 || limit > mediaSearchPageLimit { limit = mediaSearchPageLimit } - offset = req.AddOffset - if offset < 0 { - offset = 0 - } + offset = domain.ClampMessageHistoryAddOffset(req.AddOffset) return limit, offset } +// mediaSearchIDs keeps both sides of the anchor bounded before hydration. The +// forward side includes offset_id; the backward side is strictly older. An +// absent side stays empty rather than moving the requested window. This +// covers add_offset's negative range (Telegram lets a caller ask for items +// straddling offset_id, e.g. "some before and some after") which the older +// offset>=0-only OFFSET/LIMIT query could not express -- a negative +// add_offset used to just get clamped to 0. +func mediaSearchIDs(ctx context.Context, db interface { + Query(context.Context, string, ...any) (pgx.Rows, error) +}, column, base string, baseArgs []any, req domain.MediaSearchRequest) ([]int, error) { + limit, add := mediaSearchPaging(req) + args := append([]any(nil), baseArgs...) + older, newer := "", " AND FALSE" + if req.OffsetID > 0 { + args = append(args, pgInt32NonNegative(req.OffsetID)) + older = fmt.Sprintf(" AND %s < $%d", column, len(args)) + newer = fmt.Sprintf(" AND %s >= $%d", column, len(args)) + } + part := func(anchor, direction string, offset, n int) string { + args = append(args, offset, n) + return fmt.Sprintf("(SELECT DISTINCT %s AS id%s%s ORDER BY id %s OFFSET $%d LIMIT $%d)", column, base, anchor, direction, len(args)-1, len(args)) + } + var query string + switch { + case add >= 0: + query = part(older, "DESC", add, limit) + case add+limit <= 0: + query = part(newer, "ASC", -add-limit, limit) + default: + query = part(newer, "ASC", 0, -add) + " UNION ALL " + part(older, "DESC", 0, limit+add) + } + rows, err := db.Query(ctx, "SELECT id FROM ("+query+") page ORDER BY id DESC", args...) + if err != nil { + return nil, err + } + defer rows.Close() + ids := make([]int, 0, limit) + for rows.Next() { + var id int32 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, int(id)) + } + return ids, rows.Err() +} + type mediaSearchQueryer interface { QueryRow(ctx context.Context, sql string, args ...any) pgx.Row } @@ -88,10 +132,10 @@ WHERE mi.owner_user_id = $1 AND mi.peer_id = $2 AND mi.category = ANY($3::smalli where += fmt.Sprintf(clause, len(args)) } if req.MaxID > 0 { - add(" AND mi.box_id <= $%d", pgInt32NonNegative(req.MaxID)) + add(" AND mi.box_id < $%d", pgInt32NonNegative(req.MaxID)) } if req.MinID > 0 { - add(" AND mi.box_id >= $%d", pgInt32NonNegative(req.MinID)) + add(" AND mi.box_id > $%d", pgInt32NonNegative(req.MinID)) } if req.Query != "" { add(" AND mb.body ILIKE '%%' || $%d || '%%'", req.Query) @@ -152,10 +196,10 @@ WHERE mi.channel_id = $1 AND mi.category = ANY($2::smallint[]) } } if req.MaxID > 0 { - add(" AND mi.id <= $%d", pgInt32NonNegative(req.MaxID)) + add(" AND mi.id < $%d", pgInt32NonNegative(req.MaxID)) } if req.MinID > 0 { - add(" AND mi.id >= $%d", pgInt32NonNegative(req.MinID)) + add(" AND mi.id > $%d", pgInt32NonNegative(req.MinID)) } if req.Query != "" { add(" AND m.body ILIKE '%%' || $%d || '%%'", req.Query) @@ -182,7 +226,7 @@ func (s *MessageStore) SearchPrivateMedia(ctx context.Context, ownerUserID, peer if ownerUserID == 0 || peerID == 0 || len(cats) == 0 { return domain.MessageList{}, nil } - limit, offset := mediaSearchPaging(req) + limit, _ := mediaSearchPaging(req) base, baseArgs := privateMediaSearchBase(ownerUserID, peerID, cats, req) count := req.KnownCount @@ -196,28 +240,9 @@ func (s *MessageStore) SearchPrivateMedia(ctx context.Context, ownerUserID, peer if limit == 0 { return domain.MessageList{Count: count}, nil } - args := append([]any(nil), baseArgs...) - if req.OffsetID > 0 { - args = append(args, pgInt32NonNegative(req.OffsetID)) - base += fmt.Sprintf(" AND mi.box_id < $%d", len(args)) - } - args = append(args, offset, limit) - rows, err := s.db.Query(ctx, "SELECT DISTINCT mi.box_id"+base+ - fmt.Sprintf(" ORDER BY mi.box_id DESC OFFSET $%d LIMIT $%d", len(args)-1, len(args)), args...) + ids, err := mediaSearchIDs(ctx, s.db, "mi.box_id", base, baseArgs, req) if err != nil { - return domain.MessageList{}, fmt.Errorf("list private media ids: %w", err) - } - defer rows.Close() - ids := make([]int, 0, limit) - for rows.Next() { - var id int32 - if err := rows.Scan(&id); err != nil { - return domain.MessageList{}, fmt.Errorf("scan private media id: %w", err) - } - ids = append(ids, int(id)) - } - if err := rows.Err(); err != nil { - return domain.MessageList{}, fmt.Errorf("iterate private media ids: %w", err) + return domain.MessageList{}, fmt.Errorf("list media ids: %w", err) } list, err := s.GetByIDs(ctx, ownerUserID, ids) @@ -263,7 +288,7 @@ func (s *ChannelStore) SearchChannelMedia(ctx context.Context, viewerUserID, cha if viewerUserID == 0 || channelID == 0 || len(cats) == 0 { return domain.ChannelHistory{}, nil } - limit, offset := mediaSearchPaging(req) + limit, _ := mediaSearchPaging(req) channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) if err != nil { @@ -281,28 +306,9 @@ func (s *ChannelStore) SearchChannelMedia(ctx context.Context, viewerUserID, cha if limit == 0 { return domain.ChannelHistory{Channel: channel, Self: member, Count: count}, nil } - args := append([]any(nil), baseArgs...) - if req.OffsetID > 0 { - args = append(args, pgInt32NonNegative(req.OffsetID)) - base += fmt.Sprintf(" AND mi.id < $%d", len(args)) - } - args = append(args, offset, limit) - rows, err := s.db.Query(ctx, "SELECT DISTINCT mi.id"+base+ - fmt.Sprintf(" ORDER BY mi.id DESC OFFSET $%d LIMIT $%d", len(args)-1, len(args)), args...) + ids, err := mediaSearchIDs(ctx, s.db, "mi.id", base, baseArgs, req) if err != nil { - return domain.ChannelHistory{}, fmt.Errorf("list channel media ids: %w", err) - } - defer rows.Close() - ids := make([]int, 0, limit) - for rows.Next() { - var id int32 - if err := rows.Scan(&id); err != nil { - return domain.ChannelHistory{}, fmt.Errorf("scan channel media id: %w", err) - } - ids = append(ids, int(id)) - } - if err := rows.Err(); err != nil { - return domain.ChannelHistory{}, fmt.Errorf("iterate channel media ids: %w", err) + return domain.ChannelHistory{}, fmt.Errorf("list media ids: %w", err) } hist, err := s.getChannelMessagesForMember(ctx, viewerUserID, channel, member, ids) diff --git a/internal/store/postgres/message_external_reply_integration_test.go b/internal/store/postgres/message_external_reply_integration_test.go new file mode 100644 index 00000000..ce5748a7 --- /dev/null +++ b/internal/store/postgres/message_external_reply_integration_test.go @@ -0,0 +1,335 @@ +package postgres + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "telesrv/internal/domain" + "telesrv/internal/store" +) + +func externalReplyUsers(t *testing.T, pool *pgxpool.Pool) (domain.User, domain.User) { + t.Helper() + ctx := context.Background() + users := NewUserStore(pool) + suffix := randomSuffix(t) + a := createTestUser(t, ctx, users, "+1813"+suffix+"01", "ExternalA", "") + b := createTestUser(t, ctx, users, "+1813"+suffix+"02", "ExternalB", "") + t.Logf("external reply fixture users: %d %d", a.ID, b.ID) + t.Cleanup(func() { + tx, err := pool.Begin(ctx) + if err != nil { + t.Error(err) + return + } + defer tx.Rollback(ctx) + // Delete the owned projections before users: from_user_id is RESTRICT, + // so user cascades alone depend on the database's trigger order. + for _, query := range []string{ + "DELETE FROM dispatch_outbox WHERE target_user_id=ANY($1::bigint[])", + "DELETE FROM user_update_events WHERE user_id=ANY($1::bigint[])", + "DELETE FROM message_boxes WHERE owner_user_id=ANY($1::bigint[])", + "DELETE FROM private_messages WHERE sender_user_id=ANY($1::bigint[])", + "DELETE FROM dialogs WHERE user_id=ANY($1::bigint[])", + "DELETE FROM users WHERE id=ANY($1::bigint[])", + } { + if _, err := tx.Exec(ctx, query, []int64{a.ID, b.ID}); err != nil { + t.Error(err) + return + } + } + if err := tx.Commit(ctx); err != nil { + t.Error(err) + } + }) + return a, b +} + +func TestPrivateExternalReplyAllReadPathsAndReplay(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + a, b := externalReplyUsers(t, pool) + messages := newTestMessageStore(pool) + source, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: a.ID, RecipientUserID: a.ID, RandomID: 1, Message: "a🌕 quote", Date: 1700000000, Entities: []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 4, Length: 5}}, Media: &domain.MessageMedia{Kind: domain.MessageMediaKindContact, Contact: &domain.MessageContact{FirstName: "source", PhoneNumber: "123"}}}) + if err != nil { + t.Fatal(err) + } + collision, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: b.ID, RecipientUserID: b.ID, RandomID: 1, Message: "unrelated same ID", Date: 1700000000}) + if err != nil { + t.Fatal(err) + } + if collision.SenderMessage.ID != source.SenderMessage.ID { + t.Fatal("fixture must exercise owner-local ID collision") + } + req := domain.SendPrivateTextRequest{SenderUserID: a.ID, RecipientUserID: b.ID, RandomID: 2, Message: "external reply", Date: 1700000001, ReplyTo: &domain.MessageReply{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: a.ID}, MessageID: source.SenderMessage.ID, QuoteText: "quote", QuoteOffset: 4}} + res, err := messages.SendPrivateText(ctx, req) + if err != nil { + t.Fatal(err) + } + want, err := domain.NewMessageReplyExternal(source.SenderMessage) + if err != nil { + t.Fatal(err) + } + assertMessage := func(label string, m domain.Message) { + t.Helper() + if m.ReplyTo == nil || !reflect.DeepEqual(m.ReplyTo.External, want) || m.ReplyTo.QuoteText != "quote" || m.ReplyTo.QuoteOffset != 4 { + t.Fatalf("%s lost snapshot: %+v", label, m.ReplyTo) + } + expectedID := source.SenderMessage.ID + if m.OwnerUserID == b.ID { + expectedID = 0 + } + if m.ReplyTo.MessageID != expectedID { + t.Fatalf("%s source ID=%d want=%d", label, m.ReplyTo.MessageID, expectedID) + } + } + assertMessage("sender", res.SenderMessage) + assertMessage("recipient", res.RecipientMessage) + // Edit and delete the source using the normal transaction paths. Neither may + // change the snapshot of a reply that has already committed. + if _, err := messages.EditMessage(ctx, domain.EditMessageRequest{OwnerUserID: a.ID, Peer: source.SenderMessage.Peer, ID: source.SenderMessage.ID, Message: "changed source", EditDate: 1700000002}); err != nil { + t.Fatal(err) + } + if _, err := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{OwnerUserID: a.ID, IDs: []int{source.SenderMessage.ID}, Date: 1700000003}); err != nil { + t.Fatal(err) + } + for _, m := range []domain.Message{res.SenderMessage, res.RecipientMessage} { + freshStore := newTestMessageStore(pool) + got, found, err := freshStore.GetByUID(ctx, m.OwnerUserID, m.UID) + if err != nil || !found { + t.Fatalf("get uid: %v", err) + } + assertMessage("uid", got) + list, err := freshStore.GetByIDs(ctx, m.OwnerUserID, []int{m.ID}) + if err != nil || len(list.Messages) != 1 { + t.Fatalf("get ids: %v", err) + } + assertMessage("ids", list.Messages[0]) + list, err = freshStore.ListByUser(ctx, m.OwnerUserID, domain.MessageFilter{HasPeer: true, Peer: m.Peer, Limit: 10}) + if err != nil || len(list.Messages) != 1 { + t.Fatalf("history: %v", err) + } + assertMessage("history", list.Messages[0]) + dialogs, err := NewDialogStore(pool).ListByUser(ctx, m.OwnerUserID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatal(err) + } + found = false + for _, top := range dialogs.Messages { + if top.UID == m.UID { + assertMessage("dialog", top) + found = true + } + } + if !found { + t.Fatal("dialog missing reply") + } + events := NewUpdateEventStore(pool) + diff, err := events.ListAfter(ctx, m.OwnerUserID, m.Pts-1, 1) + if err != nil || len(diff) != 1 { + t.Fatalf("difference: %v", err) + } + assertMessage("difference", diff[0].Message) + batch, err := events.BatchByCursor(ctx, []store.EventCursor{{UserID: m.OwnerUserID, Pts: m.Pts}}) + if err != nil || len(batch) != 1 { + t.Fatalf("egress batch: %v", err) + } + assertMessage("egress", batch[0].Message) + } + // A preflight miss can race a prior commit. The lock-protected duplicate + // lookup must win over revalidation of the now-deleted reply source. + req.IdempotencyPreflighted = true + replay, err := messages.SendPrivateText(ctx, req) + if err != nil || !replay.Duplicate || replay.SenderMessage.ID != res.SenderMessage.ID { + t.Fatalf("post-preflight replay: %+v err=%v", replay, err) + } + assertMessage("replay", replay.SenderMessage) + req.RandomID = 3 + if _, err := messages.SendPrivateText(ctx, req); !errors.Is(err, domain.ErrReplyMessageIDInvalid) { + t.Fatalf("fresh deleted reply: %v", err) + } + // Corruption must fail all reconstruction boundaries, never silently erase + // the reply. The isolated test repairs its deliberately corrupted row. + encoded, err := domain.EncodeMessageReplyExternal(want) + if err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `UPDATE message_boxes SET reply_external='{"unexpected":true}' WHERE private_message_id=$1`, res.SenderMessage.UID); err != nil { + t.Fatal(err) + } + if _, _, err := messages.GetByUID(ctx, b.ID, res.SenderMessage.UID); err == nil { + t.Fatal("bad snapshot accepted by GetByUID") + } + if _, err := messages.GetByIDs(ctx, b.ID, []int{res.RecipientMessage.ID}); err == nil { + t.Fatal("bad snapshot accepted by GetByIDs") + } + if _, err := NewUpdateEventStore(pool).BatchByCursor(ctx, []store.EventCursor{{UserID: b.ID, Pts: res.RecipientMessage.Pts}}); err == nil { + t.Fatal("bad snapshot accepted by Egress") + } + if _, err := pool.Exec(ctx, `UPDATE message_boxes SET reply_external=$2::jsonb WHERE private_message_id=$1`, res.SenderMessage.UID, encoded); err != nil { + t.Fatal(err) + } + down, err := os.ReadFile(filepath.Join("..", "..", "..", "deploy", "migrations", "20260908000002_private_external_reply.down.sql")) + if err != nil { + t.Fatal(err) + } + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatal(err) + } + defer tx.Rollback(ctx) + if _, err := tx.Exec(ctx, string(down)); err == nil || !strings.Contains(err.Error(), "cannot discard durable external reply") { + t.Fatalf("down migration lost snapshots: %v", err) + } + if err := tx.Rollback(ctx); err != nil { + t.Fatal(err) + } + // Once the sender deletes its output box, only the immutable receipt can + // acknowledge a lost-response retry. Validate its nested external payload. + if _, err := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{OwnerUserID: a.ID, IDs: []int{res.SenderMessage.ID}, Date: 1700000004}); err != nil { + t.Fatal(err) + } + req.RandomID = 2 + replayed, err := messages.SendPrivateText(ctx, req) + if err != nil || !replayed.Duplicate { + t.Fatalf("replay after output delete: %v", err) + } + assertMessage("deleted-output replay", replayed.SenderMessage) + var receipt []byte + if err := pool.QueryRow(ctx, "SELECT sender_snapshot FROM private_messages WHERE sender_user_id=$1 AND id=$2", a.ID, res.SenderMessage.UID).Scan(&receipt); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `UPDATE private_messages SET sender_snapshot=jsonb_set(sender_snapshot,'{message,ReplyTo,External,from,Date}','0') WHERE sender_user_id=$1 AND id=$2`, a.ID, res.SenderMessage.UID); err != nil { + t.Fatal(err) + } + if _, err := messages.SendPrivateText(ctx, req); err == nil { + t.Fatal("corrupt deleted-output receipt replayed") + } + if _, err := pool.Exec(ctx, "UPDATE private_messages SET sender_snapshot=$3::jsonb WHERE sender_user_id=$1 AND id=$2", a.ID, res.SenderMessage.UID, receipt); err != nil { + t.Fatal(err) + } +} + +func TestPrivateExternalReplyProtectedPairAndQuoteRejectAtomically(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + a, b := externalReplyUsers(t, pool) + messages := newTestMessageStore(pool) + source, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: b.ID, RecipientUserID: a.ID, RandomID: 1, Message: "source 🌕 quote", Date: 1700000000}) + if err != nil { + t.Fatal(err) + } + req := domain.SendPrivateTextRequest{SenderUserID: a.ID, RecipientUserID: a.ID, RandomID: 2, Message: "saved external", Date: 1700000001, ReplyTo: &domain.MessageReply{MessageID: source.RecipientMessage.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: b.ID}}} + first, err := messages.SendPrivateText(ctx, req) + if err != nil { + t.Fatal(err) + } + if first.SenderMessage.ReplyTo == nil || first.SenderMessage.ReplyTo.External == nil || first.SenderMessage.ReplyTo.MessageID != source.RecipientMessage.ID { + t.Fatal("private-to-Saved source missing") + } + saved, err := messages.ListSavedDialogs(ctx, a.ID, domain.SavedDialogsFilter{Limit: 10}) + if err != nil || len(saved.Messages) != 1 || !reflect.DeepEqual(saved.Messages[0].ReplyTo.External, first.SenderMessage.ReplyTo.External) { + t.Fatalf("Saved dialog snapshot: %+v %v", saved, err) + } + if _, err := messages.TogglePrivateNoForwards(ctx, domain.TogglePrivateNoForwardsRequest{ActorUserID: a.ID, PeerUserID: b.ID, Enabled: true, RandomID: 3, Date: 1700000002}); err != nil { + t.Fatal(err) + } + counts := func() [3]int64 { + t.Helper() + var out [3]int64 + err := pool.QueryRow(ctx, `SELECT (SELECT count(*) FROM private_messages WHERE sender_user_id=ANY($1::bigint[])),(SELECT count(*) FROM user_update_events WHERE user_id=ANY($1::bigint[])),(SELECT count(*) FROM dispatch_outbox WHERE target_user_id=ANY($1::bigint[]))`, []int64{a.ID, b.ID}).Scan(&out[0], &out[1], &out[2]) + if err != nil { + t.Fatal(err) + } + return out + } + before := counts() + req.RandomID = 4 + if _, err := messages.SendPrivateText(ctx, req); !errors.Is(err, domain.ErrChatForwardsRestricted) { + t.Fatalf("pair protection=%v", err) + } + if counts() != before { + t.Fatal("rejected reply mutated durable state") + } + req.RandomID = 2 + req.IdempotencyPreflighted = true + if replay, err := messages.SendPrivateText(ctx, req); err != nil || !replay.Duplicate { + t.Fatalf("exact replay after protection=%v", err) + } + if counts() != before { + t.Fatal("replay mutated state") + } + // Message protection still permits an ordinary same-dialog reply. + same := domain.SendPrivateTextRequest{SenderUserID: a.ID, RecipientUserID: b.ID, RandomID: 5, Message: "same-dialog", Date: 1700000003, ReplyTo: &domain.MessageReply{MessageID: source.RecipientMessage.ID}} + if reply, err := messages.SendPrivateText(ctx, same); err != nil || reply.SenderMessage.ReplyTo.External != nil { + t.Fatalf("same-dialog=%v", err) + } + if _, err := messages.TogglePrivateNoForwards(ctx, domain.TogglePrivateNoForwardsRequest{ActorUserID: a.ID, PeerUserID: b.ID, Enabled: false, RandomID: 6, Date: 1700000004}); err != nil { + t.Fatal(err) + } + req.RandomID = 7 + req.ReplyTo = domain.CloneMessageReply(req.ReplyTo) + req.ReplyTo.QuoteText = "invented" + before = counts() + if _, err := messages.SendPrivateText(ctx, req); !errors.Is(err, domain.ErrQuoteTextInvalid) { + t.Fatalf("invalid quote=%v", err) + } + if counts() != before { + t.Fatal("invalid quote mutated durable state") + } +} + +type replyBeginGate struct { + *pgxpool.Pool + entered, release chan struct{} +} + +func (g *replyBeginGate) Begin(ctx context.Context) (pgx.Tx, error) { + close(g.entered) + select { + case <-g.release: + return g.Pool.Begin(ctx) + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func TestPrivateExternalReplySourceDeletedBeforeTransaction(t *testing.T) { + pool := testPool(t) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + a, b := externalReplyUsers(t, pool) + messages := newTestMessageStore(pool) + source, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: a.ID, RecipientUserID: a.ID, RandomID: 1, Message: "source", Date: 1700000000}) + if err != nil { + t.Fatal(err) + } + gate := &replyBeginGate{Pool: pool, entered: make(chan struct{}), release: make(chan struct{})} + blocked := newTestMessageStore(gate, WithMessageAllocators(testAllocatorsFor(pool).boxIDs)) + done := make(chan error, 1) + go func() { + _, err := blocked.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: a.ID, RecipientUserID: b.ID, RandomID: 2, Message: "race", Date: 1700000001, ReplyTo: &domain.MessageReply{MessageID: source.SenderMessage.ID, Peer: source.SenderMessage.Peer}}) + done <- err + }() + select { + case <-gate.entered: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + if _, err := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{OwnerUserID: a.ID, IDs: []int{source.SenderMessage.ID}}); err != nil { + close(gate.release) + t.Fatal(err) + } + close(gate.release) + if err := <-done; !errors.Is(err, domain.ErrReplyMessageIDInvalid) { + t.Fatalf("source deleted before BEGIN must reject: %v", err) + } +} diff --git a/internal/store/postgres/message_forward.go b/internal/store/postgres/message_forward.go index 89f841a7..5eba9864 100644 --- a/internal/store/postgres/message_forward.go +++ b/internal/store/postgres/message_forward.go @@ -132,6 +132,7 @@ func messageFromForwardRow(row sqlcgen.GetMessageBoxesForForwardRow) (domain.Mes row.QuoteText, row.QuoteEntitiesJson, row.QuoteOffset, + row.ReplyExternalJson, row.FwdFromPeerType, row.FwdFromPeerID, row.FwdFromName, diff --git a/internal/store/postgres/message_helpers.go b/internal/store/postgres/message_helpers.go index d00a518e..4ed340a9 100644 --- a/internal/store/postgres/message_helpers.go +++ b/internal/store/postgres/message_helpers.go @@ -12,12 +12,7 @@ import ( ) func cloneMessageReply(reply *domain.MessageReply) *domain.MessageReply { - if reply == nil { - return nil - } - clone := *reply - clone.QuoteEntities = append([]domain.MessageEntity(nil), reply.QuoteEntities...) - return &clone + return domain.CloneMessageReply(reply) } func cloneChannelMessageAction(action *domain.ChannelMessageAction) *domain.ChannelMessageAction { @@ -117,6 +112,7 @@ type messageMetadataParams struct { QuoteText string QuoteEntitiesJSON []byte QuoteOffset int32 + ReplyExternalJSON []byte FwdFromPeerType string FwdFromPeerID int64 FwdFromName string @@ -135,6 +131,7 @@ func messageMetadataParamsFrom(silent, noforwards bool, reply *domain.MessageRep Silent: silent, Noforwards: noforwards, QuoteEntitiesJSON: []byte("[]"), + ReplyExternalJSON: []byte("{}"), } if reply != nil { if err := domain.ValidateMessageReplyBounds(reply); err != nil { @@ -152,6 +149,10 @@ func messageMetadataParamsFrom(silent, noforwards bool, reply *domain.MessageRep meta.QuoteText = reply.QuoteText meta.QuoteEntitiesJSON = quoteEntities meta.QuoteOffset = int32(reply.QuoteOffset) + meta.ReplyExternalJSON, err = domain.EncodeMessageReplyExternal(reply.External) + if err != nil { + return messageMetadataParams{}, err + } } if forward != nil { if forward.Date < 0 { @@ -170,9 +171,13 @@ func messageMetadataParamsFrom(silent, noforwards bool, reply *domain.MessageRep return meta, nil } -func messageMetadataFromFields(silent, noforwards bool, replyToMsgID int32, replyToPeerType string, replyToPeerID int64, replyToTopID int32, replyToStoryID int32, quoteText, quoteEntitiesJSON string, quoteOffset int32, fwdFromPeerType string, fwdFromPeerID int64, fwdFromName string, fwdDate int32, fwdSavedFromPeerType string, fwdSavedFromPeerID int64, fwdSavedFromMsgID int32) (bool, bool, *domain.MessageReply, *domain.MessageForward, error) { +func messageMetadataFromFields(silent, noforwards bool, replyToMsgID int32, replyToPeerType string, replyToPeerID int64, replyToTopID int32, replyToStoryID int32, quoteText, quoteEntitiesJSON string, quoteOffset int32, replyExternalJSON string, fwdFromPeerType string, fwdFromPeerID int64, fwdFromName string, fwdDate int32, fwdSavedFromPeerType string, fwdSavedFromPeerID int64, fwdSavedFromMsgID int32) (bool, bool, *domain.MessageReply, *domain.MessageForward, error) { var reply *domain.MessageReply - if replyToMsgID > 0 || replyToStoryID > 0 { + external, err := domain.DecodeMessageReplyExternal([]byte(replyExternalJSON)) + if err != nil { + return false, false, nil, nil, err + } + if replyToMsgID > 0 || replyToStoryID > 0 || external != nil { quoteEntities, err := decodeMessageEntities(quoteEntitiesJSON) if err != nil { return false, false, nil, nil, err @@ -185,6 +190,7 @@ func messageMetadataFromFields(silent, noforwards bool, replyToMsgID int32, repl QuoteText: quoteText, QuoteEntities: quoteEntities, QuoteOffset: int(quoteOffset), + External: external, } } var forward *domain.MessageForward diff --git a/internal/store/postgres/message_history.go b/internal/store/postgres/message_history.go index 4d860782..c1a8023a 100644 --- a/internal/store/postgres/message_history.go +++ b/internal/store/postgres/message_history.go @@ -73,10 +73,18 @@ func (s *MessageStore) GetByUID(ctx context.Context, userID, uid int64) (domain. if _, err := decodeReplyMarkup(row.ReplyMarkupJson); err != nil { return domain.Message{}, false, fmt.Errorf("get message by uid reply markup: %w", err) } - return messageFromGetBoxRow(row), true, nil + msg, err := messageFromGetBoxRow(row) + return msg, err == nil, err } func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) { + if filter.CountOnly { + total, err := s.countMessagesByUser(ctx, userID, filter) + if err != nil { + return domain.MessageList{}, fmt.Errorf("count messages: %w", err) + } + return domain.MessageList{Count: int(total)}, nil + } limit := filter.Limit if limit <= 0 { limit = 100 @@ -99,13 +107,14 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma savedReactionKeys := postgresSavedReactionKeys(filter.SavedReactions) // add_offset>=0 是 backward 热路径(初始加载/上滑翻页,占 getHistory 绝大多数)。 // 走扁平静态查询 ListMessagesBackward:规划仅单 index scan + 2 LEFT JOIN,避免 - // ListMessagesByUser 大 CTE 把 4 个分支+total 全树规划(6.7ms→~1ms)。与 CTE + // ListMessagesByUser 大 CTE 把 4 个分支全树规划(6.7ms→~1ms)。与 CTE // 的 backward 分支逐位等价。around/forward(add_offset<0,锚点跳转,较罕见)仍走 - // 原 CTE,逻辑零改动。total 在 backward 路径由 CountMessagesByUser 独立提供。 + // CTE。total 始终由 CountMessagesByUser 独立提供,不依附消息行(空页仍有总数)。 var rows []sqlcgen.ListMessagesByUserRow if addOffset >= 0 { bw, err := s.q.ListMessagesBackward(ctx, sqlcgen.ListMessagesBackwardParams{ OwnerUserID: userID, + SenderUserID: filter.SenderUserID, HasPeer: filter.HasPeer, PeerType: string(filter.Peer.Type), PeerID: filter.Peer.ID, @@ -135,40 +144,12 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma for i := range bw { rows[i] = backwardRowToByUserRow(bw[i]) } - if filter.NeedTotalCount { - total, err := s.q.CountMessagesByUser(ctx, sqlcgen.CountMessagesByUserParams{ - OwnerUserID: userID, - HasPeer: filter.HasPeer, - PeerType: string(filter.Peer.Type), - PeerID: filter.Peer.ID, - RestrictPeerIds: filter.RestrictPeerIDs, - PeerIds: filter.PeerIDs, - Query: filter.Query, - MinDate: pgInt32NonNegative(filter.MinDate), - MaxDate: pgInt32NonNegative(filter.MaxDate), - MaxID: pgInt32NonNegative(filter.MaxID), - MinID: pgInt32NonNegative(filter.MinID), - PinnedOnly: filter.PinnedOnly, - MusicOnly: filter.MusicOnly, - PhoneCallsOnly: filter.PhoneCallsOnly, - MissedPhoneCallsOnly: filter.MissedPhoneCallsOnly, - SavedPeerType: savedPeerType, - SavedPeerID: savedPeerID, - SavedReactionKeys: savedReactionKeys, - }) - if err != nil { - return domain.MessageList{}, fmt.Errorf("count messages: %w", err) - } - // 镜像原 CTE 的 CROSS JOIN total 语义:total_count 只随结果行下发, - // paged 为空时不产出(out.Count 保持 0),故仅在有行时附着。 - for i := range rows { - rows[i].TotalCount = total - } - } + } else { var err error rows, err = s.q.ListMessagesByUser(ctx, sqlcgen.ListMessagesByUserParams{ OwnerUserID: userID, + SenderUserID: filter.SenderUserID, HasPeer: filter.HasPeer, PeerType: string(filter.Peer.Type), PeerID: filter.Peer.ID, @@ -187,7 +168,6 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma MusicOnly: filter.MusicOnly, PhoneCallsOnly: filter.PhoneCallsOnly, MissedPhoneCallsOnly: filter.MissedPhoneCallsOnly, - NeedTotalCount: filter.NeedTotalCount, SavedPeerType: savedPeerType, SavedPeerID: savedPeerID, SavedReactionKeys: savedReactionKeys, @@ -222,6 +202,7 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma row.QuoteText, row.QuoteEntitiesJson, row.QuoteOffset, + row.ReplyExternalJson, row.FwdFromPeerType, row.FwdFromPeerID, row.FwdFromName, @@ -275,12 +256,15 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma Pinned: row.Pinned, SavedPeer: savedPeerFromFields(row.SavedPeerType, row.SavedPeerID), }) - if filter.NeedTotalCount && out.Count == 0 { - out.Count = int(row.TotalCount) - } appendUserFromMessageRow(&out, seenUsers, row) } - if !filter.NeedTotalCount { + if filter.NeedTotalCount { + total, err := s.countMessagesByUser(ctx, userID, filter) + if err != nil { + return domain.MessageList{}, fmt.Errorf("count messages: %w", err) + } + out.Count = int(total) + } else { out.Count = len(out.Messages) if hasMore { out.Count++ @@ -666,12 +650,24 @@ func (s *MessageStore) DeleteHistory(ctx context.Context, req domain.DeleteHisto return res, nil } -func messageFromBoxRow(row sqlcgen.CreateMessageBoxRow) domain.Message { - entities, _ := decodeMessageEntities(row.EntitiesJson) - media, _ := decodeMessageMedia(row.MediaJson) - markup, _ := decodeReplyMarkup(row.ReplyMarkupJson) - rich, _ := decodeRichMessage(row.RichMessageJson) - silent, noforwards, reply, forward, _ := messageMetadataFromFields( +func messageFromBoxRow(row sqlcgen.CreateMessageBoxRow) (domain.Message, error) { + entities, err := decodeMessageEntities(row.EntitiesJson) + if err != nil { + return domain.Message{}, err + } + media, err := decodeMessageMedia(row.MediaJson) + if err != nil { + return domain.Message{}, err + } + markup, err := decodeReplyMarkup(row.ReplyMarkupJson) + if err != nil { + return domain.Message{}, err + } + rich, err := decodeRichMessage(row.RichMessageJson) + if err != nil { + return domain.Message{}, err + } + silent, noforwards, reply, forward, err := messageMetadataFromFields( row.Silent, row.Noforwards, row.ReplyToMsgID, @@ -682,6 +678,7 @@ func messageFromBoxRow(row sqlcgen.CreateMessageBoxRow) domain.Message { row.QuoteText, row.QuoteEntitiesJson, row.QuoteOffset, + row.ReplyExternalJson, row.FwdFromPeerType, row.FwdFromPeerID, row.FwdFromName, @@ -690,6 +687,9 @@ func messageFromBoxRow(row sqlcgen.CreateMessageBoxRow) domain.Message { row.FwdSavedFromPeerID, row.FwdSavedFromMsgID, ) + if err != nil { + return domain.Message{}, err + } return domain.Message{ Media: media, ReplyMarkup: markup, @@ -719,15 +719,27 @@ func messageFromBoxRow(row sqlcgen.CreateMessageBoxRow) domain.Message { Effect: row.Effect, Pinned: row.Pinned, SavedPeer: savedPeerFromFields(row.SavedPeerType, row.SavedPeerID), - } + }, nil } -func messageFromGetBoxRow(row sqlcgen.GetMessageBoxByPrivateMessageRow) domain.Message { - entities, _ := decodeMessageEntities(row.EntitiesJson) - media, _ := decodeMessageMedia(row.MediaJson) - markup, _ := decodeReplyMarkup(row.ReplyMarkupJson) - rich, _ := decodeRichMessage(row.RichMessageJson) - silent, noforwards, reply, forward, _ := messageMetadataFromFields( +func messageFromGetBoxRow(row sqlcgen.GetMessageBoxByPrivateMessageRow) (domain.Message, error) { + entities, err := decodeMessageEntities(row.EntitiesJson) + if err != nil { + return domain.Message{}, err + } + media, err := decodeMessageMedia(row.MediaJson) + if err != nil { + return domain.Message{}, err + } + markup, err := decodeReplyMarkup(row.ReplyMarkupJson) + if err != nil { + return domain.Message{}, err + } + rich, err := decodeRichMessage(row.RichMessageJson) + if err != nil { + return domain.Message{}, err + } + silent, noforwards, reply, forward, err := messageMetadataFromFields( row.Silent, row.Noforwards, row.ReplyToMsgID, @@ -738,6 +750,7 @@ func messageFromGetBoxRow(row sqlcgen.GetMessageBoxByPrivateMessageRow) domain.M row.QuoteText, row.QuoteEntitiesJson, row.QuoteOffset, + row.ReplyExternalJson, row.FwdFromPeerType, row.FwdFromPeerID, row.FwdFromName, @@ -746,6 +759,9 @@ func messageFromGetBoxRow(row sqlcgen.GetMessageBoxByPrivateMessageRow) domain.M row.FwdSavedFromPeerID, row.FwdSavedFromMsgID, ) + if err != nil { + return domain.Message{}, err + } return domain.Message{ Media: media, ReplyMarkup: markup, @@ -775,7 +791,7 @@ func messageFromGetBoxRow(row sqlcgen.GetMessageBoxByPrivateMessageRow) domain.M Effect: row.Effect, Pinned: row.Pinned, SavedPeer: savedPeerFromFields(row.SavedPeerType, row.SavedPeerID), - } + }, nil } func messageFromVisibleBoxRow(row sqlcgen.ListVisibleMessageBoxesByPrivateMessageRow) (domain.Message, error) { @@ -794,6 +810,7 @@ func messageFromVisibleBoxRow(row sqlcgen.ListVisibleMessageBoxesByPrivateMessag row.QuoteText, row.QuoteEntitiesJson, row.QuoteOffset, + row.ReplyExternalJson, row.FwdFromPeerType, row.FwdFromPeerID, row.FwdFromName, @@ -865,6 +882,7 @@ func messageFromUpdateEditRow(row sqlcgen.UpdateMessageBoxEditRow) (domain.Messa row.QuoteText, row.QuoteEntitiesJson, row.QuoteOffset, + row.ReplyExternalJson, row.FwdFromPeerType, row.FwdFromPeerID, row.FwdFromName, @@ -936,6 +954,7 @@ func messageFromIDRow(row sqlcgen.GetMessageBoxesByIDsRow) (domain.Message, erro row.QuoteText, row.QuoteEntitiesJson, row.QuoteOffset, + row.ReplyExternalJson, row.FwdFromPeerType, row.FwdFromPeerID, row.FwdFromName, @@ -1004,7 +1023,7 @@ func eventFromMessage(msg domain.Message) domain.UpdateEvent { // backwardRowToByUserRow 把 ListMessagesBackward(扁平 backward 热路径)的行适配为 // ListMessagesByUserRow,从而复用 ListByUser 既有的解码/用户收集下游逻辑。两者列与 -// base 完全一致,仅缺 TotalCount(由调用方按 NeedTotalCount 单独填,默认 0)。 +// base 完全一致;总数由调用方在 NeedTotalCount 时独立查询。 func backwardRowToByUserRow(r sqlcgen.ListMessagesBackwardRow) sqlcgen.ListMessagesByUserRow { return sqlcgen.ListMessagesByUserRow{ BoxID: r.BoxID, @@ -1031,6 +1050,7 @@ func backwardRowToByUserRow(r sqlcgen.ListMessagesBackwardRow) sqlcgen.ListMessa QuoteText: r.QuoteText, QuoteEntitiesJson: r.QuoteEntitiesJson, QuoteOffset: r.QuoteOffset, + ReplyExternalJson: r.ReplyExternalJson, FwdFromPeerType: r.FwdFromPeerType, FwdFromPeerID: r.FwdFromPeerID, FwdFromName: r.FwdFromName, @@ -1176,3 +1196,35 @@ func appendMessageUsers(out *domain.MessageList, seen map[int64]struct{}, users add(user) } } + +// countMessagesByUser shares page predicates but intentionally excludes page anchors. +func (s *MessageStore) countMessagesByUser(ctx context.Context, userID int64, filter domain.MessageFilter) (int32, error) { + savedPeerType := "" + var savedPeerID int64 + if filter.SavedPeer.ID != 0 { + savedPeerType = string(filter.SavedPeer.Type) + savedPeerID = filter.SavedPeer.ID + } + savedReactionKeys := postgresSavedReactionKeys(filter.SavedReactions) + return s.q.CountMessagesByUser(ctx, sqlcgen.CountMessagesByUserParams{ + SenderUserID: filter.SenderUserID, + OwnerUserID: userID, + HasPeer: filter.HasPeer, + PeerType: string(filter.Peer.Type), + PeerID: filter.Peer.ID, + RestrictPeerIds: filter.RestrictPeerIDs, + PeerIds: filter.PeerIDs, + Query: filter.Query, + MinDate: pgInt32NonNegative(filter.MinDate), + MaxDate: pgInt32NonNegative(filter.MaxDate), + MaxID: pgInt32NonNegative(filter.MaxID), + MinID: pgInt32NonNegative(filter.MinID), + PinnedOnly: filter.PinnedOnly, + MusicOnly: filter.MusicOnly, + PhoneCallsOnly: filter.PhoneCallsOnly, + MissedPhoneCallsOnly: filter.MissedPhoneCallsOnly, + SavedPeerType: savedPeerType, + SavedPeerID: savedPeerID, + SavedReactionKeys: savedReactionKeys, + }) +} diff --git a/internal/store/postgres/message_history_integration_test.go b/internal/store/postgres/message_history_integration_test.go index 5386f26b..f47253cf 100644 --- a/internal/store/postgres/message_history_integration_test.go +++ b/internal/store/postgres/message_history_integration_test.go @@ -57,7 +57,7 @@ func TestMessageStoreListByUserSupportsForwardAndAroundHistoryOffsets(t *testing if err != nil { t.Fatalf("around history: %v", err) } - if got := messageIDs(around.Messages); !sameInts(got, []int{6, 5, 4, 3, 2, 1}) { + if got := messageIDs(around.Messages); !sameInts(got, []int{5, 4, 3, 2, 1}) { t.Fatalf("around ids = %v, want unread/newer side plus older context", got) } if around.Count != 6 { @@ -75,8 +75,8 @@ func TestMessageStoreListByUserSupportsForwardAndAroundHistoryOffsets(t *testing if err != nil { t.Fatalf("forward history: %v", err) } - if got := messageIDs(forward.Messages); !sameInts(got, []int{6, 5, 4}) { - t.Fatalf("forward ids = %v, want messages newer than offset", got) + if got := messageIDs(forward.Messages); !sameInts(got, []int{5, 4, 3}) { + t.Fatalf("forward ids = %v, want forward window including offset anchor", got) } if forward.Count != 6 { t.Fatalf("forward count = %d, want full dialog count", forward.Count) diff --git a/internal/store/postgres/message_read.go b/internal/store/postgres/message_read.go index 8f347b20..8a8424ee 100644 --- a/internal/store/postgres/message_read.go +++ b/internal/store/postgres/message_read.go @@ -398,7 +398,11 @@ func (s *MessageStore) ListUnreadReactionMessages(ctx context.Context, ownerUser } out := make([]domain.Message, 0, len(rows)) for _, row := range rows { - out = append(out, messageFromBoxRow(sqlcgen.CreateMessageBoxRow(row))) + msg, err := messageFromBoxRow(sqlcgen.CreateMessageBoxRow(row)) + if err != nil { + return nil, err + } + out = append(out, msg) } if err := s.enrichPrivateMessageReactions(ctx, s.db, ownerUserID, out); err != nil { return nil, err diff --git a/internal/store/postgres/message_search_count_test.go b/internal/store/postgres/message_search_count_test.go new file mode 100644 index 00000000..84e73868 --- /dev/null +++ b/internal/store/postgres/message_search_count_test.go @@ -0,0 +1,56 @@ +package postgres + +import ( + "context" + "errors" + "github.com/jackc/pgx/v5" + "strings" + "telesrv/internal/domain" + "testing" +) + +// A DB boundary that only accepts the count query. Embedded unexpected methods +// panic, so a page fetch, enrichment query or mutation cannot pass this test. +type countReadDB struct { + pgx.Tx + calls int + result int32 + err error +} + +func (d *countReadDB) QueryRow(_ context.Context, q string, args ...any) pgx.Row { + if !strings.HasPrefix(q, "-- name: CountMessagesByUser :one") { + panic("unexpected query") + } + d.calls++ + return countReadRow{d.result, d.err} +} + +type countReadRow struct { + value int32 + err error +} + +func (r countReadRow) Scan(dest ...any) error { + if r.err != nil { + return r.err + } + *dest[0].(*int32) = r.value + return nil +} +func TestMessageSearchCountOnlySkipsPageAndHydration(t *testing.T) { + for _, count := range []int32{0, 7} { + db := &countReadDB{result: count} + s := NewMessageStore(db) + got, err := s.ListByUser(context.Background(), 1, domain.MessageFilter{CountOnly: true, SenderUserID: 2, OffsetID: 8, AddOffset: -3, Limit: 500}) + if err != nil || got.Count != int(count) || len(got.Messages) != 0 || len(got.Users) != 0 || db.calls != 1 { + t.Fatalf("result=%+v err=%v calls=%d", got, err, db.calls) + } + } + failure := errors.New("database unavailable") + db := &countReadDB{err: failure} + got, err := NewMessageStore(db).ListByUser(context.Background(), 1, domain.MessageFilter{CountOnly: true}) + if !errors.Is(err, failure) || got.Count != 0 || db.calls != 1 { + t.Fatalf("error swallowed: result=%+v err=%v", got, err) + } +} diff --git a/internal/store/postgres/message_search_integration_test.go b/internal/store/postgres/message_search_integration_test.go new file mode 100644 index 00000000..9df69e99 --- /dev/null +++ b/internal/store/postgres/message_search_integration_test.go @@ -0,0 +1,80 @@ +package postgres + +import ( + "context" + "reflect" + "telesrv/internal/domain" + "testing" +) + +func TestMessageSearchSenderAndCountPredicates(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + users := NewUserStore(pool) + suffix := randomSuffix(t) + a, err := users.Create(ctx, domain.User{AccessHash: 61, Phone: "+1669" + suffix + "01", FirstName: "Search A"}) + if err != nil { + t.Fatal(err) + } + b, err := users.Create(ctx, domain.User{AccessHash: 62, Phone: "+1669" + suffix + "02", FirstName: "Search B"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id=ANY($1::bigint[])", []int64{a.ID, b.ID}) }) + s := newTestMessageStore(pool) + for i := 1; i <= 6; i++ { + sender, recipient := a.ID, b.ID + if i%2 == 0 { + sender, recipient = recipient, sender + } + _, err = s.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: sender, RecipientUserID: recipient, RandomID: int64(9100 + i), Message: "count needle", Date: 1700000000 + i}) + if err != nil { + t.Fatal(err) + } + } + peer := domain.Peer{Type: domain.PeerTypeUser, ID: b.ID} + _, err = s.PinPrivateMessage(ctx, domain.PinPrivateMessageRequest{OwnerUserID: a.ID, Peer: peer, MessageID: 3, Pinned: true, PmOneside: true, Date: 1700000010}) + if err != nil { + t.Fatal(err) + } + for _, tt := range []struct { + name string + f domain.MessageFilter + ids []int + count int + }{ + {"sender-backward", domain.MessageFilter{SenderUserID: a.ID, Limit: 10, NeedTotalCount: true}, []int{5, 3, 1}, 3}, + {"sender-around", domain.MessageFilter{SenderUserID: a.ID, OffsetID: 3, AddOffset: -1, Limit: 3, NeedTotalCount: true}, []int{3, 1}, 3}, + {"sender-forward", domain.MessageFilter{SenderUserID: b.ID, OffsetID: 2, AddOffset: -2, Limit: 2, NeedTotalCount: true}, []int{4, 2}, 3}, + {"count-ignores-page", domain.MessageFilter{SenderUserID: a.ID, CountOnly: true, OffsetID: 1, AddOffset: 100}, nil, 3}, + {"count-dates-ids", domain.MessageFilter{SenderUserID: a.ID, CountOnly: true, MinDate: 1700000001, MaxDate: 1700000006, MinID: 1, MaxID: 5}, nil, 1}, + {"empty-backward-total", domain.MessageFilter{SenderUserID: a.ID, AddOffset: 100, Limit: 2, NeedTotalCount: true}, nil, 3}, + {"empty-forward-total", domain.MessageFilter{SenderUserID: a.ID, OffsetID: 6, AddOffset: -2, Limit: 2, NeedTotalCount: true}, nil, 3}, + {"count-empty", domain.MessageFilter{SenderUserID: a.ID, CountOnly: true, MinID: 5}, nil, 0}, + {"count-pinned-own", domain.MessageFilter{SenderUserID: a.ID, CountOnly: true, PinnedOnly: true}, nil, 1}, + {"count-pinned-other", domain.MessageFilter{SenderUserID: b.ID, CountOnly: true, PinnedOnly: true}, nil, 0}, + } { + t.Run(tt.name, func(t *testing.T) { + f := tt.f + f.HasPeer = true + f.Peer = peer + f.Query = "needle" + got, err := s.ListByUser(ctx, a.ID, f) + if err != nil { + t.Fatal(err) + } + ids := messageIDs(got.Messages) + if len(ids) != len(tt.ids) || (len(ids) > 0 && !reflect.DeepEqual(ids, tt.ids)) || got.Count != tt.count { + t.Fatalf("ids=%v count=%d want %v/%d", ids, got.Count, tt.ids, tt.count) + } + if f.CountOnly && len(got.Users) != 0 { + t.Fatalf("count hydrated users: %+v", got.Users) + } + }) + } + // The opposite owner cannot observe a one-sided pin through a global count. + got, err := s.ListByUser(ctx, b.ID, domain.MessageFilter{CountOnly: true, PinnedOnly: true, SenderUserID: a.ID, Query: "needle"}) + if err != nil || got.Count != 0 { + t.Fatalf("owner isolation %+v %v", got, err) + } +} diff --git a/internal/store/postgres/message_send.go b/internal/store/postgres/message_send.go index c400507c..bacf54f2 100644 --- a/internal/store/postgres/message_send.go +++ b/internal/store/postgres/message_send.go @@ -223,18 +223,6 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP return domain.SendPrivateTextResult{}, fmt.Errorf("wait private send actor lanes: %w", err) } defer releaseLanes() - senderReply, recipientReply, err := s.resolvePrivateSendReply(ctx, req) - if err != nil { - return domain.SendPrivateTextResult{}, err - } - senderMeta, err := messageMetadataParamsFrom(req.Silent, req.NoForwards, senderReply, req.Forward) - if err != nil { - return domain.SendPrivateTextResult{}, err - } - recipientMeta, err := messageMetadataParamsFrom(req.Silent, req.NoForwards, recipientReply, req.Forward) - if err != nil { - return domain.SendPrivateTextResult{}, err - } beginner, ok := s.db.(txBeginner) if !ok { return domain.SendPrivateTextResult{}, fmt.Errorf("send private text: db does not support transactions") @@ -243,11 +231,6 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP var senderBoxID, recipientBoxID, recipientPts int selfMessage := req.RecipientUserID == req.SenderUserID deliverRecipient := !selfMessage && !req.RecipientBlocked - if selfMessage { - savedPeer := domain.SavedPeerForSelfChat(req.SenderUserID, req.Forward) - senderMeta.SavedPeerType = string(savedPeer.Type) - senderMeta.SavedPeerID = savedPeer.ID - } // Box ids allow gaps. Allocate them before borrowing a PostgreSQL connection // so Redis latency never extends the database transaction's lock lifetime. if plainHotPath { @@ -284,6 +267,36 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP if err := lockDispatchOutboxAppendFences(ctx, tx, []int64{req.SenderUserID, req.RecipientUserID}); err != nil { return domain.SendPrivateTextResult{}, fmt.Errorf("lock send dispatch append fences: %w", err) } + if hooks.before != nil || req.ReplyTo != nil { + // The preflight above cannot observe another first request until it commits. + // Recheck after the per-user transaction lock so aggregate-backed sends + // replay the committed message before their hook runs a second time, and + // so an external-reply source deleted between the preflight and here is + // resolved against the committed state, not a stale read. + if duplicate, found, err := s.duplicateSendResult(ctx, qtx, req, requestFingerprint); err != nil { + return domain.SendPrivateTextResult{}, err + } else if found { + duplicate.Duplicate = true + return duplicate, nil + } + } + senderReply, recipientReply, err := s.resolvePrivateSendReply(ctx, tx, qtx, req) + if err != nil { + return domain.SendPrivateTextResult{}, err + } + senderMeta, err := messageMetadataParamsFrom(req.Silent, req.NoForwards, senderReply, req.Forward) + if err != nil { + return domain.SendPrivateTextResult{}, err + } + recipientMeta, err := messageMetadataParamsFrom(req.Silent, req.NoForwards, recipientReply, req.Forward) + if err != nil { + return domain.SendPrivateTextResult{}, err + } + if selfMessage { + savedPeer := domain.SavedPeerForSelfChat(req.SenderUserID, req.Forward) + senderMeta.SavedPeerType = string(savedPeer.Type) + senderMeta.SavedPeerID = savedPeer.ID + } if hooks.before != nil { if err := hooks.before(ctx, tx, &req); err != nil { return domain.SendPrivateTextResult{}, err @@ -486,7 +499,10 @@ WHERE sender_user_id=$1 AND id=$2`, req.SenderUserID, pm.ID, sharedMediaJSON) if err != nil { return domain.SendPrivateTextResult{}, fmt.Errorf("create sender box: %w", err) } - sender := messageFromBoxRow(senderRow) + sender, err := messageFromBoxRow(senderRow) + if err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("decode sender box: %w", err) + } sender.RandomID = req.RandomID // 共享媒体索引(0118):发送者侧 box 按媒体类别建索引(peer=收件人)。 if err := insertMessageBoxMediaIndexTx(ctx, tx, req.SenderUserID, req.RecipientUserID, int(senderBoxID), req.Date, media.Sender, req.Entities); err != nil { @@ -557,7 +573,10 @@ WHERE sender_user_id=$1 AND id=$2`, req.SenderUserID, pm.ID, sharedMediaJSON) if err != nil { return domain.SendPrivateTextResult{}, fmt.Errorf("create recipient box: %w", err) } - recipient = messageFromBoxRow(recipientRow) + recipient, err = messageFromBoxRow(recipientRow) + if err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("decode recipient box: %w", err) + } recipient.RandomID = req.RandomID // 共享媒体索引(0118):收件人侧 box 按媒体类别建索引(peer=发送者)。 if err := insertMessageBoxMediaIndexTx(ctx, tx, req.RecipientUserID, req.SenderUserID, int(recipientBoxID), req.Date, media.Recipient, req.Entities); err != nil { @@ -727,7 +746,10 @@ func (s *MessageStore) duplicateSendResult(ctx context.Context, q *sqlcgen.Queri PrivateMessageID: pm.ID, }) if currentErr == nil { - sender = messageFromGetBoxRow(currentRow) + sender, currentErr = messageFromGetBoxRow(currentRow) + if currentErr != nil { + return domain.SendPrivateTextResult{}, false, fmt.Errorf("decode duplicate private message %d current sender box: %w", pm.ID, currentErr) + } sender.RandomID = pm.RandomID } else if !errors.Is(currentErr, pgx.ErrNoRows) { return domain.SendPrivateTextResult{}, false, fmt.Errorf("get current duplicate private message %d sender box: %w", pm.ID, currentErr) @@ -783,10 +805,16 @@ func (s *MessageStore) duplicateSendResult(ctx context.Context, q *sqlcgen.Queri }, true, nil } -func (s *MessageStore) resolvePrivateSendReply(ctx context.Context, req domain.SendPrivateTextRequest) (*domain.MessageReply, *domain.MessageReply, error) { +func (s *MessageStore) resolvePrivateSendReply(ctx context.Context, db sqlcgen.DBTX, q *sqlcgen.Queries, req domain.SendPrivateTextRequest) (*domain.MessageReply, *domain.MessageReply, error) { if req.ReplyTo == nil { return nil, nil, nil } + if req.ReplyTo.External != nil { + // External is a server-computed snapshot (see below); a client sending + // one is either a replay of our own wire encoding sent back to us, or a + // forged value, neither of which should be trusted as-is. + return nil, nil, domain.ErrReplyMessageIDInvalid + } if req.ReplyTo.StoryID > 0 { // story 回复(评论):无源消息可查;story 作者就是会话对端(recipient),双盒同持。 if req.ReplyTo.StoryID > domain.MaxStoryID { @@ -816,7 +844,7 @@ func (s *MessageStore) resolvePrivateSendReply(ctx context.Context, req domain.S reply.Peer = peer return reply, cloneMessageReply(reply), nil } - source, err := s.q.GetMessageBoxForReply(ctx, sqlcgen.GetMessageBoxForReplyParams{ + source, err := q.GetMessageBoxForReply(ctx, sqlcgen.GetMessageBoxForReplyParams{ OwnerUserID: req.SenderUserID, PeerType: string(peer.Type), PeerID: peer.ID, @@ -831,17 +859,49 @@ func (s *MessageStore) resolvePrivateSendReply(ctx context.Context, req domain.S senderReply := cloneMessageReply(req.ReplyTo) senderReply.MessageID = int(source.BoxID) senderReply.Peer = peer + if peer.ID != req.RecipientUserID { + // A cross-dialog reply references the sender's source box, which has no + // corresponding row in the destination dialog to remap to. Both sides + // therefore carry an immutable External snapshot of the source instead + // of a live, re-resolvable message reference. + entities, err := decodeMessageEntities(source.EntitiesJson) + if err != nil { + return nil, nil, err + } + media, err := decodeMessageMedia(source.MediaJson) + if err != nil { + return nil, nil, err + } + protected := source.Noforwards || (media != nil && media.TTLSeconds > 0) + if low, high, ok := pgNoForwardsPair(req.SenderUserID, peer.ID); ok { + var pairProtected bool + if err := db.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM private_no_forwards_chats WHERE user_low_id=$1 AND user_high_id=$2 AND COALESCE(enabled_by_user_id,0)<>0)`, low, high).Scan(&pairProtected); err != nil { + return nil, nil, fmt.Errorf("read reply source protection: %w", err) + } + protected = protected || pairProtected + } + if protected { + return nil, nil, domain.ErrChatForwardsRestricted + } + if err := domain.ValidateExternalReplyQuote(req.ReplyTo, source.Body); err != nil { + return nil, nil, err + } + senderReply.External, err = domain.NewMessageReplyExternal(domain.Message{From: domain.Peer{Type: domain.PeerTypeUser, ID: source.FromUserID}, Date: int(source.MessageDate), Body: source.Body, Entities: entities, Media: media}) + if err != nil { + return nil, nil, err + } + recipientReply := cloneMessageReply(senderReply) + if req.SenderUserID != req.RecipientUserID { + recipientReply.MessageID = 0 + recipientReply.TopMessageID = 0 + } + return senderReply, recipientReply, nil + } if req.SenderUserID == req.RecipientUserID { return senderReply, cloneMessageReply(senderReply), nil } - if peer.ID != req.RecipientUserID { - // A cross-dialog reply references the sender's source box. There is no - // corresponding row in the destination dialog to remap to; both sides - // therefore receive the explicit source peer/message pair. - return senderReply, cloneMessageReply(senderReply), nil - } - recipientRow, err := s.q.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{ + recipientRow, err := q.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{ OwnerUserID: req.RecipientUserID, PrivateMessageID: source.PrivateMessageID, }) @@ -931,6 +991,7 @@ func applyCreatePrivateMessageMetadata(arg *sqlcgen.CreatePrivateMessageParams, arg.QuoteText = meta.QuoteText arg.QuoteEntitiesJson = meta.QuoteEntitiesJSON arg.QuoteOffset = meta.QuoteOffset + arg.ReplyExternalJson = meta.ReplyExternalJSON arg.FwdFromPeerType = meta.FwdFromPeerType arg.FwdFromPeerID = meta.FwdFromPeerID arg.FwdFromName = meta.FwdFromName @@ -948,6 +1009,7 @@ func applyCreateMessageBoxMetadata(arg *sqlcgen.CreateMessageBoxParams, meta mes arg.QuoteText = meta.QuoteText arg.QuoteEntitiesJson = meta.QuoteEntitiesJSON arg.QuoteOffset = meta.QuoteOffset + arg.ReplyExternalJson = meta.ReplyExternalJSON arg.FwdFromPeerType = meta.FwdFromPeerType arg.FwdFromPeerID = meta.FwdFromPeerID arg.FwdFromName = meta.FwdFromName diff --git a/internal/store/postgres/message_send_idempotency_integration_test.go b/internal/store/postgres/message_send_idempotency_integration_test.go index 0fb12a66..cc021897 100644 --- a/internal/store/postgres/message_send_idempotency_integration_test.go +++ b/internal/store/postgres/message_send_idempotency_integration_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "os" + "strings" "sync" "testing" "time" @@ -246,27 +247,46 @@ func TestMessageStorePrivateRandomIDReplayUsesCurrentSnapshotAndDurableDelete(t } } -// beginHookDB lets the test commit a competing send exactly after the outer -// fast-path lookup and before its transaction starts. With MaxConns=1, a +// privatePreflightHookDB commits a competing send after the fast-path lookup +// releases its connection but before account admission. Running it in Begin +// would nest a send inside the same process's already-held account lease. +// With MaxConns=1, a // duplicate fallback that queries the pool while holding the transaction would // wait until the context deadline; reading through qtx completes immediately. -type beginHookDB struct { +type privatePreflightHookDB struct { *pgxpool.Pool once sync.Once before func(context.Context) error beforeErr error + committed bool } -func (db *beginHookDB) Begin(ctx context.Context) (pgx.Tx, error) { - db.once.Do(func() { - if db.before != nil { - db.beforeErr = db.before(ctx) - } - }) - if db.beforeErr != nil { - return nil, db.beforeErr +type privatePreflightRow struct { + pgx.Row + ctx context.Context + db *privatePreflightHookDB +} + +func (db *privatePreflightHookDB) QueryRow(ctx context.Context, query string, args ...any) pgx.Row { + row := db.Pool.QueryRow(ctx, query, args...) + if strings.HasPrefix(query, "-- name: GetPrivateMessageByRandomID :one") { + return privatePreflightRow{Row: row, ctx: ctx, db: db} } - return db.Pool.Begin(ctx) + return row +} + +func (row privatePreflightRow) Scan(dest ...any) error { + err := row.Row.Scan(dest...) + if errors.Is(err, pgx.ErrNoRows) { + row.db.once.Do(func() { + row.db.beforeErr = row.db.before(row.ctx) + row.db.committed = row.db.beforeErr == nil + }) + if row.db.beforeErr != nil { + return row.db.beforeErr + } + } + return err } func TestMessageStorePrivateRandomIDConflictFallbackUsesTransactionConnection(t *testing.T) { @@ -302,7 +322,7 @@ func TestMessageStorePrivateRandomIDConflictFallbackUsesTransactionConnection(t Message: "commit between preflight and insert", Date: 1700001200, } boxIDs := &perUserCounterAllocator{} - db := &beginHookDB{Pool: pool} + db := &privatePreflightHookDB{Pool: pool} db.before = func(ctx context.Context) error { remote := NewMessageStore(pool, WithMessageAllocators(boxIDs)) // A separate actor represents another server process. Cross-process @@ -322,4 +342,7 @@ func TestMessageStorePrivateRandomIDConflictFallbackUsesTransactionConnection(t if !got.Duplicate || got.SenderMessage.ID == 0 || got.RecipientMessage.ID == 0 { t.Fatalf("conflict fallback result = %+v, want committed duplicate boxes", got) } + if !db.committed { + t.Fatal("competing send did not commit after the preflight miss") + } } diff --git a/internal/store/postgres/message_store_testkit_test.go b/internal/store/postgres/message_store_testkit_test.go index a8bff567..ec3c3f88 100644 --- a/internal/store/postgres/message_store_testkit_test.go +++ b/internal/store/postgres/message_store_testkit_test.go @@ -4,8 +4,156 @@ import ( "context" "sync" "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" ) +type testAllocatorBundle struct { + boxIDs *testBoxIDAllocator + channelIDs *testChannelIDAllocator + channelMessageIDs *testChannelMessageIDAllocator +} + +var testAllocatorBundles sync.Map + +func testAllocatorsFor(db sqlcgen.DBTX) *testAllocatorBundle { + if existing, ok := testAllocatorBundles.Load(db); ok { + return existing.(*testAllocatorBundle) + } + bundle := &testAllocatorBundle{ + boxIDs: &testBoxIDAllocator{source: NewMessageBoxCounterSource(db)}, + channelIDs: &testChannelIDAllocator{source: NewChannelIDCounterSource(db)}, + channelMessageIDs: &testChannelMessageIDAllocator{source: NewChannelMessageIDCounterSource(db)}, + } + actual, _ := testAllocatorBundles.LoadOrStore(db, bundle) + return actual.(*testAllocatorBundle) +} + +func newTestMessageStore(db sqlcgen.DBTX, opts ...MessageStoreOption) *MessageStore { + bundle := testAllocatorsFor(db) + all := append([]MessageStoreOption{WithMessageAllocators(bundle.boxIDs)}, opts...) + return NewMessageStore(db, all...) +} + +func newTestChannelStore(db sqlcgen.DBTX, opts ...ChannelStoreOption) *ChannelStore { + bundle := testAllocatorsFor(db) + all := append([]ChannelStoreOption{WithChannelAllocators(bundle.channelIDs, bundle.channelMessageIDs)}, opts...) + return NewChannelStore(db, all...) +} + +type testBoxIDAllocator struct { + mu sync.Mutex + source *MessageBoxCounterSource + values map[int64]int +} + +func (a *testBoxIDAllocator) NextBoxID(ctx context.Context, userID int64) (int, error) { + values, err := a.NextBoxIDs(ctx, []int64{userID}) + return values[userID], err +} + +func (a *testBoxIDAllocator) NextBoxIDs(ctx context.Context, userIDs []int64) (map[int64]int, error) { + a.mu.Lock() + defer a.mu.Unlock() + current, err := a.source.CurrentBatch(ctx, userIDs) + if err != nil { + return nil, err + } + if a.values == nil { + a.values = make(map[int64]int, len(userIDs)) + } + out := make(map[int64]int, len(userIDs)) + for _, userID := range userIDs { + if current[userID] > a.values[userID] { + a.values[userID] = current[userID] + } + a.values[userID]++ + out[userID] = a.values[userID] + } + return out, nil +} + +func (a *testBoxIDAllocator) CurrentBoxID(ctx context.Context, userID int64) (int, error) { + a.mu.Lock() + defer a.mu.Unlock() + current, err := a.source.Current(ctx, userID) + if err != nil { + return 0, err + } + if a.values[userID] > current { + return a.values[userID], nil + } + return current, nil +} + +type testChannelIDAllocator struct { + mu sync.Mutex + source *ChannelIDCounterSource + current int64 +} + +func (a *testChannelIDAllocator) NextChannelID(ctx context.Context) (int64, error) { + a.mu.Lock() + defer a.mu.Unlock() + current, err := a.source.Current(ctx, 0) + if err != nil { + return 0, err + } + if int64(current) > a.current { + a.current = int64(current) + } + a.current++ + return a.current, nil +} + +func (a *testChannelIDAllocator) CurrentChannelID(ctx context.Context) (int64, error) { + a.mu.Lock() + defer a.mu.Unlock() + current, err := a.source.Current(ctx, 0) + if err != nil { + return 0, err + } + if int64(current) > a.current { + a.current = int64(current) + } + return a.current, nil +} + +type testChannelMessageIDAllocator struct { + mu sync.Mutex + source *ChannelMessageIDCounterSource + values map[int64]int +} + +func (a *testChannelMessageIDAllocator) NextChannelMessageID(ctx context.Context, channelID int64) (int, error) { + a.mu.Lock() + defer a.mu.Unlock() + current, err := a.source.Current(ctx, channelID) + if err != nil { + return 0, err + } + if a.values == nil { + a.values = make(map[int64]int) + } + if current > a.values[channelID] { + a.values[channelID] = current + } + a.values[channelID]++ + return a.values[channelID], nil +} + +func (a *testChannelMessageIDAllocator) CurrentChannelMessageID(ctx context.Context, channelID int64) (int, error) { + a.mu.Lock() + defer a.mu.Unlock() + current, err := a.source.Current(ctx, channelID) + if err != nil { + return 0, err + } + if a.values[channelID] > current { + return a.values[channelID], nil + } + return current, nil +} + type fixedBoxIDAllocator struct { next int } diff --git a/internal/store/postgres/phone_identity_migration.go b/internal/store/postgres/phone_identity_migration.go new file mode 100644 index 00000000..01381130 --- /dev/null +++ b/internal/store/postgres/phone_identity_migration.go @@ -0,0 +1,152 @@ +package postgres + +import ( + "context" + "fmt" + "math" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" +) + +const phoneIdentityMigrationBatchSize = 1000 + +type phoneIdentityCandidate struct { + userID int64 + canonicalPhone string + needsUpdate bool +} + +// canonicalizeStoredPhoneIdentities is the data half of migration 0182. SQL +// alone cannot correctly distinguish a removable national trunk prefix from a +// significant leading zero, so the application numbering-plan metadata builds +// a complete plan first. Legacy values that do not describe any valid phone +// identity are left untouched and therefore remain unreachable through the +// canonical login path. No users row is changed until no two parseable rows +// converge on the same E.164 identity. +func canonicalizeStoredPhoneIdentities(ctx context.Context, conn *pgx.Conn) error { + tx, err := conn.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return fmt.Errorf("begin phone identity audit: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(context.Background()) + } + }() + if err := canonicalizeStoredPhoneIdentitiesTx(ctx, tx); err != nil { + return err + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit phone identity migration: %w", err) + } + committed = true + return nil +} + +func canonicalizeStoredPhoneIdentitiesTx(ctx context.Context, tx pgx.Tx) error { + return canonicalizeStoredPhoneIdentitiesInTableTx(ctx, tx, pgx.Identifier{"public", "users"}) +} + +func canonicalizeStoredPhoneIdentitiesInTableTx(ctx context.Context, tx pgx.Tx, usersTable pgx.Identifier) error { + if _, err := tx.Exec(ctx, ` +CREATE TEMP TABLE phone_identity_0182 ( + user_id bigint PRIMARY KEY, + canonical_phone text NOT NULL, + needs_update boolean NOT NULL +) ON COMMIT DROP`); err != nil { + return fmt.Errorf("create phone identity plan: %w", err) + } + + lastID := int64(math.MinInt64) + for { + rows, err := tx.Query(ctx, fmt.Sprintf(` +SELECT id, phone +FROM %s +WHERE id > $1 AND phone <> '' +ORDER BY id +LIMIT $2`, usersTable.Sanitize()), lastID, phoneIdentityMigrationBatchSize) + if err != nil { + return fmt.Errorf("scan stored phone identities: %w", err) + } + rowsRead := 0 + candidates := make([]phoneIdentityCandidate, 0, phoneIdentityMigrationBatchSize) + for rows.Next() { + rowsRead++ + var userID int64 + var rawPhone string + if err := rows.Scan(&userID, &rawPhone); err != nil { + rows.Close() + return fmt.Errorf("read stored phone identity: %w", err) + } + lastID = userID + canonical := rawPhone + if !domain.IsSystemUserID(userID) { + canonical = domain.NormalizePhone(rawPhone) + if canonical == "" { + continue + } + } + candidates = append(candidates, phoneIdentityCandidate{ + userID: userID, + canonicalPhone: canonical, + needsUpdate: canonical != rawPhone, + }) + } + if err := rows.Err(); err != nil { + rows.Close() + return fmt.Errorf("iterate stored phone identities: %w", err) + } + rows.Close() + if len(candidates) > 0 { + copyRows := make([][]any, 0, len(candidates)) + for _, candidate := range candidates { + copyRows = append(copyRows, []any{candidate.userID, candidate.canonicalPhone, candidate.needsUpdate}) + } + if _, err := tx.CopyFrom(ctx, + pgx.Identifier{"phone_identity_0182"}, + []string{"user_id", "canonical_phone", "needs_update"}, + pgx.CopyFromRows(copyRows), + ); err != nil { + return fmt.Errorf("stage phone identity plan: %w", err) + } + } + if rowsRead < phoneIdentityMigrationBatchSize { + break + } + } + + if _, err := tx.Exec(ctx, ` +CREATE INDEX phone_identity_0182_canonical_idx +ON phone_identity_0182(canonical_phone, user_id)`); err != nil { + return fmt.Errorf("index phone identity plan: %w", err) + } + + var firstUserID, secondUserID int64 + err := tx.QueryRow(ctx, ` +SELECT first.user_id, second.user_id +FROM phone_identity_0182 AS first +JOIN phone_identity_0182 AS second + ON second.canonical_phone = first.canonical_phone + AND second.user_id > first.user_id +ORDER BY first.user_id, second.user_id +LIMIT 1`).Scan(&firstUserID, &secondUserID) + switch { + case err == nil: + return fmt.Errorf("users %d and %d resolve to the same canonical phone", firstUserID, secondUserID) + case err != pgx.ErrNoRows: + return fmt.Errorf("audit canonical phone uniqueness: %w", err) + } + + if _, err := tx.Exec(ctx, fmt.Sprintf(` +UPDATE %s AS target +SET phone = planned.canonical_phone +FROM phone_identity_0182 AS planned +WHERE planned.user_id = target.id + AND planned.needs_update`, usersTable.Sanitize())); err != nil { + return fmt.Errorf("apply canonical phone identities: %w", err) + } + return nil +} diff --git a/internal/store/postgres/phone_identity_migration_integration_test.go b/internal/store/postgres/phone_identity_migration_integration_test.go new file mode 100644 index 00000000..561d5587 --- /dev/null +++ b/internal/store/postgres/phone_identity_migration_integration_test.go @@ -0,0 +1,97 @@ +package postgres + +import ( + "context" + "strings" + "testing" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" +) + +func TestPhoneIdentityMigrationCanonicalizesOnlyAfterCompleteAuditPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + + t.Run("canonicalizes national trunk and preserves significant zero", func(t *testing.T) { + tx := beginPhoneIdentityFixtureTx(t, ctx, pool) + defer func() { _ = tx.Rollback(context.Background()) }() + if _, err := tx.Exec(ctx, ` +INSERT INTO users(id, phone) VALUES + (1001, '9809981679461'), + (1002, '390212345678'), + ($1, $2)`, domain.OfficialSystemUserID, domain.OfficialSystemPhone); err != nil { + t.Fatalf("insert phone fixtures: %v", err) + } + + if err := canonicalizeStoredPhoneIdentitiesInTableTx(ctx, tx, pgx.Identifier{"users"}); err != nil { + t.Fatalf("canonicalizeStoredPhoneIdentitiesInTableTx: %v", err) + } + assertFixturePhone(t, ctx, tx, 1001, "989981679461") + assertFixturePhone(t, ctx, tx, 1002, "390212345678") + assertFixturePhone(t, ctx, tx, domain.OfficialSystemUserID, domain.OfficialSystemPhone) + }) + + t.Run("duplicate canonical identity fails without changing rows", func(t *testing.T) { + tx := beginPhoneIdentityFixtureTx(t, ctx, pool) + defer func() { _ = tx.Rollback(context.Background()) }() + if _, err := tx.Exec(ctx, ` +INSERT INTO users(id, phone) VALUES + (2001, '9809981679461'), + (2002, '989981679461')`); err != nil { + t.Fatalf("insert duplicate fixtures: %v", err) + } + + err := canonicalizeStoredPhoneIdentitiesInTableTx(ctx, tx, pgx.Identifier{"users"}) + if err == nil || !strings.Contains(err.Error(), "users 2001 and 2002 resolve to the same canonical phone") { + t.Fatalf("canonicalize err = %v, want duplicate user IDs", err) + } + assertFixturePhone(t, ctx, tx, 2001, "9809981679461") + assertFixturePhone(t, ctx, tx, 2002, "989981679461") + }) + + t.Run("invalid ordinary identity remains unreachable without blocking valid rows", func(t *testing.T) { + tx := beginPhoneIdentityFixtureTx(t, ctx, pool) + defer func() { _ = tx.Rollback(context.Background()) }() + const invalidPhone = "legacy-not-a-phone" + if _, err := tx.Exec(ctx, ` +INSERT INTO users(id, phone) VALUES + (3001, $1), + (3002, '9809981679461')`, invalidPhone); err != nil { + t.Fatalf("insert invalid fixture: %v", err) + } + + if err := canonicalizeStoredPhoneIdentitiesInTableTx(ctx, tx, pgx.Identifier{"users"}); err != nil { + t.Fatalf("canonicalizeStoredPhoneIdentitiesInTableTx: %v", err) + } + assertFixturePhone(t, ctx, tx, 3001, invalidPhone) + assertFixturePhone(t, ctx, tx, 3002, "989981679461") + }) +} + +func beginPhoneIdentityFixtureTx(t *testing.T, ctx context.Context, pool interface { + Begin(context.Context) (pgx.Tx, error) +}) pgx.Tx { + t.Helper() + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatalf("begin phone identity fixture: %v", err) + } + if _, err := tx.Exec(ctx, `CREATE TEMP TABLE users (id bigint PRIMARY KEY, phone text NOT NULL) ON COMMIT DROP`); err != nil { + _ = tx.Rollback(context.Background()) + t.Fatalf("create isolated users fixture: %v", err) + } + return tx +} + +func assertFixturePhone(t *testing.T, ctx context.Context, tx pgx.Tx, userID int64, want string) { + t.Helper() + var got string + if err := tx.QueryRow(ctx, `SELECT phone FROM users WHERE id=$1`, userID).Scan(&got); err != nil { + t.Fatalf("read user %d phone: %v", userID, err) + } + if got != want { + t.Fatalf("user %d phone = %q, want %q", userID, got, want) + } +} diff --git a/internal/store/postgres/postgres.go b/internal/store/postgres/postgres.go index 04615bd5..cbd0c435 100644 --- a/internal/store/postgres/postgres.go +++ b/internal/store/postgres/postgres.go @@ -21,6 +21,14 @@ import ( const defaultMinConns = 16 +const ( + phoneIdentityPredecessorVersion = uint(181) + phoneIdentityMigrationVersion = uint(182) + // "phone182" as a signed PostgreSQL advisory-lock key. The lock spans the + // two-stage 0181 -> identity audit -> 0182 transition across new binaries. + phoneIdentityMigrationLockKey = int64(0x70686f6e65313832) +) + // MigrationStatus 是启动迁移后的 schema 状态。 type MigrationStatus struct { Version uint @@ -73,6 +81,9 @@ func Open(ctx context.Context, dsn string, opts ...PoolOption) (*pgxpool.Pool, e } } cfg.ConnConfig.Tracer = queryStatsTracer{} + if err := installPostgresConnectionAdmission(ctx, cfg, dsn); err != nil { + return nil, fmt.Errorf("initialize PostgreSQL connection admission: %w", err) + } pool, err := pgxpool.NewWithConfig(ctx, cfg) if err != nil { return nil, fmt.Errorf("pgxpool new: %w", err) @@ -142,6 +153,19 @@ func Migrate(dsn string) error { // MigrateAndStatus 用嵌入迁移脚本迁移数据库,并返回迁移后的 schema 版本。 func MigrateAndStatus(dsn string) (MigrationStatus, error) { + ctx := context.Background() + lockConn, err := pgx.Connect(ctx, dsn) + if err != nil { + return MigrationStatus{}, fmt.Errorf("connect migration lock: %w", err) + } + defer lockConn.Close(ctx) + if _, err := lockConn.Exec(ctx, `SELECT pg_advisory_lock($1)`, phoneIdentityMigrationLockKey); err != nil { + return MigrationStatus{}, fmt.Errorf("lock migrations: %w", err) + } + defer func() { + _, _ = lockConn.Exec(context.Background(), `SELECT pg_advisory_unlock($1)`, phoneIdentityMigrationLockKey) + }() + src, err := iofs.New(deploy.Migrations, "migrations") if err != nil { return MigrationStatus{}, fmt.Errorf("iofs source: %w", err) @@ -151,9 +175,37 @@ func MigrateAndStatus(dsn string) (MigrationStatus, error) { return MigrationStatus{}, fmt.Errorf("migrate new: %w", err) } defer m.Close() + status, err := migrationStatus(m) + if err != nil { + return MigrationStatus{}, err + } + if status.Dirty { + return MigrationStatus{}, fmt.Errorf("migrate version %d is dirty", status.Version) + } + if status.Empty || status.Version < phoneIdentityPredecessorVersion { + if err := m.Migrate(phoneIdentityPredecessorVersion); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return MigrationStatus{}, fmt.Errorf("migrate to phone identity predecessor: %w", err) + } + status, err = migrationStatus(m) + if err != nil { + return MigrationStatus{}, err + } + if status.Dirty || status.Empty || status.Version != phoneIdentityPredecessorVersion { + return MigrationStatus{}, fmt.Errorf("phone identity predecessor status = %+v", status) + } + } + if status.Version < phoneIdentityMigrationVersion { + if err := canonicalizeStoredPhoneIdentities(ctx, lockConn); err != nil { + return MigrationStatus{}, fmt.Errorf("migrate phone identities: %w", err) + } + } if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { return MigrationStatus{}, fmt.Errorf("migrate up: %w", err) } + return migrationStatus(m) +} + +func migrationStatus(m *migrate.Migrate) (MigrationStatus, error) { version, dirty, err := m.Version() if errors.Is(err, migrate.ErrNilVersion) { return MigrationStatus{Empty: true}, nil diff --git a/internal/store/postgres/postgres_connection_admission.go b/internal/store/postgres/postgres_connection_admission.go new file mode 100644 index 00000000..67485bfc --- /dev/null +++ b/internal/store/postgres/postgres_connection_admission.go @@ -0,0 +1,466 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "math" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +const ( + // A constructor first owns the reservation key, then the new PostgreSQL + // backend takes the owner key before the reservation is released. No + // backend is therefore opened unless one application slot is already + // fenced for it. The owner lock is session scoped and is released by + // PostgreSQL on clean close, process crash, or network loss. + postgresAdmissionLockClass int32 = 0x54454c45 // "TELE": live owner + postgresAdmissionReservationLockClass int32 = 0x54454c52 // "TELR": pre-connect reservation + minimumOperatorConnections = 8 + operatorConnectionReserveRatio = 0.05 + + postgresAdmissionConnectTimeout = 10 * time.Second + postgresAdmissionReservationSlack = 5 * time.Second + postgresAdmissionProbeBatch = 8 + postgresAdmissionProbeInterval = 25 * time.Millisecond + postgresAdmissionApplicationNamePrefix = "telesrv-admission/" + + // Admission slots are shared by every process that targets the same + // PostgreSQL server. Pools may retain their configured minimum, but burst + // connections must return idle slots promptly so another process can make + // progress without a per-role connection budget. + postgresAdmissionMaxConnIdleTime = 5 * time.Second + postgresAdmissionHealthCheckPeriod = time.Second +) + +type postgresConnectionCapacity struct { + maxConnections int + superuserReserved int + serverReserved int + operatorReserved int + applicationSlots int +} + +func calculatePostgresConnectionCapacity(maxConnections, superuserReserved, serverReserved int) (postgresConnectionCapacity, error) { + if maxConnections <= 0 || superuserReserved < 0 || serverReserved < 0 { + return postgresConnectionCapacity{}, errors.New("invalid PostgreSQL connection settings") + } + operatorReserved := max(minimumOperatorConnections, int(math.Ceil(float64(maxConnections)*operatorConnectionReserveRatio))) + applicationSlots := maxConnections - superuserReserved - serverReserved - operatorReserved + if applicationSlots <= 0 { + return postgresConnectionCapacity{}, fmt.Errorf( + "PostgreSQL has no application connection capacity: max=%d superuser_reserved=%d reserved=%d operator_reserved=%d", + maxConnections, superuserReserved, serverReserved, operatorReserved, + ) + } + return postgresConnectionCapacity{ + maxConnections: maxConnections, superuserReserved: superuserReserved, + serverReserved: serverReserved, operatorReserved: operatorReserved, + applicationSlots: applicationSlots, + }, nil +} + +type postgresAdmissionReservation struct { + token string + slot int32 + generation uint64 + timer *time.Timer +} + +type postgresAdmissionController struct { + dsn string + + mu sync.Mutex + conn *pgx.Conn + generation uint64 + capacity postgresConnectionCapacity + ownerSlot int32 + nextSlot int + pending map[string]*postgresAdmissionReservation + reserved map[int32]string + sequence atomic.Uint64 +} + +type postgresAdmissionControllerInit struct { + ready chan struct{} + controller *postgresAdmissionController + err error +} + +var postgresAdmissionControllers sync.Map + +func postgresAdmissionControllerForDSN(ctx context.Context, dsn string) (*postgresAdmissionController, error) { + key := strings.TrimSpace(dsn) + if key == "" { + return nil, errors.New("PostgreSQL DSN is required for connection admission") + } + created := &postgresAdmissionControllerInit{ready: make(chan struct{})} + actual, loaded := postgresAdmissionControllers.LoadOrStore(key, created) + entry := actual.(*postgresAdmissionControllerInit) + if !loaded { + entry.controller, entry.err = newPostgresAdmissionController(ctx, key) + close(entry.ready) + if entry.err != nil { + postgresAdmissionControllers.CompareAndDelete(key, entry) + } + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-entry.ready: + } + if entry.err != nil { + return nil, entry.err + } + return entry.controller, nil +} + +func newPostgresAdmissionController(ctx context.Context, dsn string) (*postgresAdmissionController, error) { + controller := &postgresAdmissionController{ + dsn: dsn, pending: make(map[string]*postgresAdmissionReservation), reserved: make(map[int32]string), + } + controller.mu.Lock() + err := controller.connectLocked(ctx) + controller.mu.Unlock() + if err != nil { + return nil, err + } + return controller, nil +} + +func (c *postgresAdmissionController) connectLocked(ctx context.Context) error { + if c.conn != nil && !c.conn.IsClosed() { + return nil + } + config, err := pgx.ParseConfig(c.dsn) + if err != nil { + return fmt.Errorf("parse PostgreSQL admission controller config: %w", err) + } + if config.ConnectTimeout <= 0 { + config.ConnectTimeout = postgresAdmissionConnectTimeout + } + config.RuntimeParams["application_name"] = "telesrv-admission-controller" + conn, err := pgx.ConnectConfig(ctx, config) + if err != nil { + return fmt.Errorf("connect PostgreSQL admission controller: %w", err) + } + var maxConnections, superuserReserved, serverReserved int + if err := conn.QueryRow(ctx, ` +SELECT current_setting('max_connections')::integer, + current_setting('superuser_reserved_connections')::integer, + COALESCE(NULLIF(current_setting('reserved_connections', true), ''), '0')::integer`).Scan( + &maxConnections, &superuserReserved, &serverReserved, + ); err != nil { + _ = conn.Close(context.Background()) + return fmt.Errorf("read PostgreSQL connection capacity: %w", err) + } + capacity, err := calculatePostgresConnectionCapacity(maxConnections, superuserReserved, serverReserved) + if err != nil { + _ = conn.Close(context.Background()) + return err + } + c.conn = conn + c.capacity = capacity + if err := c.claimControllerSlotLocked(ctx); err != nil { + _ = conn.Close(context.Background()) + c.conn = nil + return err + } + c.generation++ + return nil +} + +func (c *postgresAdmissionController) claimControllerSlotLocked(ctx context.Context) error { + for { + for offset := 0; offset < c.capacity.applicationSlots; offset++ { + slot := int32((c.nextSlot+offset)%c.capacity.applicationSlots + 1) + var reserved bool + if err := c.conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1::integer, $2::integer)`, postgresAdmissionReservationLockClass, slot).Scan(&reserved); err != nil { + return fmt.Errorf("reserve PostgreSQL admission controller slot %d: %w", slot, err) + } + if !reserved { + continue + } + var acquired bool + if err := c.conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1::integer, $2::integer)`, postgresAdmissionLockClass, slot).Scan(&acquired); err != nil { + return fmt.Errorf("claim PostgreSQL admission controller slot %d: %w", slot, err) + } + if !acquired { + var released bool + if err := c.conn.QueryRow(ctx, `SELECT pg_advisory_unlock($1::integer, $2::integer)`, postgresAdmissionReservationLockClass, slot).Scan(&released); err != nil { + return fmt.Errorf("release occupied PostgreSQL admission controller slot %d: %w", slot, err) + } + if !released { + return fmt.Errorf("PostgreSQL admission controller slot %d reservation was not held", slot) + } + continue + } + var released bool + if err := c.conn.QueryRow(ctx, `SELECT pg_advisory_unlock($1::integer, $2::integer)`, postgresAdmissionReservationLockClass, slot).Scan(&released); err != nil { + return fmt.Errorf("release PostgreSQL admission controller slot %d reservation: %w", slot, err) + } + if !released { + return fmt.Errorf("PostgreSQL admission controller slot %d reservation was not held", slot) + } + c.ownerSlot = slot + c.nextSlot = int(slot) % c.capacity.applicationSlots + return nil + } + if !sleepPostgresAdmission(ctx, postgresAdmissionProbeInterval) { + return ctx.Err() + } + } +} + +func (c *postgresAdmissionController) resetLocked() { + if c.conn != nil { + _ = c.conn.Close(context.Background()) + c.conn = nil + } + c.ownerSlot = 0 + c.generation++ + clear(c.reserved) +} + +func (c *postgresAdmissionController) reserve(ctx context.Context, ttl time.Duration) (string, error) { + if ttl <= 0 { + ttl = postgresAdmissionConnectTimeout + postgresAdmissionReservationSlack + } + for { + c.mu.Lock() + if err := c.connectLocked(ctx); err != nil { + c.mu.Unlock() + return "", err + } + probes := min(postgresAdmissionProbeBatch, c.capacity.applicationSlots) + for probe := 0; probe < probes; probe++ { + slot := int32(c.nextSlot%c.capacity.applicationSlots + 1) + c.nextSlot = (c.nextSlot + 1) % c.capacity.applicationSlots + if slot == c.ownerSlot || c.reserved[slot] != "" { + continue + } + free, err := c.reserveSlotLocked(ctx, slot) + if err != nil { + c.resetLocked() + c.mu.Unlock() + return "", err + } + if !free { + continue + } + token := fmt.Sprintf("%016x", c.sequence.Add(1)) + reservation := &postgresAdmissionReservation{token: token, slot: slot, generation: c.generation} + c.pending[token] = reservation + c.reserved[slot] = token + reservation.timer = time.AfterFunc(ttl, func() { c.abort(token) }) + c.mu.Unlock() + return token, nil + } + c.mu.Unlock() + if !sleepPostgresAdmission(ctx, postgresAdmissionProbeInterval) { + return "", ctx.Err() + } + } +} + +func (c *postgresAdmissionController) reserveSlotLocked(ctx context.Context, slot int32) (bool, error) { + var reserved bool + if err := c.conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1::integer, $2::integer)`, postgresAdmissionReservationLockClass, slot).Scan(&reserved); err != nil { + return false, fmt.Errorf("reserve PostgreSQL connection slot %d: %w", slot, err) + } + if !reserved { + return false, nil + } + var ownerFree bool + if err := c.conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1::integer, $2::integer)`, postgresAdmissionLockClass, slot).Scan(&ownerFree); err != nil { + return false, fmt.Errorf("probe PostgreSQL connection slot %d owner: %w", slot, err) + } + if ownerFree { + var unlocked bool + if err := c.conn.QueryRow(ctx, `SELECT pg_advisory_unlock($1::integer, $2::integer)`, postgresAdmissionLockClass, slot).Scan(&unlocked); err != nil { + return false, fmt.Errorf("release PostgreSQL connection slot %d owner probe: %w", slot, err) + } + if !unlocked { + return false, fmt.Errorf("PostgreSQL connection slot %d owner probe was not held", slot) + } + return true, nil + } + var released bool + if err := c.conn.QueryRow(ctx, `SELECT pg_advisory_unlock($1::integer, $2::integer)`, postgresAdmissionReservationLockClass, slot).Scan(&released); err != nil { + return false, fmt.Errorf("release occupied PostgreSQL connection slot %d reservation: %w", slot, err) + } + if !released { + return false, fmt.Errorf("PostgreSQL connection slot %d reservation was not held", slot) + } + return false, nil +} + +func (c *postgresAdmissionController) claim(ctx context.Context, token string, conn *pgx.Conn) error { + reservation, err := c.take(token) + if err != nil { + return err + } + var acquired bool + if err := conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1::integer, $2::integer)`, postgresAdmissionLockClass, reservation.slot).Scan(&acquired); err != nil { + _ = c.release(reservation) + return fmt.Errorf("claim PostgreSQL connection slot %d: %w", reservation.slot, err) + } + if !acquired { + _ = c.release(reservation) + return fmt.Errorf("claim PostgreSQL connection slot %d: reservation ownership was lost", reservation.slot) + } + if err := c.release(reservation); err != nil { + var ignored bool + _ = conn.QueryRow(context.Background(), `SELECT pg_advisory_unlock($1::integer, $2::integer)`, postgresAdmissionLockClass, reservation.slot).Scan(&ignored) + return err + } + return nil +} + +func (c *postgresAdmissionController) take(token string) (*postgresAdmissionReservation, error) { + c.mu.Lock() + defer c.mu.Unlock() + reservation := c.pending[token] + if reservation == nil { + return nil, fmt.Errorf("PostgreSQL connection reservation %q is absent or expired", token) + } + delete(c.pending, token) + if reservation.timer != nil { + reservation.timer.Stop() + } + return reservation, nil +} + +func (c *postgresAdmissionController) abort(token string) { + c.mu.Lock() + reservation := c.pending[token] + if reservation != nil { + delete(c.pending, token) + } + c.mu.Unlock() + if reservation != nil { + _ = c.release(reservation) + } +} + +func (c *postgresAdmissionController) release(reservation *postgresAdmissionReservation) error { + if reservation == nil { + return nil + } + c.mu.Lock() + defer c.mu.Unlock() + if c.reserved[reservation.slot] == reservation.token { + delete(c.reserved, reservation.slot) + } + if reservation.generation != c.generation || c.conn == nil || c.conn.IsClosed() { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var released bool + if err := c.conn.QueryRow(ctx, `SELECT pg_advisory_unlock($1::integer, $2::integer)`, postgresAdmissionReservationLockClass, reservation.slot).Scan(&released); err != nil { + c.resetLocked() + return fmt.Errorf("release PostgreSQL connection slot %d reservation: %w", reservation.slot, err) + } + if !released { + return fmt.Errorf("PostgreSQL connection slot %d reservation was not held", reservation.slot) + } + return nil +} + +func installPostgresConnectionAdmission(ctx context.Context, cfg *pgxpool.Config, dsn string) error { + controller, err := postgresAdmissionControllerForDSN(ctx, dsn) + if err != nil { + return err + } + boundPostgresAdmissionPoolIdle(cfg) + if cfg.ConnConfig.ConnectTimeout <= 0 { + cfg.ConnConfig.ConnectTimeout = postgresAdmissionConnectTimeout + } + reservationTTL := cfg.ConnConfig.ConnectTimeout + postgresAdmissionReservationSlack + previousBefore := cfg.BeforeConnect + cfg.BeforeConnect = func(ctx context.Context, connConfig *pgx.ConnConfig) error { + if previousBefore != nil { + if err := previousBefore(ctx, connConfig); err != nil { + return err + } + } + token, err := controller.reserve(ctx, reservationTTL) + if err != nil { + return err + } + connConfig.RuntimeParams["application_name"] = postgresAdmissionApplicationNamePrefix + token + return nil + } + previousAfter := cfg.AfterConnect + cfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error { + applicationName := conn.Config().RuntimeParams["application_name"] + token, ok := strings.CutPrefix(applicationName, postgresAdmissionApplicationNamePrefix) + if !ok || token == "" { + return fmt.Errorf("PostgreSQL connection has no admission reservation identity") + } + if err := controller.claim(ctx, token, conn); err != nil { + return err + } + if previousAfter != nil { + return previousAfter(ctx, conn) + } + return nil + } + return nil +} + +func boundPostgresAdmissionPoolIdle(cfg *pgxpool.Config) { + if cfg.MaxConnIdleTime <= 0 || cfg.MaxConnIdleTime > postgresAdmissionMaxConnIdleTime { + cfg.MaxConnIdleTime = postgresAdmissionMaxConnIdleTime + } + if cfg.HealthCheckPeriod <= 0 || cfg.HealthCheckPeriod > postgresAdmissionHealthCheckPeriod { + cfg.HealthCheckPeriod = postgresAdmissionHealthCheckPeriod + } +} + +func connectPostgresAdmitted(ctx context.Context, dsn string) (*pgx.Conn, error) { + config, err := pgx.ParseConfig(dsn) + if err != nil { + return nil, fmt.Errorf("parse admitted PostgreSQL connection config: %w", err) + } + controller, err := postgresAdmissionControllerForDSN(ctx, dsn) + if err != nil { + return nil, err + } + if config.ConnectTimeout <= 0 { + config.ConnectTimeout = postgresAdmissionConnectTimeout + } + token, err := controller.reserve(ctx, config.ConnectTimeout+postgresAdmissionReservationSlack) + if err != nil { + return nil, err + } + config.RuntimeParams["application_name"] = postgresAdmissionApplicationNamePrefix + token + conn, err := pgx.ConnectConfig(ctx, config) + if err != nil { + controller.abort(token) + return nil, fmt.Errorf("connect admitted PostgreSQL session: %w", err) + } + if err := controller.claim(ctx, token, conn); err != nil { + _ = conn.Close(context.Background()) + return nil, err + } + return conn, nil +} + +func sleepPostgresAdmission(ctx context.Context, delay time.Duration) bool { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} diff --git a/internal/store/postgres/postgres_connection_admission_test.go b/internal/store/postgres/postgres_connection_admission_test.go new file mode 100644 index 00000000..9f00dec3 --- /dev/null +++ b/internal/store/postgres/postgres_connection_admission_test.go @@ -0,0 +1,258 @@ +package postgres + +import ( + "context" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestCalculatePostgresConnectionCapacity(t *testing.T) { + tests := []struct { + name string + max, superuser, reserved, slots int + wantErr bool + }{ + {name: "postgres17-default-shape", max: 100, superuser: 3, reserved: 0, slots: 89}, + {name: "server-reserved", max: 100, superuser: 3, reserved: 2, slots: 87}, + {name: "minimum-operator-reserve", max: 20, superuser: 3, reserved: 0, slots: 9}, + {name: "percentage-operator-reserve", max: 1000, superuser: 3, reserved: 0, slots: 947}, + {name: "no-application-capacity", max: 5, superuser: 3, reserved: 0, wantErr: true}, + {name: "invalid-settings", max: 100, superuser: -1, reserved: 0, wantErr: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := calculatePostgresConnectionCapacity(test.max, test.superuser, test.reserved) + if test.wantErr { + if err == nil { + t.Fatalf("capacity = %+v, want error", got) + } + return + } + if err != nil { + t.Fatalf("calculate capacity: %v", err) + } + if got.applicationSlots != test.slots || got.maxConnections != test.max || + got.superuserReserved != test.superuser || got.serverReserved != test.reserved { + t.Fatalf("capacity = %+v, want slots=%d", got, test.slots) + } + }) + } +} + +func TestBoundPostgresAdmissionPoolIdle(t *testing.T) { + tests := []struct { + name string + idle time.Duration + health time.Duration + wantIdle time.Duration + wantHealth time.Duration + }{ + { + name: "bound defaults", + idle: 30 * time.Minute, health: time.Minute, + wantIdle: postgresAdmissionMaxConnIdleTime, wantHealth: postgresAdmissionHealthCheckPeriod, + }, + { + name: "replace invalid zero values", + wantIdle: postgresAdmissionMaxConnIdleTime, wantHealth: postgresAdmissionHealthCheckPeriod, + }, + { + name: "preserve tighter settings", + idle: time.Second, health: 500 * time.Millisecond, + wantIdle: time.Second, wantHealth: 500 * time.Millisecond, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cfg := &pgxpool.Config{MaxConnIdleTime: test.idle, HealthCheckPeriod: test.health} + boundPostgresAdmissionPoolIdle(cfg) + if cfg.MaxConnIdleTime != test.wantIdle || cfg.HealthCheckPeriod != test.wantHealth { + t.Fatalf("idle/health = %s/%s, want %s/%s", cfg.MaxConnIdleTime, cfg.HealthCheckPeriod, test.wantIdle, test.wantHealth) + } + }) + } +} + +func TestPostgresAdmissionReservesCapacityBeforeOpeningBackend(t *testing.T) { + dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("set TELESRV_TEST_POSTGRES_DSN to run postgres integration test") + } + ctx := context.Background() + controller, err := postgresAdmissionControllerForDSN(ctx, dsn) + if err != nil { + t.Fatalf("open admission controller: %v", err) + } + observer, err := pgx.Connect(ctx, dsn) + if err != nil { + t.Fatalf("open observer: %v", err) + } + defer observer.Close(ctx) + + controller.mu.Lock() + slots := controller.capacity.applicationSlots + controller.mu.Unlock() + available := slots - 1 // The controller's own backend is an admitted owner. + var beforeBackends int + if err := observer.QueryRow(ctx, `SELECT count(*) FROM pg_stat_activity WHERE datname = current_database()`).Scan(&beforeBackends); err != nil { + t.Fatalf("count initial backends: %v", err) + } + tokens := make([]string, 0, available) + defer func() { + for _, token := range tokens { + controller.abort(token) + } + }() + for len(tokens) < available { + token, err := controller.reserve(ctx, time.Minute) + if err != nil { + t.Fatalf("reserve slot %d/%d: %v", len(tokens)+1, available, err) + } + tokens = append(tokens, token) + } + + waitCtx, cancel := context.WithTimeout(ctx, 100*time.Millisecond) + defer cancel() + if token, err := controller.reserve(waitCtx, time.Minute); err == nil { + controller.abort(token) + t.Fatal("capacity-exhausted reservation unexpectedly succeeded") + } + var afterBackends, reservations, owners int + if err := observer.QueryRow(ctx, ` +SELECT + (SELECT count(*) FROM pg_stat_activity WHERE datname = current_database()), + (SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND classid = $1::oid AND granted), + (SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND classid = $2::oid AND granted)`, + uint32(postgresAdmissionReservationLockClass), uint32(postgresAdmissionLockClass), + ).Scan(&afterBackends, &reservations, &owners); err != nil { + t.Fatalf("inspect reserved capacity: %v", err) + } + if afterBackends != beforeBackends { + t.Fatalf("capacity wait opened PostgreSQL backends: before=%d after=%d", beforeBackends, afterBackends) + } + if reservations != available || owners != 1 { + t.Fatalf("reservation/owner locks = %d/%d, want %d/1", reservations, owners, available) + } +} + +func TestPostgresPoolConnectionsOwnDistinctAdmissionSlots(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + first, err := pool.Acquire(ctx) + if err != nil { + t.Fatalf("acquire first pool connection: %v", err) + } + defer first.Release() + second, err := pool.Acquire(ctx) + if err != nil { + t.Fatalf("acquire second pool connection: %v", err) + } + defer second.Release() + + admissionSlot := func(label string, conn *pgxpool.Conn) int64 { + t.Helper() + var count int + var slot int64 + if err := conn.QueryRow(ctx, ` +SELECT count(*), min(objid::bigint) +FROM pg_locks +WHERE pid = pg_backend_pid() + AND locktype = 'advisory' + AND classid = $1::oid + AND granted`, uint32(postgresAdmissionLockClass)).Scan(&count, &slot); err != nil { + t.Fatalf("load %s admission slot: %v", label, err) + } + if count != 1 { + t.Fatalf("%s connection owns %d admission slots, want 1", label, count) + } + return slot + } + firstSlot := admissionSlot("first", first) + secondSlot := admissionSlot("second", second) + if firstSlot <= 0 || secondSlot <= 0 || firstSlot == secondSlot { + t.Fatalf("admission slots = %d/%d, want distinct positive slots", firstSlot, secondSlot) + } +} + +func TestPostgresAdmissionSlotReleasesWithPoolConnection(t *testing.T) { + dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("set TELESRV_TEST_POSTGRES_DSN to run postgres integration test") + } + observer := testPool(t) + ctx := context.Background() + pool, err := Open(ctx, dsn, WithMaxConns(1), WithMinConns(1)) + if err != nil { + t.Fatalf("open single-connection pool: %v", err) + } + conn, err := pool.Acquire(ctx) + if err != nil { + pool.Close() + t.Fatalf("acquire single-connection pool: %v", err) + } + var backendPID int + if err := conn.QueryRow(ctx, `SELECT pg_backend_pid()`).Scan(&backendPID); err != nil { + conn.Release() + pool.Close() + t.Fatalf("load admitted backend pid: %v", err) + } + conn.Release() + pool.Close() + + var retained int + if err := observer.QueryRow(ctx, ` +SELECT count(*) +FROM pg_locks +WHERE pid = $1 AND locktype = 'advisory' AND classid = $2::oid`, backendPID, uint32(postgresAdmissionLockClass)).Scan(&retained); err != nil { + t.Fatalf("inspect released admission slot: %v", err) + } + if retained != 0 { + t.Fatalf("closed backend retained %d admission locks", retained) + } +} + +func TestPostgresPoolReturnsBurstAdmissionSlotsAfterIdle(t *testing.T) { + dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("set TELESRV_TEST_POSTGRES_DSN to run postgres integration test") + } + ctx := context.Background() + pool, err := Open(ctx, dsn, WithMaxConns(3), WithMinConns(1)) + if err != nil { + t.Fatalf("open elastic pool: %v", err) + } + defer pool.Close() + + connections := make([]*pgxpool.Conn, 0, 3) + for len(connections) < cap(connections) { + conn, err := pool.Acquire(ctx) + if err != nil { + t.Fatalf("acquire burst connection %d: %v", len(connections)+1, err) + } + connections = append(connections, conn) + } + for _, conn := range connections { + conn.Release() + } + if got := pool.Stat().TotalConns(); got != 3 { + t.Fatalf("burst pool total connections = %d, want 3", got) + } + + deadline := time.Now().Add(postgresAdmissionMaxConnIdleTime + 3*postgresAdmissionHealthCheckPeriod) + for time.Now().Before(deadline) { + stat := pool.Stat() + if stat.TotalConns() == 1 && stat.IdleConns() == 1 && stat.MaxIdleDestroyCount() >= 2 { + return + } + time.Sleep(100 * time.Millisecond) + } + stat := pool.Stat() + t.Fatalf( + "burst connections were not returned: total=%d idle=%d idle_destroyed=%d", + stat.TotalConns(), stat.IdleConns(), stat.MaxIdleDestroyCount(), + ) +} diff --git a/internal/store/postgres/queries/dialog.sql b/internal/store/postgres/queries/dialog.sql index fa39af80..8470b3d3 100644 --- a/internal/store/postgres/queries/dialog.sql +++ b/internal/store/postgres/queries/dialog.sql @@ -58,6 +58,7 @@ WITH base AS ( COALESCE(m.quote_text, '')::text AS message_quote_text, COALESCE(m.quote_entities::text, '[]')::text AS message_quote_entities_json, COALESCE(m.quote_offset, 0)::int AS message_quote_offset, + COALESCE(m.reply_external, '{}'::jsonb)::text AS message_reply_external_json, COALESCE(m.fwd_from_peer_type, '')::text AS message_fwd_from_peer_type, COALESCE(m.fwd_from_peer_id, 0)::bigint AS message_fwd_from_peer_id, COALESCE(m.fwd_from_name, '')::text AS message_fwd_from_name, @@ -224,6 +225,7 @@ SELECT message_quote_text, message_quote_entities_json, message_quote_offset, + message_reply_external_json, message_fwd_from_peer_type, message_fwd_from_peer_id, message_fwd_from_name, @@ -401,6 +403,7 @@ base AS ( COALESCE(m.quote_text, '')::text AS message_quote_text, COALESCE(m.quote_entities::text, '[]')::text AS message_quote_entities_json, COALESCE(m.quote_offset, 0)::int AS message_quote_offset, + COALESCE(m.reply_external, '{}'::jsonb)::text AS message_reply_external_json, COALESCE(m.fwd_from_peer_type, '')::text AS message_fwd_from_peer_type, COALESCE(m.fwd_from_peer_id, 0)::bigint AS message_fwd_from_peer_id, COALESCE(m.fwd_from_name, '')::text AS message_fwd_from_name, @@ -486,6 +489,7 @@ SELECT message_quote_text, message_quote_entities_json, message_quote_offset, + message_reply_external_json, message_fwd_from_peer_type, message_fwd_from_peer_id, message_fwd_from_name, diff --git a/internal/store/postgres/queries/message.sql b/internal/store/postgres/queries/message.sql index d17165c1..e7b11123 100644 --- a/internal/store/postgres/queries/message.sql +++ b/internal/store/postgres/queries/message.sql @@ -115,6 +115,7 @@ INSERT INTO private_messages ( quote_text, quote_entities, quote_offset, + reply_external, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, @@ -139,6 +140,7 @@ INSERT INTO private_messages ( sqlc.arg(quote_text)::text, sqlc.arg(quote_entities_json)::jsonb, sqlc.arg(quote_offset)::int, + COALESCE(sqlc.arg(reply_external_json)::jsonb, '{}'::jsonb), sqlc.arg(fwd_from_peer_type)::text, sqlc.arg(fwd_from_peer_id)::bigint, sqlc.arg(fwd_from_name)::text, @@ -216,6 +218,7 @@ INSERT INTO message_boxes ( quote_text, quote_entities, quote_offset, + reply_external, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, @@ -249,6 +252,7 @@ INSERT INTO message_boxes ( sqlc.arg(quote_text)::text, sqlc.arg(quote_entities_json)::jsonb, sqlc.arg(quote_offset)::int, + COALESCE(sqlc.arg(reply_external_json)::jsonb, '{}'::jsonb), sqlc.arg(fwd_from_peer_type)::text, sqlc.arg(fwd_from_peer_id)::bigint, sqlc.arg(fwd_from_name)::text, @@ -293,6 +297,7 @@ RETURNING quote_text, quote_entities::text AS quote_entities_json, quote_offset, + reply_external::text AS reply_external_json, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, @@ -339,6 +344,7 @@ SELECT quote_text, quote_entities::text AS quote_entities_json, quote_offset, + reply_external::text AS reply_external_json, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, @@ -367,7 +373,9 @@ WHERE owner_user_id = $1 SELECT box_id, private_message_id, - message_sender_id + message_sender_id, + from_user_id, message_date, body, entities::text AS entities_json, + media::text AS media_json, noforwards FROM message_boxes WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint AND peer_type = sqlc.arg(peer_type)::text @@ -410,6 +418,7 @@ SELECT m.quote_text, m.quote_entities::text AS quote_entities_json, m.quote_offset, + m.reply_external::text AS reply_external_json, m.fwd_from_peer_type, m.fwd_from_peer_id, m.fwd_from_name, @@ -479,6 +488,7 @@ base AS NOT MATERIALIZED ( m.quote_text, m.quote_entities::text AS quote_entities_json, m.quote_offset, + m.reply_external::text AS reply_external_json, m.fwd_from_peer_type, m.fwd_from_peer_id, m.fwd_from_name, @@ -533,6 +543,7 @@ base AS NOT MATERIALIZED ( LEFT JOIN users from_u ON from_u.id = m.from_user_id WHERE m.owner_user_id = $1 AND NOT m.deleted + AND (sqlc.arg(sender_user_id)::bigint = 0 OR m.from_user_id = sqlc.arg(sender_user_id)::bigint) AND ( NOT sqlc.arg(has_peer)::boolean OR (m.peer_type = sqlc.arg(peer_type)::text AND m.peer_id = sqlc.arg(peer_id)::bigint) @@ -590,11 +601,6 @@ base AS NOT MATERIALIZED ( ) ) ), -total AS ( - SELECT count(*)::int AS total_count - FROM base - WHERE sqlc.arg(need_total_count)::boolean -), backward AS ( SELECT b.* FROM base b @@ -617,7 +623,7 @@ around_forward AS ( WHERE p.load_type = 'around' AND ( (p.offset_date > 0 AND b.message_date >= p.offset_date) - OR (p.offset_date <= 0 AND p.offset_id > 0 AND b.box_id > p.offset_id) + OR (p.offset_date <= 0 AND p.offset_id > 0 AND b.box_id >= p.offset_id) ) ORDER BY b.box_id ASC LIMIT LEAST(-(SELECT add_offset FROM load_params), (SELECT limit_count FROM load_params)) @@ -630,7 +636,7 @@ around_backward AS ( WHERE p.load_type = 'around' AND ( (p.offset_date > 0 AND b.message_date < p.offset_date) - OR (p.offset_date <= 0 AND (p.offset_id <= 0 OR b.box_id <= p.offset_id)) + OR (p.offset_date <= 0 AND (p.offset_id <= 0 OR b.box_id < p.offset_id)) ) ORDER BY b.box_id DESC LIMIT GREATEST((SELECT limit_count + add_offset FROM load_params), 0) @@ -644,9 +650,10 @@ forward AS ( WHERE p.load_type = 'forward' AND ( (p.offset_date > 0 AND b.message_date >= p.offset_date) - OR (p.offset_date <= 0 AND p.offset_id > 0 AND b.box_id > p.offset_id) + OR (p.offset_date <= 0 AND p.offset_id > 0 AND b.box_id >= p.offset_id) ) ORDER BY b.box_id ASC + OFFSET GREATEST((SELECT -add_offset - limit_count FROM load_params), 0) LIMIT (SELECT limit_count FROM load_params) ) f ), @@ -684,6 +691,7 @@ SELECT quote_text, quote_entities_json, quote_offset, + reply_external_json, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, @@ -732,17 +740,15 @@ SELECT from_user_premium_until, from_user_emoji_status_document_id, from_user_emoji_status_until, - from_user_last_seen_at, - COALESCE(total.total_count, 0)::int AS total_count + from_user_last_seen_at FROM paged -CROSS JOIN total ORDER BY box_id DESC; -- name: ListMessagesBackward :many -- backward 热路径(add_offset>=0:初始加载/上滑翻页)的扁平静态查询。 -- 与 ListMessagesByUser 的 backward 分支逐位等价(相同 base 过滤 + 相同 anchor + -- ORDER BY box_id DESC + OFFSET GREATEST(add_offset,0) + LIMIT),但只规划单次 --- index scan + 2 LEFT JOIN,避免大 CTE 把 4 个分支+total 全树规划。total 走 +-- index scan + 2 LEFT JOIN,避免大 CTE 把 4 个分支全树规划。total 走 -- 独立 CountMessagesByUser,仅 NeedTotalCount 时发。 SELECT m.box_id, @@ -769,6 +775,7 @@ SELECT m.quote_text, m.quote_entities::text AS quote_entities_json, m.quote_offset, + m.reply_external::text AS reply_external_json, m.fwd_from_peer_type, m.fwd_from_peer_id, m.fwd_from_name, @@ -823,6 +830,7 @@ LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id LEFT JOIN users from_u ON from_u.id = m.from_user_id WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint AND NOT m.deleted + AND (sqlc.arg(sender_user_id)::bigint = 0 OR m.from_user_id = sqlc.arg(sender_user_id)::bigint) AND ( NOT sqlc.arg(has_peer)::boolean OR (m.peer_type = sqlc.arg(peer_type)::text AND m.peer_id = sqlc.arg(peer_id)::bigint) @@ -894,6 +902,7 @@ SELECT count(*)::int AS total_count FROM message_boxes m WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint AND NOT m.deleted + AND (sqlc.arg(sender_user_id)::bigint = 0 OR m.from_user_id = sqlc.arg(sender_user_id)::bigint) AND ( NOT sqlc.arg(has_peer)::boolean OR (m.peer_type = sqlc.arg(peer_type)::text AND m.peer_id = sqlc.arg(peer_id)::bigint) @@ -978,6 +987,7 @@ SELECT m.quote_text, m.quote_entities::text AS quote_entities_json, m.quote_offset, + m.reply_external::text AS reply_external_json, m.fwd_from_peer_type, m.fwd_from_peer_id, m.fwd_from_name, @@ -1063,6 +1073,7 @@ SELECT quote_text, quote_entities::text AS quote_entities_json, quote_offset, + reply_external::text AS reply_external_json, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, @@ -1118,6 +1129,7 @@ SELECT quote_text, quote_entities::text AS quote_entities_json, quote_offset, + reply_external::text AS reply_external_json, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, @@ -1206,6 +1218,7 @@ RETURNING quote_text, quote_entities::text AS quote_entities_json, quote_offset, + reply_external::text AS reply_external_json, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, @@ -1507,6 +1520,7 @@ SELECT quote_text, quote_entities::text AS quote_entities_json, quote_offset, + reply_external::text AS reply_external_json, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, diff --git a/internal/store/postgres/queries/saved_dialog.sql b/internal/store/postgres/queries/saved_dialog.sql index 004ccbf4..4c646c2d 100644 --- a/internal/store/postgres/queries/saved_dialog.sql +++ b/internal/store/postgres/queries/saved_dialog.sql @@ -42,6 +42,7 @@ SELECT m.quote_text, m.quote_entities::text AS quote_entities_json, m.quote_offset, + m.reply_external::text AS reply_external_json, m.fwd_from_peer_type, m.fwd_from_peer_id, m.fwd_from_name, @@ -113,6 +114,7 @@ SELECT m.quote_text, m.quote_entities::text AS quote_entities_json, m.quote_offset, + m.reply_external::text AS reply_external_json, m.fwd_from_peer_type, m.fwd_from_peer_id, m.fwd_from_name, @@ -190,6 +192,7 @@ SELECT m.quote_text, m.quote_entities::text AS quote_entities_json, m.quote_offset, + m.reply_external::text AS reply_external_json, m.fwd_from_peer_type, m.fwd_from_peer_id, m.fwd_from_name, diff --git a/internal/store/postgres/queries/user_update_event.sql b/internal/store/postgres/queries/user_update_event.sql index 1df6a636..d942fb05 100644 --- a/internal/store/postgres/queries/user_update_event.sql +++ b/internal/store/postgres/queries/user_update_event.sql @@ -103,6 +103,7 @@ SELECT COALESCE(m.quote_text, '')::text AS quote_text, COALESCE(m.quote_entities::text, '[]')::text AS quote_entities_json, COALESCE(m.quote_offset, 0)::int AS quote_offset, + COALESCE(m.reply_external, '{}'::jsonb)::text AS reply_external_json, COALESCE(m.fwd_from_peer_type, '')::text AS fwd_from_peer_type, COALESCE(m.fwd_from_peer_id, 0)::bigint AS fwd_from_peer_id, COALESCE(m.fwd_from_name, '')::text AS fwd_from_name, @@ -378,6 +379,7 @@ SELECT COALESCE(m.quote_text, '')::text AS quote_text, COALESCE(m.quote_entities::text, '[]')::text AS quote_entities_json, COALESCE(m.quote_offset, 0)::int AS quote_offset, + COALESCE(m.reply_external, '{}'::jsonb)::text AS reply_external_json, COALESCE(m.fwd_from_peer_type, '')::text AS fwd_from_peer_type, COALESCE(m.fwd_from_peer_id, 0)::bigint AS fwd_from_peer_id, COALESCE(m.fwd_from_name, '')::text AS fwd_from_name, diff --git a/internal/store/postgres/read_model_listener.go b/internal/store/postgres/read_model_listener.go index 0516c39d..bb325a6f 100644 --- a/internal/store/postgres/read_model_listener.go +++ b/internal/store/postgres/read_model_listener.go @@ -5,7 +5,6 @@ import ( "encoding/json" "time" - "github.com/jackc/pgx/v5" "go.uber.org/zap" "telesrv/internal/domain" @@ -227,7 +226,7 @@ func (l *ReadModelChangeListener) Run(ctx context.Context) { } func (l *ReadModelChangeListener) listenAndConsume(ctx context.Context) error { - conn, err := pgx.Connect(ctx, l.dsn) + conn, err := connectPostgresAdmitted(ctx, l.dsn) if err != nil { return err } diff --git a/internal/store/postgres/saved_dialog.go b/internal/store/postgres/saved_dialog.go index 07a9fddd..9c95e6dc 100644 --- a/internal/store/postgres/saved_dialog.go +++ b/internal/store/postgres/saved_dialog.go @@ -377,6 +377,7 @@ type savedDialogTopRowFields struct { QuoteText string QuoteEntitiesJson string QuoteOffset int32 + ReplyExternalJson string FwdFromPeerType string FwdFromPeerID int64 FwdFromName string @@ -401,11 +402,11 @@ type savedDialogTopRowFields struct { func savedDialogRowFields[T sqlcgen.ListSavedDialogTopsRow | sqlcgen.ListPinnedSavedDialogTopsRow | sqlcgen.ListSavedDialogTopsByPeersRow](row T) savedDialogTopRowFields { switch r := any(row).(type) { case sqlcgen.ListSavedDialogTopsRow: - return savedDialogTopRowFields{r.BoxID, r.PrivateMessageID, r.OwnerUserID, r.PeerType, r.PeerID, r.FromUserID, r.MessageDate, r.TtlPeriod, r.ExpiresAt, r.EditDate, r.HideEdited, r.Outgoing, r.Body, r.EntitiesJson, r.Silent, r.Noforwards, r.ReplyToMsgID, r.ReplyToPeerType, r.ReplyToPeerID, r.ReplyToTopID, r.ReplyToStoryID, r.QuoteText, r.QuoteEntitiesJson, r.QuoteOffset, r.FwdFromPeerType, r.FwdFromPeerID, r.FwdFromName, r.FwdDate, r.FwdSavedFromPeerType, r.FwdSavedFromPeerID, r.FwdSavedFromMsgID, r.SavedPeerType, r.SavedPeerID, r.Pts, r.MediaJson, r.MediaUnread, r.ReactionUnread, r.ViaBotID, r.GroupedID, r.Effect, r.ReplyMarkupJson, r.RichMessageJson, r.Pinned} + return savedDialogTopRowFields{r.BoxID, r.PrivateMessageID, r.OwnerUserID, r.PeerType, r.PeerID, r.FromUserID, r.MessageDate, r.TtlPeriod, r.ExpiresAt, r.EditDate, r.HideEdited, r.Outgoing, r.Body, r.EntitiesJson, r.Silent, r.Noforwards, r.ReplyToMsgID, r.ReplyToPeerType, r.ReplyToPeerID, r.ReplyToTopID, r.ReplyToStoryID, r.QuoteText, r.QuoteEntitiesJson, r.QuoteOffset, r.ReplyExternalJson, r.FwdFromPeerType, r.FwdFromPeerID, r.FwdFromName, r.FwdDate, r.FwdSavedFromPeerType, r.FwdSavedFromPeerID, r.FwdSavedFromMsgID, r.SavedPeerType, r.SavedPeerID, r.Pts, r.MediaJson, r.MediaUnread, r.ReactionUnread, r.ViaBotID, r.GroupedID, r.Effect, r.ReplyMarkupJson, r.RichMessageJson, r.Pinned} case sqlcgen.ListPinnedSavedDialogTopsRow: - return savedDialogTopRowFields{r.BoxID, r.PrivateMessageID, r.OwnerUserID, r.PeerType, r.PeerID, r.FromUserID, r.MessageDate, r.TtlPeriod, r.ExpiresAt, r.EditDate, r.HideEdited, r.Outgoing, r.Body, r.EntitiesJson, r.Silent, r.Noforwards, r.ReplyToMsgID, r.ReplyToPeerType, r.ReplyToPeerID, r.ReplyToTopID, r.ReplyToStoryID, r.QuoteText, r.QuoteEntitiesJson, r.QuoteOffset, r.FwdFromPeerType, r.FwdFromPeerID, r.FwdFromName, r.FwdDate, r.FwdSavedFromPeerType, r.FwdSavedFromPeerID, r.FwdSavedFromMsgID, r.SavedPeerType, r.SavedPeerID, r.Pts, r.MediaJson, r.MediaUnread, r.ReactionUnread, r.ViaBotID, r.GroupedID, r.Effect, r.ReplyMarkupJson, r.RichMessageJson, r.Pinned} + return savedDialogTopRowFields{r.BoxID, r.PrivateMessageID, r.OwnerUserID, r.PeerType, r.PeerID, r.FromUserID, r.MessageDate, r.TtlPeriod, r.ExpiresAt, r.EditDate, r.HideEdited, r.Outgoing, r.Body, r.EntitiesJson, r.Silent, r.Noforwards, r.ReplyToMsgID, r.ReplyToPeerType, r.ReplyToPeerID, r.ReplyToTopID, r.ReplyToStoryID, r.QuoteText, r.QuoteEntitiesJson, r.QuoteOffset, r.ReplyExternalJson, r.FwdFromPeerType, r.FwdFromPeerID, r.FwdFromName, r.FwdDate, r.FwdSavedFromPeerType, r.FwdSavedFromPeerID, r.FwdSavedFromMsgID, r.SavedPeerType, r.SavedPeerID, r.Pts, r.MediaJson, r.MediaUnread, r.ReactionUnread, r.ViaBotID, r.GroupedID, r.Effect, r.ReplyMarkupJson, r.RichMessageJson, r.Pinned} case sqlcgen.ListSavedDialogTopsByPeersRow: - return savedDialogTopRowFields{r.BoxID, r.PrivateMessageID, r.OwnerUserID, r.PeerType, r.PeerID, r.FromUserID, r.MessageDate, r.TtlPeriod, r.ExpiresAt, r.EditDate, r.HideEdited, r.Outgoing, r.Body, r.EntitiesJson, r.Silent, r.Noforwards, r.ReplyToMsgID, r.ReplyToPeerType, r.ReplyToPeerID, r.ReplyToTopID, r.ReplyToStoryID, r.QuoteText, r.QuoteEntitiesJson, r.QuoteOffset, r.FwdFromPeerType, r.FwdFromPeerID, r.FwdFromName, r.FwdDate, r.FwdSavedFromPeerType, r.FwdSavedFromPeerID, r.FwdSavedFromMsgID, r.SavedPeerType, r.SavedPeerID, r.Pts, r.MediaJson, r.MediaUnread, r.ReactionUnread, r.ViaBotID, r.GroupedID, r.Effect, r.ReplyMarkupJson, r.RichMessageJson, r.Pinned} + return savedDialogTopRowFields{r.BoxID, r.PrivateMessageID, r.OwnerUserID, r.PeerType, r.PeerID, r.FromUserID, r.MessageDate, r.TtlPeriod, r.ExpiresAt, r.EditDate, r.HideEdited, r.Outgoing, r.Body, r.EntitiesJson, r.Silent, r.Noforwards, r.ReplyToMsgID, r.ReplyToPeerType, r.ReplyToPeerID, r.ReplyToTopID, r.ReplyToStoryID, r.QuoteText, r.QuoteEntitiesJson, r.QuoteOffset, r.ReplyExternalJson, r.FwdFromPeerType, r.FwdFromPeerID, r.FwdFromName, r.FwdDate, r.FwdSavedFromPeerType, r.FwdSavedFromPeerID, r.FwdSavedFromMsgID, r.SavedPeerType, r.SavedPeerID, r.Pts, r.MediaJson, r.MediaUnread, r.ReactionUnread, r.ViaBotID, r.GroupedID, r.Effect, r.ReplyMarkupJson, r.RichMessageJson, r.Pinned} } return savedDialogTopRowFields{} } @@ -426,6 +427,7 @@ func messageFromSavedDialogRow(row savedDialogTopRowFields) (domain.Message, err row.QuoteText, row.QuoteEntitiesJson, row.QuoteOffset, + row.ReplyExternalJson, row.FwdFromPeerType, row.FwdFromPeerID, row.FwdFromName, diff --git a/internal/store/postgres/scheduled_message.go b/internal/store/postgres/scheduled_message.go index 64e7dec3..87310131 100644 --- a/internal/store/postgres/scheduled_message.go +++ b/internal/store/postgres/scheduled_message.go @@ -578,7 +578,7 @@ func scanScheduledMessage(scanner interface{ Scan(...any) error }) (domain.Sched _, _, reply, forward, err := messageMetadataFromFields( msg.Silent, msg.NoForwards, replyToMsgID, replyToPeerType, replyToPeerID, replyToTopID, 0, // scheduled_messages 不持久化 story 回复(定时回复 story 是边角,恒 0) - quoteText, quoteEntitiesJSON, quoteOffset, + quoteText, quoteEntitiesJSON, quoteOffset, "{}", fwdFromPeerType, fwdFromPeerID, fwdFromName, fwdDate, "", 0, 0, ) diff --git a/internal/store/postgres/sqlcgen/dialog.sql.go b/internal/store/postgres/sqlcgen/dialog.sql.go index b2d6078b..4d3dcff7 100644 --- a/internal/store/postgres/sqlcgen/dialog.sql.go +++ b/internal/store/postgres/sqlcgen/dialog.sql.go @@ -727,6 +727,7 @@ base AS ( COALESCE(m.quote_text, '')::text AS message_quote_text, COALESCE(m.quote_entities::text, '[]')::text AS message_quote_entities_json, COALESCE(m.quote_offset, 0)::int AS message_quote_offset, + COALESCE(m.reply_external, '{}'::jsonb)::text AS message_reply_external_json, COALESCE(m.fwd_from_peer_type, '')::text AS message_fwd_from_peer_type, COALESCE(m.fwd_from_peer_id, 0)::bigint AS message_fwd_from_peer_id, COALESCE(m.fwd_from_name, '')::text AS message_fwd_from_name, @@ -812,6 +813,7 @@ SELECT message_quote_text, message_quote_entities_json, message_quote_offset, + message_reply_external_json, message_fwd_from_peer_type, message_fwd_from_peer_id, message_fwd_from_name, @@ -897,6 +899,7 @@ type ListDialogsByPeersRow struct { MessageQuoteText string MessageQuoteEntitiesJson string MessageQuoteOffset int32 + MessageReplyExternalJson string MessageFwdFromPeerType string MessageFwdFromPeerID int64 MessageFwdFromName string @@ -983,6 +986,7 @@ func (q *Queries) ListDialogsByPeers(ctx context.Context, arg ListDialogsByPeers &i.MessageQuoteText, &i.MessageQuoteEntitiesJson, &i.MessageQuoteOffset, + &i.MessageReplyExternalJson, &i.MessageFwdFromPeerType, &i.MessageFwdFromPeerID, &i.MessageFwdFromName, @@ -1071,6 +1075,7 @@ WITH base AS ( COALESCE(m.quote_text, '')::text AS message_quote_text, COALESCE(m.quote_entities::text, '[]')::text AS message_quote_entities_json, COALESCE(m.quote_offset, 0)::int AS message_quote_offset, + COALESCE(m.reply_external, '{}'::jsonb)::text AS message_reply_external_json, COALESCE(m.fwd_from_peer_type, '')::text AS message_fwd_from_peer_type, COALESCE(m.fwd_from_peer_id, 0)::bigint AS message_fwd_from_peer_id, COALESCE(m.fwd_from_name, '')::text AS message_fwd_from_name, @@ -1143,7 +1148,7 @@ WITH base AS ( AND (NOT $16::boolean OR NOT d.pinned) ), paged AS ( - SELECT user_id, peer_type, peer_id, folder_id, top_message_id, top_message_date, read_inbox_max_id, read_outbox_max_id, unread_count, unread_mentions_count, unread_reactions_count, ttl_period, theme_emoticon, has_scheduled, pinned, pinned_order, unread_mark, hidden_peer_settings_bar, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_is_bot, peer_bot_info_version, peer_premium_until, peer_emoji_status_document_id, peer_emoji_status_until, peer_last_seen_at, peer_contact, peer_mutual, message_id, message_private_message_id, message_from_user_id, message_date, message_outgoing, message_body, message_entities_json, message_media_json, message_ttl_period, message_expires_at, message_edit_date, message_hide_edited, message_silent, message_noforwards, message_reply_to_msg_id, message_reply_to_peer_type, message_reply_to_peer_id, message_reply_to_top_id, message_reply_to_story_id, message_quote_text, message_quote_entities_json, message_quote_offset, message_fwd_from_peer_type, message_fwd_from_peer_id, message_fwd_from_name, message_fwd_date, message_fwd_saved_from_peer_type, message_fwd_saved_from_peer_id, message_fwd_saved_from_msg_id, message_saved_peer_type, message_saved_peer_id, message_media_unread, message_reaction_unread, message_via_bot_id, message_grouped_id, message_effect, message_reply_markup_json, message_rich_message_json, message_pinned + SELECT user_id, peer_type, peer_id, folder_id, top_message_id, top_message_date, read_inbox_max_id, read_outbox_max_id, unread_count, unread_mentions_count, unread_reactions_count, ttl_period, theme_emoticon, has_scheduled, pinned, pinned_order, unread_mark, hidden_peer_settings_bar, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_is_bot, peer_bot_info_version, peer_premium_until, peer_emoji_status_document_id, peer_emoji_status_until, peer_last_seen_at, peer_contact, peer_mutual, message_id, message_private_message_id, message_from_user_id, message_date, message_outgoing, message_body, message_entities_json, message_media_json, message_ttl_period, message_expires_at, message_edit_date, message_hide_edited, message_silent, message_noforwards, message_reply_to_msg_id, message_reply_to_peer_type, message_reply_to_peer_id, message_reply_to_top_id, message_reply_to_story_id, message_quote_text, message_quote_entities_json, message_quote_offset, message_reply_external_json, message_fwd_from_peer_type, message_fwd_from_peer_id, message_fwd_from_name, message_fwd_date, message_fwd_saved_from_peer_type, message_fwd_saved_from_peer_id, message_fwd_saved_from_msg_id, message_saved_peer_type, message_saved_peer_id, message_media_unread, message_reaction_unread, message_via_bot_id, message_grouped_id, message_effect, message_reply_markup_json, message_rich_message_json, message_pinned FROM base WHERE ( ($17::int <= 0 AND $18::int <= 0) @@ -1237,6 +1242,7 @@ SELECT message_quote_text, message_quote_entities_json, message_quote_offset, + message_reply_external_json, message_fwd_from_peer_type, message_fwd_from_peer_id, message_fwd_from_name, @@ -1345,6 +1351,7 @@ type ListDialogsByUserRow struct { MessageQuoteText string MessageQuoteEntitiesJson string MessageQuoteOffset int32 + MessageReplyExternalJson string MessageFwdFromPeerType string MessageFwdFromPeerID int64 MessageFwdFromName string @@ -1452,6 +1459,7 @@ func (q *Queries) ListDialogsByUser(ctx context.Context, arg ListDialogsByUserPa &i.MessageQuoteText, &i.MessageQuoteEntitiesJson, &i.MessageQuoteOffset, + &i.MessageReplyExternalJson, &i.MessageFwdFromPeerType, &i.MessageFwdFromPeerID, &i.MessageFwdFromName, diff --git a/internal/store/postgres/sqlcgen/message.sql.go b/internal/store/postgres/sqlcgen/message.sql.go index da448b00..a5a44596 100644 --- a/internal/store/postgres/sqlcgen/message.sql.go +++ b/internal/store/postgres/sqlcgen/message.sql.go @@ -14,25 +14,26 @@ SELECT count(*)::int AS total_count FROM message_boxes m WHERE m.owner_user_id = $1::bigint AND NOT m.deleted + AND ($2::bigint = 0 OR m.from_user_id = $2::bigint) AND ( - NOT $2::boolean - OR (m.peer_type = $3::text AND m.peer_id = $4::bigint) + NOT $3::boolean + OR (m.peer_type = $4::text AND m.peer_id = $5::bigint) ) AND ( - NOT $5::boolean - OR (m.peer_type = 'user' AND m.peer_id = ANY($6::bigint[])) + NOT $6::boolean + OR (m.peer_type = 'user' AND m.peer_id = ANY($7::bigint[])) ) AND ( - $7::text = '' - OR m.body ILIKE ('%' || $7::text || '%') + $8::text = '' + OR m.body ILIKE ('%' || $8::text || '%') ) - AND ($8::int <= 0 OR m.message_date > $8::int) - AND ($9::int <= 0 OR m.message_date < $9::int) - AND ($10::int <= 0 OR m.box_id < $10::int) - AND ($11::int <= 0 OR m.box_id > $11::int) - AND (NOT $12::boolean OR m.pinned) + AND ($9::int <= 0 OR m.message_date > $9::int) + AND ($10::int <= 0 OR m.message_date < $10::int) + AND ($11::int <= 0 OR m.box_id < $11::int) + AND ($12::int <= 0 OR m.box_id > $12::int) + AND (NOT $13::boolean OR m.pinned) AND ( - NOT $13::boolean + NOT $14::boolean OR ( m.media->>'kind' = 'document' AND EXISTS ( @@ -44,11 +45,11 @@ WHERE m.owner_user_id = $1::bigint ) ) AND ( - NOT $14::boolean + NOT $15::boolean OR m.media #>> '{service_action,kind}' = 'phone_call' ) AND ( - NOT $15::boolean + NOT $16::boolean OR ( NOT m.outgoing AND m.media #>> '{service_action,kind}' = 'phone_call' @@ -56,24 +57,25 @@ WHERE m.owner_user_id = $1::bigint ) ) AND ( - $16::text = '' - OR (m.saved_peer_type = $16::text AND m.saved_peer_id = $17::bigint) + $17::text = '' + OR (m.saved_peer_type = $17::text AND m.saved_peer_id = $18::bigint) ) AND ( - cardinality($18::text[]) = 0 + cardinality($19::text[]) = 0 OR EXISTS ( SELECT 1 FROM saved_message_reaction_tags tag WHERE tag.user_id = m.owner_user_id AND tag.message_box_id = m.box_id AND (tag.reaction_type || ':' || tag.reaction_value) - = ANY($18::text[]) + = ANY($19::text[]) ) ) ` type CountMessagesByUserParams struct { OwnerUserID int64 + SenderUserID int64 HasPeer bool PeerType string PeerID int64 @@ -98,6 +100,7 @@ type CountMessagesByUserParams struct { func (q *Queries) CountMessagesByUser(ctx context.Context, arg CountMessagesByUserParams) (int32, error) { row := q.db.QueryRow(ctx, countMessagesByUser, arg.OwnerUserID, + arg.SenderUserID, arg.HasPeer, arg.PeerType, arg.PeerID, @@ -299,6 +302,7 @@ INSERT INTO message_boxes ( quote_text, quote_entities, quote_offset, + reply_external, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, @@ -332,24 +336,25 @@ INSERT INTO message_boxes ( $21::text, $22::jsonb, $23::int, - $24::text, - $25::bigint, - $26::text, - $27::int, - $28::text, - $29::bigint, - $30::int, - $31::text, - $32::bigint, - $33::int, - $34::jsonb, - $35::boolean, + COALESCE($24::jsonb, '{}'::jsonb), + $25::text, + $26::bigint, + $27::text, + $28::int, + $29::text, + $30::bigint, + $31::int, + $32::text, + $33::bigint, + $34::int, + $35::jsonb, $36::boolean, - $37::jsonb, + $37::boolean, $38::jsonb, - $39::bigint, + $39::jsonb, $40::bigint, - $41::bigint + $41::bigint, + $42::bigint ) RETURNING box_id, @@ -376,6 +381,7 @@ RETURNING quote_text, quote_entities::text AS quote_entities_json, quote_offset, + reply_external::text AS reply_external_json, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, @@ -421,6 +427,7 @@ type CreateMessageBoxParams struct { QuoteText string QuoteEntitiesJson []byte QuoteOffset int32 + ReplyExternalJson []byte FwdFromPeerType string FwdFromPeerID int64 FwdFromName string @@ -466,6 +473,7 @@ type CreateMessageBoxRow struct { QuoteText string QuoteEntitiesJson string QuoteOffset int32 + ReplyExternalJson string FwdFromPeerType string FwdFromPeerID int64 FwdFromName string @@ -512,6 +520,7 @@ func (q *Queries) CreateMessageBox(ctx context.Context, arg CreateMessageBoxPara arg.QuoteText, arg.QuoteEntitiesJson, arg.QuoteOffset, + arg.ReplyExternalJson, arg.FwdFromPeerType, arg.FwdFromPeerID, arg.FwdFromName, @@ -557,6 +566,7 @@ func (q *Queries) CreateMessageBox(ctx context.Context, arg CreateMessageBoxPara &i.QuoteText, &i.QuoteEntitiesJson, &i.QuoteOffset, + &i.ReplyExternalJson, &i.FwdFromPeerType, &i.FwdFromPeerID, &i.FwdFromName, @@ -606,6 +616,7 @@ INSERT INTO private_messages ( quote_text, quote_entities, quote_offset, + reply_external, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, @@ -630,16 +641,17 @@ INSERT INTO private_messages ( $18::text, $19::jsonb, $20::int, - $21::text, - $22::bigint, - $23::text, - $24::int, - $25::jsonb, + COALESCE($21::jsonb, '{}'::jsonb), + $22::text, + $23::bigint, + $24::text, + $25::int, $26::jsonb, $27::jsonb, - $28::bigint, + $28::jsonb, $29::bigint, - $30::bigint + $30::bigint, + $31::bigint ) ON CONFLICT (sender_user_id, random_id) WHERE random_id <> 0 DO NOTHING RETURNING @@ -676,6 +688,7 @@ type CreatePrivateMessageParams struct { QuoteText string QuoteEntitiesJson []byte QuoteOffset int32 + ReplyExternalJson []byte FwdFromPeerType string FwdFromPeerID int64 FwdFromName string @@ -723,6 +736,7 @@ func (q *Queries) CreatePrivateMessage(ctx context.Context, arg CreatePrivateMes arg.QuoteText, arg.QuoteEntitiesJson, arg.QuoteOffset, + arg.ReplyExternalJson, arg.FwdFromPeerType, arg.FwdFromPeerID, arg.FwdFromName, @@ -1147,6 +1161,7 @@ SELECT quote_text, quote_entities::text AS quote_entities_json, quote_offset, + reply_external::text AS reply_external_json, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, @@ -1202,6 +1217,7 @@ type GetMessageBoxByPrivateMessageRow struct { QuoteText string QuoteEntitiesJson string QuoteOffset int32 + ReplyExternalJson string FwdFromPeerType string FwdFromPeerID int64 FwdFromName string @@ -1251,6 +1267,7 @@ func (q *Queries) GetMessageBoxByPrivateMessage(ctx context.Context, arg GetMess &i.QuoteText, &i.QuoteEntitiesJson, &i.QuoteOffset, + &i.ReplyExternalJson, &i.FwdFromPeerType, &i.FwdFromPeerID, &i.FwdFromName, @@ -1301,6 +1318,7 @@ SELECT quote_text, quote_entities::text AS quote_entities_json, quote_offset, + reply_external::text AS reply_external_json, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, @@ -1363,6 +1381,7 @@ type GetMessageBoxForEditRow struct { QuoteText string QuoteEntitiesJson string QuoteOffset int32 + ReplyExternalJson string FwdFromPeerType string FwdFromPeerID int64 FwdFromName string @@ -1418,6 +1437,7 @@ func (q *Queries) GetMessageBoxForEdit(ctx context.Context, arg GetMessageBoxFor &i.QuoteText, &i.QuoteEntitiesJson, &i.QuoteOffset, + &i.ReplyExternalJson, &i.FwdFromPeerType, &i.FwdFromPeerID, &i.FwdFromName, @@ -1495,7 +1515,9 @@ const getMessageBoxForReply = `-- name: GetMessageBoxForReply :one SELECT box_id, private_message_id, - message_sender_id + message_sender_id, + from_user_id, message_date, body, entities::text AS entities_json, + media::text AS media_json, noforwards FROM message_boxes WHERE owner_user_id = $1::bigint AND peer_type = $2::text @@ -1516,6 +1538,12 @@ type GetMessageBoxForReplyRow struct { BoxID int32 PrivateMessageID int64 MessageSenderID int64 + FromUserID int64 + MessageDate int32 + Body string + EntitiesJson string + MediaJson string + Noforwards bool } func (q *Queries) GetMessageBoxForReply(ctx context.Context, arg GetMessageBoxForReplyParams) (GetMessageBoxForReplyRow, error) { @@ -1526,7 +1554,17 @@ func (q *Queries) GetMessageBoxForReply(ctx context.Context, arg GetMessageBoxFo arg.BoxID, ) var i GetMessageBoxForReplyRow - err := row.Scan(&i.BoxID, &i.PrivateMessageID, &i.MessageSenderID) + err := row.Scan( + &i.BoxID, + &i.PrivateMessageID, + &i.MessageSenderID, + &i.FromUserID, + &i.MessageDate, + &i.Body, + &i.EntitiesJson, + &i.MediaJson, + &i.Noforwards, + ) return i, err } @@ -1557,6 +1595,7 @@ SELECT m.quote_text, m.quote_entities::text AS quote_entities_json, m.quote_offset, + m.reply_external::text AS reply_external_json, m.fwd_from_peer_type, m.fwd_from_peer_id, m.fwd_from_name, @@ -1647,6 +1686,7 @@ type GetMessageBoxesByIDsRow struct { QuoteText string QuoteEntitiesJson string QuoteOffset int32 + ReplyExternalJson string FwdFromPeerType string FwdFromPeerID int64 FwdFromName string @@ -1733,6 +1773,7 @@ func (q *Queries) GetMessageBoxesByIDs(ctx context.Context, arg GetMessageBoxesB &i.QuoteText, &i.QuoteEntitiesJson, &i.QuoteOffset, + &i.ReplyExternalJson, &i.FwdFromPeerType, &i.FwdFromPeerID, &i.FwdFromName, @@ -1827,6 +1868,7 @@ SELECT m.quote_text, m.quote_entities::text AS quote_entities_json, m.quote_offset, + m.reply_external::text AS reply_external_json, m.fwd_from_peer_type, m.fwd_from_peer_id, m.fwd_from_name, @@ -1887,6 +1929,7 @@ type GetMessageBoxesForForwardRow struct { QuoteText string QuoteEntitiesJson string QuoteOffset int32 + ReplyExternalJson string FwdFromPeerType string FwdFromPeerID int64 FwdFromName string @@ -1946,6 +1989,7 @@ func (q *Queries) GetMessageBoxesForForward(ctx context.Context, arg GetMessageB &i.QuoteText, &i.QuoteEntitiesJson, &i.QuoteOffset, + &i.ReplyExternalJson, &i.FwdFromPeerType, &i.FwdFromPeerID, &i.FwdFromName, @@ -2270,6 +2314,7 @@ SELECT m.quote_text, m.quote_entities::text AS quote_entities_json, m.quote_offset, + m.reply_external::text AS reply_external_json, m.fwd_from_peer_type, m.fwd_from_peer_id, m.fwd_from_name, @@ -2324,25 +2369,26 @@ LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id LEFT JOIN users from_u ON from_u.id = m.from_user_id WHERE m.owner_user_id = $1::bigint AND NOT m.deleted + AND ($2::bigint = 0 OR m.from_user_id = $2::bigint) AND ( - NOT $2::boolean - OR (m.peer_type = $3::text AND m.peer_id = $4::bigint) + NOT $3::boolean + OR (m.peer_type = $4::text AND m.peer_id = $5::bigint) ) AND ( - NOT $5::boolean - OR (m.peer_type = 'user' AND m.peer_id = ANY($6::bigint[])) + NOT $6::boolean + OR (m.peer_type = 'user' AND m.peer_id = ANY($7::bigint[])) ) AND ( - $7::text = '' - OR m.body ILIKE ('%' || $7::text || '%') + $8::text = '' + OR m.body ILIKE ('%' || $8::text || '%') ) - AND ($8::int <= 0 OR m.message_date > $8::int) - AND ($9::int <= 0 OR m.message_date < $9::int) - AND ($10::int <= 0 OR m.box_id < $10::int) - AND ($11::int <= 0 OR m.box_id > $11::int) - AND (NOT $12::boolean OR m.pinned) + AND ($9::int <= 0 OR m.message_date > $9::int) + AND ($10::int <= 0 OR m.message_date < $10::int) + AND ($11::int <= 0 OR m.box_id < $11::int) + AND ($12::int <= 0 OR m.box_id > $12::int) + AND (NOT $13::boolean OR m.pinned) AND ( - NOT $13::boolean + NOT $14::boolean OR ( m.media->>'kind' = 'document' AND EXISTS ( @@ -2354,11 +2400,11 @@ WHERE m.owner_user_id = $1::bigint ) ) AND ( - NOT $14::boolean + NOT $15::boolean OR m.media #>> '{service_action,kind}' = 'phone_call' ) AND ( - NOT $15::boolean + NOT $16::boolean OR ( NOT m.outgoing AND m.media #>> '{service_action,kind}' = 'phone_call' @@ -2366,31 +2412,32 @@ WHERE m.owner_user_id = $1::bigint ) ) AND ( - $16::text = '' - OR (m.saved_peer_type = $16::text AND m.saved_peer_id = $17::bigint) + $17::text = '' + OR (m.saved_peer_type = $17::text AND m.saved_peer_id = $18::bigint) ) AND ( - cardinality($18::text[]) = 0 + cardinality($19::text[]) = 0 OR EXISTS ( SELECT 1 FROM saved_message_reaction_tags tag WHERE tag.user_id = m.owner_user_id AND tag.message_box_id = m.box_id AND (tag.reaction_type || ':' || tag.reaction_value) - = ANY($18::text[]) + = ANY($19::text[]) ) ) AND ( - ($19::int > 0 AND m.message_date < $19::int) - OR ($19::int <= 0 AND ($20::int <= 0 OR m.box_id < $20::int)) + ($20::int > 0 AND m.message_date < $20::int) + OR ($20::int <= 0 AND ($21::int <= 0 OR m.box_id < $21::int)) ) ORDER BY m.box_id DESC -OFFSET GREATEST($21::int, 0) -LIMIT $22::int +OFFSET GREATEST($22::int, 0) +LIMIT $23::int ` type ListMessagesBackwardParams struct { OwnerUserID int64 + SenderUserID int64 HasPeer bool PeerType string PeerID int64 @@ -2439,6 +2486,7 @@ type ListMessagesBackwardRow struct { QuoteText string QuoteEntitiesJson string QuoteOffset int32 + ReplyExternalJson string FwdFromPeerType string FwdFromPeerID int64 FwdFromName string @@ -2493,11 +2541,12 @@ type ListMessagesBackwardRow struct { // backward 热路径(add_offset>=0:初始加载/上滑翻页)的扁平静态查询。 // 与 ListMessagesByUser 的 backward 分支逐位等价(相同 base 过滤 + 相同 anchor + // ORDER BY box_id DESC + OFFSET GREATEST(add_offset,0) + LIMIT),但只规划单次 -// index scan + 2 LEFT JOIN,避免大 CTE 把 4 个分支+total 全树规划。total 走 +// index scan + 2 LEFT JOIN,避免大 CTE 把 4 个分支全树规划。total 走 // 独立 CountMessagesByUser,仅 NeedTotalCount 时发。 func (q *Queries) ListMessagesBackward(ctx context.Context, arg ListMessagesBackwardParams) ([]ListMessagesBackwardRow, error) { rows, err := q.db.Query(ctx, listMessagesBackward, arg.OwnerUserID, + arg.SenderUserID, arg.HasPeer, arg.PeerType, arg.PeerID, @@ -2552,6 +2601,7 @@ func (q *Queries) ListMessagesBackward(ctx context.Context, arg ListMessagesBack &i.QuoteText, &i.QuoteEntitiesJson, &i.QuoteOffset, + &i.ReplyExternalJson, &i.FwdFromPeerType, &i.FwdFromPeerID, &i.FwdFromName, @@ -2651,6 +2701,7 @@ base AS NOT MATERIALIZED ( m.quote_text, m.quote_entities::text AS quote_entities_json, m.quote_offset, + m.reply_external::text AS reply_external_json, m.fwd_from_peer_type, m.fwd_from_peer_id, m.fwd_from_name, @@ -2705,25 +2756,26 @@ base AS NOT MATERIALIZED ( LEFT JOIN users from_u ON from_u.id = m.from_user_id WHERE m.owner_user_id = $1 AND NOT m.deleted + AND ($6::bigint = 0 OR m.from_user_id = $6::bigint) AND ( - NOT $6::boolean - OR (m.peer_type = $7::text AND m.peer_id = $8::bigint) + NOT $7::boolean + OR (m.peer_type = $8::text AND m.peer_id = $9::bigint) ) AND ( - NOT $9::boolean - OR (m.peer_type = 'user' AND m.peer_id = ANY($10::bigint[])) + NOT $10::boolean + OR (m.peer_type = 'user' AND m.peer_id = ANY($11::bigint[])) ) AND ( - $11::text = '' - OR m.body ILIKE ('%' || $11::text || '%') + $12::text = '' + OR m.body ILIKE ('%' || $12::text || '%') ) - AND ($12::int <= 0 OR m.message_date > $12::int) - AND ($13::int <= 0 OR m.message_date < $13::int) - AND ($14::int <= 0 OR m.box_id < $14::int) - AND ($15::int <= 0 OR m.box_id > $15::int) - AND (NOT $16::boolean OR m.pinned) + AND ($13::int <= 0 OR m.message_date > $13::int) + AND ($14::int <= 0 OR m.message_date < $14::int) + AND ($15::int <= 0 OR m.box_id < $15::int) + AND ($16::int <= 0 OR m.box_id > $16::int) + AND (NOT $17::boolean OR m.pinned) AND ( - NOT $17::boolean + NOT $18::boolean OR ( m.media->>'kind' = 'document' AND EXISTS ( @@ -2735,11 +2787,11 @@ base AS NOT MATERIALIZED ( ) ) AND ( - NOT $18::boolean + NOT $19::boolean OR m.media #>> '{service_action,kind}' = 'phone_call' ) AND ( - NOT $19::boolean + NOT $20::boolean OR ( NOT m.outgoing AND m.media #>> '{service_action,kind}' = 'phone_call' @@ -2747,28 +2799,23 @@ base AS NOT MATERIALIZED ( ) ) AND ( - $20::text = '' - OR (m.saved_peer_type = $20::text AND m.saved_peer_id = $21::bigint) + $21::text = '' + OR (m.saved_peer_type = $21::text AND m.saved_peer_id = $22::bigint) ) AND ( - cardinality($22::text[]) = 0 + cardinality($23::text[]) = 0 OR EXISTS ( SELECT 1 FROM saved_message_reaction_tags tag WHERE tag.user_id = m.owner_user_id AND tag.message_box_id = m.box_id AND (tag.reaction_type || ':' || tag.reaction_value) - = ANY($22::text[]) + = ANY($23::text[]) ) ) ), -total AS ( - SELECT count(*)::int AS total_count - FROM base - WHERE $23::boolean -), backward AS ( - SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.ttl_period, b.expires_at, b.edit_date, b.hide_edited, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.reply_to_story_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.fwd_saved_from_peer_type, b.fwd_saved_from_peer_id, b.fwd_saved_from_msg_id, b.saved_peer_type, b.saved_peer_id, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.pinned, b.via_bot_id, b.grouped_id, b.effect, b.reply_markup_json, b.rich_message_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_is_bot, b.peer_bot_info_version, b.peer_premium_until, b.peer_emoji_status_document_id, b.peer_emoji_status_until, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_is_bot, b.from_user_bot_info_version, b.from_user_premium_until, b.from_user_emoji_status_document_id, b.from_user_emoji_status_until, b.from_user_last_seen_at + SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.ttl_period, b.expires_at, b.edit_date, b.hide_edited, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.reply_to_story_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.reply_external_json, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.fwd_saved_from_peer_type, b.fwd_saved_from_peer_id, b.fwd_saved_from_msg_id, b.saved_peer_type, b.saved_peer_id, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.pinned, b.via_bot_id, b.grouped_id, b.effect, b.reply_markup_json, b.rich_message_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_is_bot, b.peer_bot_info_version, b.peer_premium_until, b.peer_emoji_status_document_id, b.peer_emoji_status_until, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_is_bot, b.from_user_bot_info_version, b.from_user_premium_until, b.from_user_emoji_status_document_id, b.from_user_emoji_status_until, b.from_user_last_seen_at FROM base b CROSS JOIN load_params p WHERE p.load_type = 'backward' @@ -2781,55 +2828,56 @@ backward AS ( LIMIT (SELECT limit_count FROM load_params) ), around_forward AS ( - SELECT f.box_id, f.private_message_id, f.owner_user_id, f.peer_type, f.peer_id, f.from_user_id, f.message_date, f.ttl_period, f.expires_at, f.edit_date, f.hide_edited, f.outgoing, f.body, f.entities_json, f.silent, f.noforwards, f.reply_to_msg_id, f.reply_to_peer_type, f.reply_to_peer_id, f.reply_to_top_id, f.reply_to_story_id, f.quote_text, f.quote_entities_json, f.quote_offset, f.fwd_from_peer_type, f.fwd_from_peer_id, f.fwd_from_name, f.fwd_date, f.fwd_saved_from_peer_type, f.fwd_saved_from_peer_id, f.fwd_saved_from_msg_id, f.saved_peer_type, f.saved_peer_id, f.pts, f.media_json, f.media_unread, f.reaction_unread, f.pinned, f.via_bot_id, f.grouped_id, f.effect, f.reply_markup_json, f.rich_message_json, f.peer_user_id, f.peer_access_hash, f.peer_phone, f.peer_first_name, f.peer_last_name, f.peer_username, f.peer_country_code, f.peer_verified, f.peer_support, f.peer_is_bot, f.peer_bot_info_version, f.peer_premium_until, f.peer_emoji_status_document_id, f.peer_emoji_status_until, f.peer_last_seen_at, f.from_user_user_id, f.from_user_access_hash, f.from_user_phone, f.from_user_first_name, f.from_user_last_name, f.from_user_username, f.from_user_country_code, f.from_user_verified, f.from_user_support, f.from_user_is_bot, f.from_user_bot_info_version, f.from_user_premium_until, f.from_user_emoji_status_document_id, f.from_user_emoji_status_until, f.from_user_last_seen_at + SELECT f.box_id, f.private_message_id, f.owner_user_id, f.peer_type, f.peer_id, f.from_user_id, f.message_date, f.ttl_period, f.expires_at, f.edit_date, f.hide_edited, f.outgoing, f.body, f.entities_json, f.silent, f.noforwards, f.reply_to_msg_id, f.reply_to_peer_type, f.reply_to_peer_id, f.reply_to_top_id, f.reply_to_story_id, f.quote_text, f.quote_entities_json, f.quote_offset, f.reply_external_json, f.fwd_from_peer_type, f.fwd_from_peer_id, f.fwd_from_name, f.fwd_date, f.fwd_saved_from_peer_type, f.fwd_saved_from_peer_id, f.fwd_saved_from_msg_id, f.saved_peer_type, f.saved_peer_id, f.pts, f.media_json, f.media_unread, f.reaction_unread, f.pinned, f.via_bot_id, f.grouped_id, f.effect, f.reply_markup_json, f.rich_message_json, f.peer_user_id, f.peer_access_hash, f.peer_phone, f.peer_first_name, f.peer_last_name, f.peer_username, f.peer_country_code, f.peer_verified, f.peer_support, f.peer_is_bot, f.peer_bot_info_version, f.peer_premium_until, f.peer_emoji_status_document_id, f.peer_emoji_status_until, f.peer_last_seen_at, f.from_user_user_id, f.from_user_access_hash, f.from_user_phone, f.from_user_first_name, f.from_user_last_name, f.from_user_username, f.from_user_country_code, f.from_user_verified, f.from_user_support, f.from_user_is_bot, f.from_user_bot_info_version, f.from_user_premium_until, f.from_user_emoji_status_document_id, f.from_user_emoji_status_until, f.from_user_last_seen_at FROM ( - SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.ttl_period, b.expires_at, b.edit_date, b.hide_edited, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.reply_to_story_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.fwd_saved_from_peer_type, b.fwd_saved_from_peer_id, b.fwd_saved_from_msg_id, b.saved_peer_type, b.saved_peer_id, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.pinned, b.via_bot_id, b.grouped_id, b.effect, b.reply_markup_json, b.rich_message_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_is_bot, b.peer_bot_info_version, b.peer_premium_until, b.peer_emoji_status_document_id, b.peer_emoji_status_until, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_is_bot, b.from_user_bot_info_version, b.from_user_premium_until, b.from_user_emoji_status_document_id, b.from_user_emoji_status_until, b.from_user_last_seen_at + SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.ttl_period, b.expires_at, b.edit_date, b.hide_edited, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.reply_to_story_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.reply_external_json, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.fwd_saved_from_peer_type, b.fwd_saved_from_peer_id, b.fwd_saved_from_msg_id, b.saved_peer_type, b.saved_peer_id, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.pinned, b.via_bot_id, b.grouped_id, b.effect, b.reply_markup_json, b.rich_message_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_is_bot, b.peer_bot_info_version, b.peer_premium_until, b.peer_emoji_status_document_id, b.peer_emoji_status_until, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_is_bot, b.from_user_bot_info_version, b.from_user_premium_until, b.from_user_emoji_status_document_id, b.from_user_emoji_status_until, b.from_user_last_seen_at FROM base b CROSS JOIN load_params p WHERE p.load_type = 'around' AND ( (p.offset_date > 0 AND b.message_date >= p.offset_date) - OR (p.offset_date <= 0 AND p.offset_id > 0 AND b.box_id > p.offset_id) + OR (p.offset_date <= 0 AND p.offset_id > 0 AND b.box_id >= p.offset_id) ) ORDER BY b.box_id ASC LIMIT LEAST(-(SELECT add_offset FROM load_params), (SELECT limit_count FROM load_params)) ) f ), around_backward AS ( - SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.ttl_period, b.expires_at, b.edit_date, b.hide_edited, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.reply_to_story_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.fwd_saved_from_peer_type, b.fwd_saved_from_peer_id, b.fwd_saved_from_msg_id, b.saved_peer_type, b.saved_peer_id, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.pinned, b.via_bot_id, b.grouped_id, b.effect, b.reply_markup_json, b.rich_message_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_is_bot, b.peer_bot_info_version, b.peer_premium_until, b.peer_emoji_status_document_id, b.peer_emoji_status_until, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_is_bot, b.from_user_bot_info_version, b.from_user_premium_until, b.from_user_emoji_status_document_id, b.from_user_emoji_status_until, b.from_user_last_seen_at + SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.ttl_period, b.expires_at, b.edit_date, b.hide_edited, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.reply_to_story_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.reply_external_json, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.fwd_saved_from_peer_type, b.fwd_saved_from_peer_id, b.fwd_saved_from_msg_id, b.saved_peer_type, b.saved_peer_id, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.pinned, b.via_bot_id, b.grouped_id, b.effect, b.reply_markup_json, b.rich_message_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_is_bot, b.peer_bot_info_version, b.peer_premium_until, b.peer_emoji_status_document_id, b.peer_emoji_status_until, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_is_bot, b.from_user_bot_info_version, b.from_user_premium_until, b.from_user_emoji_status_document_id, b.from_user_emoji_status_until, b.from_user_last_seen_at FROM base b CROSS JOIN load_params p WHERE p.load_type = 'around' AND ( (p.offset_date > 0 AND b.message_date < p.offset_date) - OR (p.offset_date <= 0 AND (p.offset_id <= 0 OR b.box_id <= p.offset_id)) + OR (p.offset_date <= 0 AND (p.offset_id <= 0 OR b.box_id < p.offset_id)) ) ORDER BY b.box_id DESC LIMIT GREATEST((SELECT limit_count + add_offset FROM load_params), 0) ), forward AS ( - SELECT f.box_id, f.private_message_id, f.owner_user_id, f.peer_type, f.peer_id, f.from_user_id, f.message_date, f.ttl_period, f.expires_at, f.edit_date, f.hide_edited, f.outgoing, f.body, f.entities_json, f.silent, f.noforwards, f.reply_to_msg_id, f.reply_to_peer_type, f.reply_to_peer_id, f.reply_to_top_id, f.reply_to_story_id, f.quote_text, f.quote_entities_json, f.quote_offset, f.fwd_from_peer_type, f.fwd_from_peer_id, f.fwd_from_name, f.fwd_date, f.fwd_saved_from_peer_type, f.fwd_saved_from_peer_id, f.fwd_saved_from_msg_id, f.saved_peer_type, f.saved_peer_id, f.pts, f.media_json, f.media_unread, f.reaction_unread, f.pinned, f.via_bot_id, f.grouped_id, f.effect, f.reply_markup_json, f.rich_message_json, f.peer_user_id, f.peer_access_hash, f.peer_phone, f.peer_first_name, f.peer_last_name, f.peer_username, f.peer_country_code, f.peer_verified, f.peer_support, f.peer_is_bot, f.peer_bot_info_version, f.peer_premium_until, f.peer_emoji_status_document_id, f.peer_emoji_status_until, f.peer_last_seen_at, f.from_user_user_id, f.from_user_access_hash, f.from_user_phone, f.from_user_first_name, f.from_user_last_name, f.from_user_username, f.from_user_country_code, f.from_user_verified, f.from_user_support, f.from_user_is_bot, f.from_user_bot_info_version, f.from_user_premium_until, f.from_user_emoji_status_document_id, f.from_user_emoji_status_until, f.from_user_last_seen_at + SELECT f.box_id, f.private_message_id, f.owner_user_id, f.peer_type, f.peer_id, f.from_user_id, f.message_date, f.ttl_period, f.expires_at, f.edit_date, f.hide_edited, f.outgoing, f.body, f.entities_json, f.silent, f.noforwards, f.reply_to_msg_id, f.reply_to_peer_type, f.reply_to_peer_id, f.reply_to_top_id, f.reply_to_story_id, f.quote_text, f.quote_entities_json, f.quote_offset, f.reply_external_json, f.fwd_from_peer_type, f.fwd_from_peer_id, f.fwd_from_name, f.fwd_date, f.fwd_saved_from_peer_type, f.fwd_saved_from_peer_id, f.fwd_saved_from_msg_id, f.saved_peer_type, f.saved_peer_id, f.pts, f.media_json, f.media_unread, f.reaction_unread, f.pinned, f.via_bot_id, f.grouped_id, f.effect, f.reply_markup_json, f.rich_message_json, f.peer_user_id, f.peer_access_hash, f.peer_phone, f.peer_first_name, f.peer_last_name, f.peer_username, f.peer_country_code, f.peer_verified, f.peer_support, f.peer_is_bot, f.peer_bot_info_version, f.peer_premium_until, f.peer_emoji_status_document_id, f.peer_emoji_status_until, f.peer_last_seen_at, f.from_user_user_id, f.from_user_access_hash, f.from_user_phone, f.from_user_first_name, f.from_user_last_name, f.from_user_username, f.from_user_country_code, f.from_user_verified, f.from_user_support, f.from_user_is_bot, f.from_user_bot_info_version, f.from_user_premium_until, f.from_user_emoji_status_document_id, f.from_user_emoji_status_until, f.from_user_last_seen_at FROM ( - SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.ttl_period, b.expires_at, b.edit_date, b.hide_edited, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.reply_to_story_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.fwd_saved_from_peer_type, b.fwd_saved_from_peer_id, b.fwd_saved_from_msg_id, b.saved_peer_type, b.saved_peer_id, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.pinned, b.via_bot_id, b.grouped_id, b.effect, b.reply_markup_json, b.rich_message_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_is_bot, b.peer_bot_info_version, b.peer_premium_until, b.peer_emoji_status_document_id, b.peer_emoji_status_until, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_is_bot, b.from_user_bot_info_version, b.from_user_premium_until, b.from_user_emoji_status_document_id, b.from_user_emoji_status_until, b.from_user_last_seen_at + SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.ttl_period, b.expires_at, b.edit_date, b.hide_edited, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.reply_to_story_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.reply_external_json, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.fwd_saved_from_peer_type, b.fwd_saved_from_peer_id, b.fwd_saved_from_msg_id, b.saved_peer_type, b.saved_peer_id, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.pinned, b.via_bot_id, b.grouped_id, b.effect, b.reply_markup_json, b.rich_message_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_is_bot, b.peer_bot_info_version, b.peer_premium_until, b.peer_emoji_status_document_id, b.peer_emoji_status_until, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_is_bot, b.from_user_bot_info_version, b.from_user_premium_until, b.from_user_emoji_status_document_id, b.from_user_emoji_status_until, b.from_user_last_seen_at FROM base b CROSS JOIN load_params p WHERE p.load_type = 'forward' AND ( (p.offset_date > 0 AND b.message_date >= p.offset_date) - OR (p.offset_date <= 0 AND p.offset_id > 0 AND b.box_id > p.offset_id) + OR (p.offset_date <= 0 AND p.offset_id > 0 AND b.box_id >= p.offset_id) ) ORDER BY b.box_id ASC + OFFSET GREATEST((SELECT -add_offset - limit_count FROM load_params), 0) LIMIT (SELECT limit_count FROM load_params) ) f ), paged AS ( - SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, ttl_period, expires_at, edit_date, hide_edited, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, reply_to_story_id, quote_text, quote_entities_json, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, fwd_saved_from_peer_type, fwd_saved_from_peer_id, fwd_saved_from_msg_id, saved_peer_type, saved_peer_id, pts, media_json, media_unread, reaction_unread, pinned, via_bot_id, grouped_id, effect, reply_markup_json, rich_message_json, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_is_bot, peer_bot_info_version, peer_premium_until, peer_emoji_status_document_id, peer_emoji_status_until, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_is_bot, from_user_bot_info_version, from_user_premium_until, from_user_emoji_status_document_id, from_user_emoji_status_until, from_user_last_seen_at FROM backward + SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, ttl_period, expires_at, edit_date, hide_edited, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, reply_to_story_id, quote_text, quote_entities_json, quote_offset, reply_external_json, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, fwd_saved_from_peer_type, fwd_saved_from_peer_id, fwd_saved_from_msg_id, saved_peer_type, saved_peer_id, pts, media_json, media_unread, reaction_unread, pinned, via_bot_id, grouped_id, effect, reply_markup_json, rich_message_json, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_is_bot, peer_bot_info_version, peer_premium_until, peer_emoji_status_document_id, peer_emoji_status_until, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_is_bot, from_user_bot_info_version, from_user_premium_until, from_user_emoji_status_document_id, from_user_emoji_status_until, from_user_last_seen_at FROM backward UNION ALL - SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, ttl_period, expires_at, edit_date, hide_edited, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, reply_to_story_id, quote_text, quote_entities_json, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, fwd_saved_from_peer_type, fwd_saved_from_peer_id, fwd_saved_from_msg_id, saved_peer_type, saved_peer_id, pts, media_json, media_unread, reaction_unread, pinned, via_bot_id, grouped_id, effect, reply_markup_json, rich_message_json, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_is_bot, peer_bot_info_version, peer_premium_until, peer_emoji_status_document_id, peer_emoji_status_until, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_is_bot, from_user_bot_info_version, from_user_premium_until, from_user_emoji_status_document_id, from_user_emoji_status_until, from_user_last_seen_at FROM around_forward + SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, ttl_period, expires_at, edit_date, hide_edited, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, reply_to_story_id, quote_text, quote_entities_json, quote_offset, reply_external_json, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, fwd_saved_from_peer_type, fwd_saved_from_peer_id, fwd_saved_from_msg_id, saved_peer_type, saved_peer_id, pts, media_json, media_unread, reaction_unread, pinned, via_bot_id, grouped_id, effect, reply_markup_json, rich_message_json, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_is_bot, peer_bot_info_version, peer_premium_until, peer_emoji_status_document_id, peer_emoji_status_until, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_is_bot, from_user_bot_info_version, from_user_premium_until, from_user_emoji_status_document_id, from_user_emoji_status_until, from_user_last_seen_at FROM around_forward UNION ALL - SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, ttl_period, expires_at, edit_date, hide_edited, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, reply_to_story_id, quote_text, quote_entities_json, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, fwd_saved_from_peer_type, fwd_saved_from_peer_id, fwd_saved_from_msg_id, saved_peer_type, saved_peer_id, pts, media_json, media_unread, reaction_unread, pinned, via_bot_id, grouped_id, effect, reply_markup_json, rich_message_json, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_is_bot, peer_bot_info_version, peer_premium_until, peer_emoji_status_document_id, peer_emoji_status_until, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_is_bot, from_user_bot_info_version, from_user_premium_until, from_user_emoji_status_document_id, from_user_emoji_status_until, from_user_last_seen_at FROM around_backward + SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, ttl_period, expires_at, edit_date, hide_edited, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, reply_to_story_id, quote_text, quote_entities_json, quote_offset, reply_external_json, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, fwd_saved_from_peer_type, fwd_saved_from_peer_id, fwd_saved_from_msg_id, saved_peer_type, saved_peer_id, pts, media_json, media_unread, reaction_unread, pinned, via_bot_id, grouped_id, effect, reply_markup_json, rich_message_json, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_is_bot, peer_bot_info_version, peer_premium_until, peer_emoji_status_document_id, peer_emoji_status_until, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_is_bot, from_user_bot_info_version, from_user_premium_until, from_user_emoji_status_document_id, from_user_emoji_status_until, from_user_last_seen_at FROM around_backward UNION ALL - SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, ttl_period, expires_at, edit_date, hide_edited, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, reply_to_story_id, quote_text, quote_entities_json, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, fwd_saved_from_peer_type, fwd_saved_from_peer_id, fwd_saved_from_msg_id, saved_peer_type, saved_peer_id, pts, media_json, media_unread, reaction_unread, pinned, via_bot_id, grouped_id, effect, reply_markup_json, rich_message_json, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_is_bot, peer_bot_info_version, peer_premium_until, peer_emoji_status_document_id, peer_emoji_status_until, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_is_bot, from_user_bot_info_version, from_user_premium_until, from_user_emoji_status_document_id, from_user_emoji_status_until, from_user_last_seen_at FROM forward + SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, ttl_period, expires_at, edit_date, hide_edited, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, reply_to_story_id, quote_text, quote_entities_json, quote_offset, reply_external_json, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, fwd_saved_from_peer_type, fwd_saved_from_peer_id, fwd_saved_from_msg_id, saved_peer_type, saved_peer_id, pts, media_json, media_unread, reaction_unread, pinned, via_bot_id, grouped_id, effect, reply_markup_json, rich_message_json, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_is_bot, peer_bot_info_version, peer_premium_until, peer_emoji_status_document_id, peer_emoji_status_until, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_is_bot, from_user_bot_info_version, from_user_premium_until, from_user_emoji_status_document_id, from_user_emoji_status_until, from_user_last_seen_at FROM forward ) SELECT box_id, @@ -2856,6 +2904,7 @@ SELECT quote_text, quote_entities_json, quote_offset, + reply_external_json, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, @@ -2904,10 +2953,8 @@ SELECT from_user_premium_until, from_user_emoji_status_document_id, from_user_emoji_status_until, - from_user_last_seen_at, - COALESCE(total.total_count, 0)::int AS total_count + from_user_last_seen_at FROM paged -CROSS JOIN total ORDER BY box_id DESC ` @@ -2917,6 +2964,7 @@ type ListMessagesByUserParams struct { OffsetDate int32 AddOffset int32 LimitCount int32 + SenderUserID int64 HasPeer bool PeerType string PeerID int64 @@ -2934,7 +2982,6 @@ type ListMessagesByUserParams struct { SavedPeerType string SavedPeerID int64 SavedReactionKeys []string - NeedTotalCount bool } type ListMessagesByUserRow struct { @@ -2962,6 +3009,7 @@ type ListMessagesByUserRow struct { QuoteText string QuoteEntitiesJson string QuoteOffset int32 + ReplyExternalJson string FwdFromPeerType string FwdFromPeerID int64 FwdFromName string @@ -3011,7 +3059,6 @@ type ListMessagesByUserRow struct { FromUserEmojiStatusDocumentID int64 FromUserEmojiStatusUntil int64 FromUserLastSeenAt int64 - TotalCount int32 } func (q *Queries) ListMessagesByUser(ctx context.Context, arg ListMessagesByUserParams) ([]ListMessagesByUserRow, error) { @@ -3021,6 +3068,7 @@ func (q *Queries) ListMessagesByUser(ctx context.Context, arg ListMessagesByUser arg.OffsetDate, arg.AddOffset, arg.LimitCount, + arg.SenderUserID, arg.HasPeer, arg.PeerType, arg.PeerID, @@ -3038,7 +3086,6 @@ func (q *Queries) ListMessagesByUser(ctx context.Context, arg ListMessagesByUser arg.SavedPeerType, arg.SavedPeerID, arg.SavedReactionKeys, - arg.NeedTotalCount, ) if err != nil { return nil, err @@ -3072,6 +3119,7 @@ func (q *Queries) ListMessagesByUser(ctx context.Context, arg ListMessagesByUser &i.QuoteText, &i.QuoteEntitiesJson, &i.QuoteOffset, + &i.ReplyExternalJson, &i.FwdFromPeerType, &i.FwdFromPeerID, &i.FwdFromName, @@ -3121,7 +3169,6 @@ func (q *Queries) ListMessagesByUser(ctx context.Context, arg ListMessagesByUser &i.FromUserEmojiStatusDocumentID, &i.FromUserEmojiStatusUntil, &i.FromUserLastSeenAt, - &i.TotalCount, ); err != nil { return nil, err } @@ -3159,6 +3206,7 @@ SELECT quote_text, quote_entities::text AS quote_entities_json, quote_offset, + reply_external::text AS reply_external_json, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, @@ -3220,6 +3268,7 @@ type ListUnreadReactionMessageBoxesRow struct { QuoteText string QuoteEntitiesJson string QuoteOffset int32 + ReplyExternalJson string FwdFromPeerType string FwdFromPeerID int64 FwdFromName string @@ -3280,6 +3329,7 @@ func (q *Queries) ListUnreadReactionMessageBoxes(ctx context.Context, arg ListUn &i.QuoteText, &i.QuoteEntitiesJson, &i.QuoteOffset, + &i.ReplyExternalJson, &i.FwdFromPeerType, &i.FwdFromPeerID, &i.FwdFromName, @@ -3337,6 +3387,7 @@ SELECT quote_text, quote_entities::text AS quote_entities_json, quote_offset, + reply_external::text AS reply_external_json, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, @@ -3397,6 +3448,7 @@ type ListVisibleMessageBoxesByPrivateMessageRow struct { QuoteText string QuoteEntitiesJson string QuoteOffset int32 + ReplyExternalJson string FwdFromPeerType string FwdFromPeerID int64 FwdFromName string @@ -3453,6 +3505,7 @@ func (q *Queries) ListVisibleMessageBoxesByPrivateMessage(ctx context.Context, a &i.QuoteText, &i.QuoteEntitiesJson, &i.QuoteOffset, + &i.ReplyExternalJson, &i.FwdFromPeerType, &i.FwdFromPeerID, &i.FwdFromName, @@ -3780,6 +3833,7 @@ RETURNING quote_text, quote_entities::text AS quote_entities_json, quote_offset, + reply_external::text AS reply_external_json, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, @@ -3841,6 +3895,7 @@ type UpdateMessageBoxEditRow struct { QuoteText string QuoteEntitiesJson string QuoteOffset int32 + ReplyExternalJson string FwdFromPeerType string FwdFromPeerID int64 FwdFromName string @@ -3903,6 +3958,7 @@ func (q *Queries) UpdateMessageBoxEdit(ctx context.Context, arg UpdateMessageBox &i.QuoteText, &i.QuoteEntitiesJson, &i.QuoteOffset, + &i.ReplyExternalJson, &i.FwdFromPeerType, &i.FwdFromPeerID, &i.FwdFromName, diff --git a/internal/store/postgres/sqlcgen/models.go b/internal/store/postgres/sqlcgen/models.go index 6fc2a2f0..58119193 100644 --- a/internal/store/postgres/sqlcgen/models.go +++ b/internal/store/postgres/sqlcgen/models.go @@ -147,6 +147,11 @@ type AccountSetting struct { SensitiveContentEnabled bool ContactSignupSilent bool UpdatedAt pgtype.Timestamptz + DisallowUnlimitedStargifts bool + DisallowLimitedStargifts bool + DisallowUniqueStargifts bool + DisallowPremiumGifts bool + DisallowStargiftsFromChannels bool } type AdminAuditLog struct { @@ -183,6 +188,18 @@ type AdminCommand struct { CompletedAt pgtype.Timestamptz } +type AdminConsoleUser struct { + ID int64 + Username string + PasswordHash string + Permissions []string + Enabled bool + TokenEpoch int32 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + LastLoginAt pgtype.Timestamptz +} + type AiComposeTone struct { ID int64 AccessHash int64 @@ -505,6 +522,39 @@ type BotVerifierSetting struct { Version int64 } +type Broadcast struct { + ID int64 + Message string + TargetMode string + TargetCount int64 + CreatedBy string + CreatedAt pgtype.Timestamptz + Entities []byte + SnapshotMaxUserID int64 + EnumerationCursorUserID int64 + EnumerationDone bool + MaterializedCount int64 + SentCount int64 + FailedCount int64 +} + +type BroadcastRecipient struct { + ID int64 + BroadcastID int64 + UserID int64 + Status string + Attempts int32 + LastError string + SentAt pgtype.Timestamptz + NextAttemptAt pgtype.Timestamptz + LeaseToken string + LeaseUntil pgtype.Timestamptz + PrivateMessageID int64 + MessageBoxID int32 + Pts int32 + UpdatedAt pgtype.Timestamptz +} + type BusinessAutomationDelivery struct { OwnerUserID int64 PeerUserID int64 @@ -1324,6 +1374,19 @@ type FileBlob struct { CreatedAt pgtype.Timestamptz } +type GifCatalog struct { + ID int64 + Title string + DocumentID int64 + Enabled bool + SortOrder int32 + CreatedBy string + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + SourceFilename string + Category string +} + type GroupCall struct { CallID int64 AccessHash int64 @@ -1499,6 +1562,7 @@ type MessageBox struct { ReplyToStoryID int32 Effect int64 HideEdited bool + ReplyExternal []byte } type MessageBoxMedium struct { @@ -1796,6 +1860,7 @@ type PrivateMessage struct { SenderDeletePtsCount int32 SenderDeleteDate int32 SenderDeleteMessageIds []byte + ReplyExternal []byte } type PrivateMessageReaction struct { @@ -2642,6 +2707,15 @@ type SuggestedPostApproval struct { FinalServiceMessageID int32 CreatedAt int32 UpdatedAt int32 + LifecycleAttempts int32 + NextAttemptAt int32 + LastLifecycleError string +} + +type SuggestedPostLifecycleWakeup struct { + MonoforumID int64 + SuggestionMessageID int32 + CreatedAt int32 } type TelegramLoginCode struct { @@ -3125,3 +3199,43 @@ type WebviewRequestedButton struct { UsernameRequested bool PhotoRequested bool } + +type WelcomeMessage struct { + ChannelID int64 + ID int32 + CreatorUserID int64 + Date int32 + EditDate int32 + RandomID int64 + Content []byte + CreateFingerprint []byte + Version int64 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type WelcomeMessageDelivery struct { + ID int64 + JoinEventID int64 + ChannelID int64 + TargetUserID int64 + TemplateID int32 + EphemeralID *int32 + JoinedAt int32 + Content []byte + AttemptCount int32 + NextAttemptAt pgtype.Timestamptz + LeaseOwner *string + LeaseExpiresAt pgtype.Timestamptz + DeliveredAt pgtype.Timestamptz + LastError string + CreatedAt pgtype.Timestamptz + ExpiresAt pgtype.Timestamptz +} + +type WelcomeMessagePeer struct { + ChannelID int64 + NextID int32 + Revision int64 + UpdatedAt pgtype.Timestamptz +} diff --git a/internal/store/postgres/sqlcgen/saved_dialog.sql.go b/internal/store/postgres/sqlcgen/saved_dialog.sql.go index d42f8b36..53136e42 100644 --- a/internal/store/postgres/sqlcgen/saved_dialog.sql.go +++ b/internal/store/postgres/sqlcgen/saved_dialog.sql.go @@ -274,6 +274,7 @@ SELECT m.quote_text, m.quote_entities::text AS quote_entities_json, m.quote_offset, + m.reply_external::text AS reply_external_json, m.fwd_from_peer_type, m.fwd_from_peer_id, m.fwd_from_name, @@ -332,6 +333,7 @@ type ListPinnedSavedDialogTopsRow struct { QuoteText string QuoteEntitiesJson string QuoteOffset int32 + ReplyExternalJson string FwdFromPeerType string FwdFromPeerID int64 FwdFromName string @@ -390,6 +392,7 @@ func (q *Queries) ListPinnedSavedDialogTops(ctx context.Context, ownerUserID int &i.QuoteText, &i.QuoteEntitiesJson, &i.QuoteOffset, + &i.ReplyExternalJson, &i.FwdFromPeerType, &i.FwdFromPeerID, &i.FwdFromName, @@ -460,6 +463,7 @@ SELECT m.quote_text, m.quote_entities::text AS quote_entities_json, m.quote_offset, + m.reply_external::text AS reply_external_json, m.fwd_from_peer_type, m.fwd_from_peer_id, m.fwd_from_name, @@ -528,6 +532,7 @@ type ListSavedDialogTopsRow struct { QuoteText string QuoteEntitiesJson string QuoteOffset int32 + ReplyExternalJson string FwdFromPeerType string FwdFromPeerID int64 FwdFromName string @@ -595,6 +600,7 @@ func (q *Queries) ListSavedDialogTops(ctx context.Context, arg ListSavedDialogTo &i.QuoteText, &i.QuoteEntitiesJson, &i.QuoteOffset, + &i.ReplyExternalJson, &i.FwdFromPeerType, &i.FwdFromPeerID, &i.FwdFromName, @@ -673,6 +679,7 @@ SELECT m.quote_text, m.quote_entities::text AS quote_entities_json, m.quote_offset, + m.reply_external::text AS reply_external_json, m.fwd_from_peer_type, m.fwd_from_peer_id, m.fwd_from_name, @@ -737,6 +744,7 @@ type ListSavedDialogTopsByPeersRow struct { QuoteText string QuoteEntitiesJson string QuoteOffset int32 + ReplyExternalJson string FwdFromPeerType string FwdFromPeerID int64 FwdFromName string @@ -795,6 +803,7 @@ func (q *Queries) ListSavedDialogTopsByPeers(ctx context.Context, arg ListSavedD &i.QuoteText, &i.QuoteEntitiesJson, &i.QuoteOffset, + &i.ReplyExternalJson, &i.FwdFromPeerType, &i.FwdFromPeerID, &i.FwdFromName, diff --git a/internal/store/postgres/sqlcgen/user_update_event.sql.go b/internal/store/postgres/sqlcgen/user_update_event.sql.go index de76f2b8..45f38890 100644 --- a/internal/store/postgres/sqlcgen/user_update_event.sql.go +++ b/internal/store/postgres/sqlcgen/user_update_event.sql.go @@ -174,6 +174,7 @@ SELECT COALESCE(m.quote_text, '')::text AS quote_text, COALESCE(m.quote_entities::text, '[]')::text AS quote_entities_json, COALESCE(m.quote_offset, 0)::int AS quote_offset, + COALESCE(m.reply_external, '{}'::jsonb)::text AS reply_external_json, COALESCE(m.fwd_from_peer_type, '')::text AS fwd_from_peer_type, COALESCE(m.fwd_from_peer_id, 0)::bigint AS fwd_from_peer_id, COALESCE(m.fwd_from_name, '')::text AS fwd_from_name, @@ -313,6 +314,7 @@ type BatchListDispatchEventsRow struct { QuoteText string QuoteEntitiesJson string QuoteOffset int32 + ReplyExternalJson string FwdFromPeerType string FwdFromPeerID int64 FwdFromName string @@ -450,6 +452,7 @@ func (q *Queries) BatchListDispatchEvents(ctx context.Context, arg BatchListDisp &i.QuoteText, &i.QuoteEntitiesJson, &i.QuoteOffset, + &i.ReplyExternalJson, &i.FwdFromPeerType, &i.FwdFromPeerID, &i.FwdFromName, @@ -833,6 +836,7 @@ SELECT COALESCE(m.quote_text, '')::text AS quote_text, COALESCE(m.quote_entities::text, '[]')::text AS quote_entities_json, COALESCE(m.quote_offset, 0)::int AS quote_offset, + COALESCE(m.reply_external, '{}'::jsonb)::text AS reply_external_json, COALESCE(m.fwd_from_peer_type, '')::text AS fwd_from_peer_type, COALESCE(m.fwd_from_peer_id, 0)::bigint AS fwd_from_peer_id, COALESCE(m.fwd_from_name, '')::text AS fwd_from_name, @@ -975,6 +979,7 @@ type ListUserUpdateEventsAfterRow struct { QuoteText string QuoteEntitiesJson string QuoteOffset int32 + ReplyExternalJson string FwdFromPeerType string FwdFromPeerID int64 FwdFromName string @@ -1110,6 +1115,7 @@ func (q *Queries) ListUserUpdateEventsAfter(ctx context.Context, arg ListUserUpd &i.QuoteText, &i.QuoteEntitiesJson, &i.QuoteOffset, + &i.ReplyExternalJson, &i.FwdFromPeerType, &i.FwdFromPeerID, &i.FwdFromName, diff --git a/internal/store/postgres/update_event.go b/internal/store/postgres/update_event.go index 4a9f537e..efbaf040 100644 --- a/internal/store/postgres/update_event.go +++ b/internal/store/postgres/update_event.go @@ -352,6 +352,7 @@ func (s *UpdateEventStore) ListAfter(ctx context.Context, userID int64, pts, lim row.QuoteText, row.QuoteEntitiesJson, row.QuoteOffset, + row.ReplyExternalJson, row.FwdFromPeerType, row.FwdFromPeerID, row.FwdFromName, @@ -560,6 +561,7 @@ func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.Ev row.QuoteText, row.QuoteEntitiesJson, row.QuoteOffset, + row.ReplyExternalJson, row.FwdFromPeerType, row.FwdFromPeerID, row.FwdFromName, diff --git a/internal/store/private_send_idempotency.go b/internal/store/private_send_idempotency.go index f1620805..b2a77d95 100644 --- a/internal/store/private_send_idempotency.go +++ b/internal/store/private_send_idempotency.go @@ -210,6 +210,11 @@ func EncodePrivateSendSnapshot(msg domain.Message) ([]byte, error) { if msg.ID <= 0 || msg.UID <= 0 || msg.RandomID == 0 || msg.OwnerUserID == 0 || msg.Pts <= 0 { return nil, fmt.Errorf("private send snapshot: invalid id=%d uid=%d random_id=%d owner=%d pts=%d", msg.ID, msg.UID, msg.RandomID, msg.OwnerUserID, msg.Pts) } + if msg.ReplyTo != nil && msg.ReplyTo.External != nil { + if _, err := domain.EncodeMessageReplyExternal(msg.ReplyTo.External); err != nil { + return nil, fmt.Errorf("private send snapshot reply: %w", err) + } + } raw, err := json.Marshal(privateSendSnapshotEnvelope{Version: sendSnapshotVersion, Message: msg}) if err != nil { return nil, fmt.Errorf("marshal private send snapshot: %w", err) @@ -228,6 +233,11 @@ func DecodePrivateSendSnapshot(raw []byte) (domain.Message, error) { if envelope.Version != sendSnapshotVersion || msg.ID <= 0 || msg.UID <= 0 || msg.RandomID == 0 || msg.OwnerUserID == 0 || msg.Pts <= 0 { return domain.Message{}, fmt.Errorf("private send snapshot: invalid version=%d id=%d uid=%d random_id=%d owner=%d pts=%d", envelope.Version, msg.ID, msg.UID, msg.RandomID, msg.OwnerUserID, msg.Pts) } + if msg.ReplyTo != nil && msg.ReplyTo.External != nil { + if _, err := domain.EncodeMessageReplyExternal(msg.ReplyTo.External); err != nil { + return domain.Message{}, fmt.Errorf("private send snapshot reply: %w", err) + } + } return msg, nil } diff --git a/internal/store/private_send_idempotency_test.go b/internal/store/private_send_idempotency_test.go index 41a7de8c..43f42da3 100644 --- a/internal/store/private_send_idempotency_test.go +++ b/internal/store/private_send_idempotency_test.go @@ -1,11 +1,43 @@ package store import ( + "bytes" "testing" "telesrv/internal/domain" ) +func TestPrivateSendSnapshotRejectsInvalidExternalReply(t *testing.T) { + message := domain.Message{ID: 7, UID: 8, RandomID: 9, OwnerUserID: 10, Pts: 11, + ReplyTo: &domain.MessageReply{MessageID: 1, External: &domain.MessageReplyExternal{ + From: domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeUser, ID: 10}, Date: 12}, Text: "source", + }}, + } + raw, err := EncodePrivateSendSnapshot(message) + if err != nil { + t.Fatal(err) + } + if got, err := DecodePrivateSendSnapshot(raw); err != nil || got.ReplyTo.External.Text != "source" { + t.Fatalf("valid external replay: %+v %v", got, err) + } + for _, mutation := range [][2][]byte{ + {[]byte(`"Date":12`), []byte(`"Date":0`)}, + {[]byte(`"text":"source"`), []byte(`"text":"source","unexpected":true`)}, + } { + corrupt := bytes.Replace(raw, mutation[0], mutation[1], 1) + if bytes.Equal(corrupt, raw) { + t.Fatalf("mutation did not match %s", mutation[0]) + } + if _, err := DecodePrivateSendSnapshot(corrupt); err == nil { + t.Fatal("corrupt external receipt accepted") + } + } + message.ReplyTo.External.From.Date = 0 + if _, err := EncodePrivateSendSnapshot(message); err == nil { + t.Fatal("invalid external receipt written") + } +} + func TestPrivateSendSnapshotIsDeepAndVersioned(t *testing.T) { message := domain.Message{ ID: 7, UID: 8, RandomID: 9, OwnerUserID: 10, diff --git a/internal/store/redisstore/redis.go b/internal/store/redisstore/redis.go index cfb9d0a9..fa369e55 100644 --- a/internal/store/redisstore/redis.go +++ b/internal/store/redisstore/redis.go @@ -6,18 +6,28 @@ package redisstore import ( "context" "fmt" + "time" "github.com/redis/go-redis/v9" ) -// Open 按地址建立 Redis 连接并 ping 验证。 +const defaultOpenPingTimeout = 5 * time.Second + +// Open 按地址建立 Redis 连接并用有界 PING 验证;返回的客户端继续保留 go-redis 默认重试策略。 func Open(ctx context.Context, addr, password string, db int) (*redis.Client, error) { + return open(ctx, addr, password, db, defaultOpenPingTimeout) +} + +func open(ctx context.Context, addr, password string, db int, pingTimeout time.Duration) (*redis.Client, error) { c := redis.NewClient(&redis.Options{ - Addr: addr, - Password: password, - DB: db, + Addr: addr, + Password: password, + DB: db, + ContextTimeoutEnabled: true, }) - if err := c.Ping(ctx).Err(); err != nil { + pingCtx, cancel := context.WithTimeout(ctx, pingTimeout) + defer cancel() + if err := c.Ping(pingCtx).Err(); err != nil { _ = c.Close() return nil, fmt.Errorf("redis ping %s: %w", addr, err) } diff --git a/internal/store/redisstore/redis_test.go b/internal/store/redisstore/redis_test.go new file mode 100644 index 00000000..118a919c --- /dev/null +++ b/internal/store/redisstore/redis_test.go @@ -0,0 +1,72 @@ +package redisstore + +import ( + "context" + "net" + "testing" + "time" +) + +func TestOpenBoundsStalledRedisPing(t *testing.T) { + address, stop := startStalledRedis(t) + defer stop() + + started := time.Now() + client, err := open(context.Background(), address, "", 0, 50*time.Millisecond) + if client != nil { + _ = client.Close() + } + if err == nil { + t.Fatal("open() succeeded against a stalled Redis endpoint") + } + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("open() took %s, want at most 1s", elapsed) + } +} + +func TestOpenPreservesEarlierCallerDeadline(t *testing.T) { + address, stop := startStalledRedis(t) + defer stop() + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + started := time.Now() + client, err := open(ctx, address, "", 0, time.Second) + if client != nil { + _ = client.Close() + } + if err == nil { + t.Fatal("open() succeeded against a stalled Redis endpoint") + } + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("open() took %s, want at most 1s", elapsed) + } +} + +func startStalledRedis(t *testing.T) (string, func()) { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + stop := make(chan struct{}) + done := make(chan struct{}) + go func() { + defer close(done) + for { + conn, err := listener.Accept() + if err != nil { + return + } + go func() { + <-stop + _ = conn.Close() + }() + } + }() + return listener.Addr().String(), func() { + close(stop) + _ = listener.Close() + <-done + } +} diff --git a/internal/telegramloginhttp/handler.go b/internal/telegramloginhttp/handler.go index f2851c07..4d1b319a 100644 --- a/internal/telegramloginhttp/handler.go +++ b/internal/telegramloginhttp/handler.go @@ -71,7 +71,7 @@ func NewHandler(cfg Config) (*Handler, error) { return nil, errors.New("telegram login HTTP dependencies are incomplete") } if strings.TrimSpace(cfg.AppName) == "" { - cfg.AppName = branding.ProductName + cfg.AppName = branding.ProductName() } if cfg.Logger == nil { cfg.Logger = zap.NewNop() diff --git a/internal/transport/metadata.go b/internal/transport/metadata.go new file mode 100644 index 00000000..5be4557f --- /dev/null +++ b/internal/transport/metadata.go @@ -0,0 +1,28 @@ +// Package transport holds neutral transport metadata shared across the +// protocol/edge boundary and the RPC business layer. It must not import any +// business package (internal/rpc, internal/app, ...), so that mtprotoedge can +// produce transport facts without depending on the RPC implementation. +package transport + +import "context" + +type ctxKey int + +const ( + clientIPKey ctxKey = iota +) + +// WithClientIP records the client remote IP as neutral transport metadata. +// An empty ip is ignored. +func WithClientIP(ctx context.Context, ip string) context.Context { + if ip == "" { + return ctx + } + return context.WithValue(ctx, clientIPKey, ip) +} + +// ClientIPFrom returns the client remote IP carried in ctx, if set. +func ClientIPFrom(ctx context.Context) (string, bool) { + v, ok := ctx.Value(clientIPKey).(string) + return v, ok && v != "" +} diff --git a/internal/transport/metadata_test.go b/internal/transport/metadata_test.go new file mode 100644 index 00000000..658d3eb8 --- /dev/null +++ b/internal/transport/metadata_test.go @@ -0,0 +1,27 @@ +package transport + +import ( + "context" + "testing" +) + +func TestWithClientIPRoundTrip(t *testing.T) { + ctx := WithClientIP(context.Background(), "203.0.113.7") + ip, ok := ClientIPFrom(ctx) + if !ok || ip != "203.0.113.7" { + t.Fatalf("round-trip mismatch: got %q ok=%v", ip, ok) + } +} + +func TestWithClientIPEmptyIgnored(t *testing.T) { + ctx := WithClientIP(context.Background(), "") + if _, ok := ClientIPFrom(ctx); ok { + t.Fatalf("empty ip must not be stored") + } +} + +func TestClientIPFromNotSet(t *testing.T) { + if _, ok := ClientIPFrom(context.Background()); ok { + t.Fatalf("no ip set, ok must be false") + } +} diff --git a/internal/web/server.go b/internal/web/server.go index f4d3d812..af77b3cb 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -130,7 +130,7 @@ func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) { return nil, fmt.Errorf("public Web sticker set resolver is nil") } if strings.TrimSpace(cfg.AppName) == "" { - cfg.AppName = branding.ProductName + cfg.AppName = branding.ProductName() } if cfg.PublicBaseURL, err = links.ValidateBaseURL(cfg.PublicBaseURL); err != nil { return nil, fmt.Errorf("public base URL: %w", err) diff --git a/internal/webauthn/webauthn.go b/internal/webauthn/webauthn.go index 7c1bfd99..82d44bd5 100644 --- a/internal/webauthn/webauthn.go +++ b/internal/webauthn/webauthn.go @@ -101,7 +101,7 @@ func BuildRegistrationOptions(p RegistrationParams) ([]byte, error) { exclude = append(exclude, map[string]any{"type": "public-key", "id": b64.EncodeToString(id)}) } pub := map[string]any{ - "rp": map[string]any{"id": p.RPID, "name": orDefault(p.RPName, branding.ProductName)}, + "rp": map[string]any{"id": p.RPID, "name": orDefault(p.RPName, branding.ProductName())}, "user": map[string]any{"id": b64.EncodeToString(p.UserID), "name": p.UserName, "displayName": orDefault(p.UserDisplay, p.UserName)}, "challenge": b64.EncodeToString(p.Challenge), "pubKeyCredParams": []map[string]any{