Auth: multibot TG MAX logging fix 500

This commit is contained in:
Fedor
2026-02-27 07:48:16 +03:00
parent b3a7396d32
commit 62fc57f108
6 changed files with 417 additions and 78 deletions

View File

@@ -5,6 +5,7 @@
"""
import logging
import os
import uuid
from typing import Optional, Any, Dict
@@ -57,6 +58,8 @@ async def auth_universal(request: AuthUniversalRequest):
if not init_data:
raise HTTPException(status_code=400, detail="init_data обязателен")
logger.debug("[AUTH] init_data length=%s", len(init_data))
# 1) Извлечь channel_user_id из init_data
channel_user_id: Optional[str] = None
if channel == "telegram":
@@ -77,7 +80,8 @@ async def auth_universal(request: AuthUniversalRequest):
if not channel_user_id:
raise HTTPException(status_code=400, detail="Не удалось получить channel_user_id из init_data")
webhook_url = (getattr(settings, "n8n_auth_webhook", None) or "").strip()
# URL из settings или напрямую из env (если в config нет поля n8n_auth_webhook)
webhook_url = (getattr(settings, "n8n_auth_webhook", None) or os.environ.get("N8N_AUTH_WEBHOOK") or "").strip()
if not webhook_url:
logger.error("N8N_AUTH_WEBHOOK не задан в .env")
raise HTTPException(status_code=503, detail="Сервис авторизации не настроен")
@@ -88,6 +92,9 @@ async def auth_universal(request: AuthUniversalRequest):
"channel_user_id": channel_user_id,
"init_data": init_data,
}
# При мультиботе (Telegram или MAX) передаём bot_id (из extract_telegram_user / extract_max_user)
if user.get("bot_id"):
payload["bot_id"] = user["bot_id"]
logger.info("[AUTH] Вызов N8N_AUTH_WEBHOOK: channel=%s, channel_user_id=%s", channel, channel_user_id)
try:
async with httpx.AsyncClient(timeout=30.0) as client:
@@ -129,8 +136,13 @@ async def auth_universal(request: AuthUniversalRequest):
data = {}
logger.warning("[AUTH] парсинг: first без json/success/unified_id, data={}")
elif isinstance(raw, dict):
data = raw
logger.info("[AUTH] парсинг: raw — dict, keys=%s", list(data.keys()))
# n8n Respond to Webhook может вернуть { "json": { success, phone, ... } }
if "json" in raw and isinstance(raw.get("json"), dict):
data = raw["json"]
logger.info("[AUTH] парсинг: raw — dict с json, data keys=%s", list(data.keys()))
else:
data = raw
logger.info("[AUTH] парсинг: raw — dict, keys=%s", list(data.keys()))
else:
data = {}
logger.warning("[AUTH] парсинг: неизвестный формат raw, data={}")
@@ -155,11 +167,13 @@ async def auth_universal(request: AuthUniversalRequest):
)
if data.get("success") is False:
# Ошибка/неуспех без требования контакта — не закрываем приложение, показываем сообщение
logger.info("[AUTH] ответ: success=false, need_contact=false → показать ошибку")
msg = data.get("message") or "Ошибка авторизации."
logger.info("[AUTH] ответ: success=false, need_contact=false → показать ошибку: message=%s", msg)
logger.debug("[AUTH] полный data при success=false: %s", data)
return AuthUniversalResponse(
success=False,
need_contact=False,
message=(data.get("message") or "Ошибка авторизации."),
message=msg,
)
# 4) Успех: unified_id и т.д.
@@ -172,13 +186,20 @@ async def auth_universal(request: AuthUniversalRequest):
return AuthUniversalResponse(success=False, need_contact=True, message="Контакт не найден.")
# 5) Записать сессию в Redis по session:{channel}:{channel_user_id} и session:{session_token}
_phone = data.get("phone") or ((data.get("result") or {}).get("phone") if isinstance(data.get("result"), dict) else None)
_contact_id = data.get("contact_id") or ((data.get("result") or {}).get("contact_id") if isinstance(data.get("result"), dict) else None)
if _phone is not None and not isinstance(_phone, str):
_phone = str(_phone).strip() or None
elif isinstance(_phone, str):
_phone = _phone.strip() or None
session_data = {
"unified_id": unified_id,
"phone": data.get("phone") or (data.get("result") or {}).get("phone") if isinstance(data.get("result"), dict) else None,
"contact_id": data.get("contact_id") or (data.get("result") or {}).get("contact_id") if isinstance(data.get("result"), dict) else None,
"phone": _phone,
"contact_id": _contact_id,
"has_drafts": data.get("has_drafts", False) or (data.get("result") or {}).get("has_drafts", False) if isinstance(data.get("result"), dict) else False,
"chat_id": channel_user_id,
}
logger.info("[AUTH] session_data: unified_id=%s, phone=%s", unified_id, session_data.get("phone"))
try:
await session_api.set_session_by_channel_user(channel, channel_user_id, session_data)
except HTTPException:

View File

@@ -210,10 +210,25 @@ class Settings(BaseSettings):
# ============================================
telegram_bot_token: str = "" # Токен бота для проверки initData WebApp
def get_telegram_bot_tokens(self) -> List[tuple]:
"""Список (bot_id, token) для проверки подписи Telegram initData. Один токен — [('default', token)]."""
token = (self.telegram_bot_token or "").strip()
if token:
return [("default", token)]
return []
# ============================================
# MAX (мессенджер) — Mini App auth
# ============================================
max_bot_token: str = "" # Токен бота MAX для проверки initData WebApp
def get_max_bot_tokens(self) -> List[tuple]:
"""Список (bot_id, token) для проверки подписи MAX initData. Один токен — [('default', token)]."""
token = (self.max_bot_token or "").strip()
if token:
return [("default", token)]
return []
n8n_max_auth_webhook: str = "" # Webhook n8n: max_user_id → unified_id, contact_id, has_drafts
n8n_auth_webhook: str = "" # Универсальный auth: channel + channel_user_id + init_data → unified_id, phone, contact_id, has_drafts

View File

@@ -22,12 +22,18 @@ from .services.s3_service import s3_service
from .api import sms, claims, policy, upload, draft, events, n8n_proxy, session, documents, banks, telegram_auth, max_auth, auth2, auth_universal, documents_draft_open, profile, support
from .api import debug_session
# Настройка логирования
# Настройка логирования (уровень из config: LOG_LEVEL=DEBUG для отладки)
import sys
_level = getattr(logging, (getattr(get_settings(), "log_level", None) or "INFO").upper(), logging.INFO)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
level=_level,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
stream=sys.stdout,
)
# Применяем уровень ко всем логгерам приложения
logging.getLogger("app").setLevel(_level)
logger = logging.getLogger(__name__)
logger.info("Backend log level: %s", logging.getLevelName(_level))
DEBUG_SESSION_ID = "2a4d38"
# В прод-контейнере гарантированно доступен /app/logs (volume ./backend/logs:/app/logs)

View File

@@ -29,10 +29,28 @@ def _parse_init_data(init_data: str) -> Dict[str, Any]:
return data
def _verify_with_token(parsed: Dict[str, Any], data_check_string: str, received_hash: str, bot_token: str) -> bool:
"""Проверяет подпись initData одним MAX ботом. Возвращает True, если подпись верна."""
secret_key = hmac.new(
key="WebAppData".encode("utf-8"),
msg=bot_token.encode("utf-8"),
digestmod=hashlib.sha256,
).digest()
calculated_hash = hmac.new(
key=secret_key,
msg=data_check_string.encode("utf-8"),
digestmod=hashlib.sha256,
).hexdigest()
return hmac.compare_digest(calculated_hash, received_hash)
def verify_max_init_data(init_data: str) -> Dict[str, Any]:
"""
Проверяет подпись initData по правилам MAX (аналогично Telegram).
Поддерживает один бот (MAX_BOT_TOKEN) или несколько (MAX_BOT_TOKENS — JSON).
Перебирает токены, пока один не подойдёт; в результат добавляется ключ bot_id.
- secret_key = HMAC_SHA256("WebAppData", BOT_TOKEN)
- data_check_string: пары key=value без hash, сортировка по key, разделитель \n
- hex(HMAC_SHA256(secret_key, data_check_string)) === hash из initData
@@ -41,9 +59,9 @@ def verify_max_init_data(init_data: str) -> Dict[str, Any]:
logger.warning("[MAX] verify_max_init_data: init_data пустой")
raise MaxAuthError("init_data is empty")
bot_token = (getattr(settings, "max_bot_token", None) or "").strip()
if not bot_token:
logger.warning("[MAX] MAX_BOT_TOKEN не задан в .env")
tokens_list = settings.get_max_bot_tokens()
if not tokens_list:
logger.warning("[MAX] Ни MAX_BOT_TOKEN, ни MAX_BOT_TOKENS не заданы в .env")
raise MaxAuthError("MAX bot token is not configured")
parsed = _parse_init_data(init_data)
@@ -54,29 +72,17 @@ def verify_max_init_data(init_data: str) -> Dict[str, Any]:
logger.warning("[MAX] В initData отсутствует поле hash")
raise MaxAuthError("Missing hash in init_data")
data_check_items = []
for key in sorted(parsed.keys()):
value = parsed[key]
data_check_items.append(f"{key}={value}")
data_check_items = [f"{k}={parsed[k]}" for k in sorted(parsed.keys())]
data_check_string = "\n".join(data_check_items)
secret_key = hmac.new(
key="WebAppData".encode("utf-8"),
msg=bot_token.encode("utf-8"),
digestmod=hashlib.sha256,
).digest()
for bot_id, bot_token in tokens_list:
if _verify_with_token(parsed, data_check_string, received_hash, bot_token):
parsed["bot_id"] = bot_id
logger.info("[MAX] Подпись MAX initData проверена, bot_id=%s", bot_id)
return parsed
calculated_hash = hmac.new(
key=secret_key,
msg=data_check_string.encode("utf-8"),
digestmod=hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(calculated_hash, received_hash):
logger.warning("[MAX] Подпись initData не совпадает")
raise MaxAuthError("Invalid init_data hash")
return parsed
logger.warning("[MAX] Подпись initData не совпадает ни с одним из токенов MAX ботов")
raise MaxAuthError("Invalid init_data hash")
def extract_max_user(init_data: str) -> Dict[str, Any]:
@@ -100,7 +106,7 @@ def extract_max_user(init_data: str) -> Dict[str, Any]:
if "id" not in user_obj:
raise MaxAuthError("MAX user.id is missing")
return {
result = {
"max_user_id": str(user_obj.get("id")),
"username": user_obj.get("username"),
"first_name": user_obj.get("first_name"),
@@ -109,3 +115,6 @@ def extract_max_user(init_data: str) -> Dict[str, Any]:
"photo_url": user_obj.get("photo_url"),
"raw": user_obj,
}
if "bot_id" in parsed:
result["bot_id"] = parsed["bot_id"]
return result

View File

@@ -0,0 +1,214 @@
/**
* SupportForm — форма обращения в поддержку (переиспользуется на странице /support и в модалке карточки жалобы).
* Отправка: POST /api/v1/support (multipart). Лимиты вложений опционально из GET /api/v1/support/limits.
*/
import { useEffect, useState } from 'react';
import { Button, Form, Input, message as antMessage } from 'antd';
import { Paperclip, X } from 'lucide-react';
const { TextArea } = Input;
export interface SupportLimits {
max_count: number;
max_size_per_file: number;
allowed_types: string;
unlimited: boolean;
}
export interface SupportFormProps {
/** Привязка к обращению (из карточки жалобы) */
claimId?: string;
/** bar | complaint_card */
source?: 'bar' | 'complaint_card';
/** После успешной отправки */
onSuccess?: () => void;
/** Компактный вид (модалка) */
compact?: boolean;
/** Скрыть заголовок «По обращению №…» когда передан claimId */
hideClaimLabel?: boolean;
}
function getSessionToken(): string | null {
if (typeof sessionStorage !== 'undefined') {
const s = sessionStorage.getItem('session_token');
if (s) return s;
}
if (typeof localStorage !== 'undefined') {
return localStorage.getItem('session_token');
}
return null;
}
export default function SupportForm({
claimId,
source = 'bar',
onSuccess,
compact = false,
hideClaimLabel = false,
}: SupportFormProps) {
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
const [limits, setLimits] = useState<SupportLimits | null>(null);
const [files, setFiles] = useState<File[]>([]);
const [fileInputKey, setFileInputKey] = useState(0);
useEffect(() => {
fetch('/api/v1/support/limits')
.then((res) => (res.ok ? res.json() : null))
.then((data: SupportLimits | null) => {
if (data) setLimits(data);
})
.catch(() => {});
}, []);
const canAddFile = (): boolean => {
if (!limits || limits.unlimited) return true;
return files.length < limits.max_count;
};
const isFileSizeOk = (file: File): boolean => {
if (!limits || limits.unlimited || limits.max_size_per_file <= 0) return true;
return file.size <= limits.max_size_per_file;
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = Array.from(e.target.files || []);
if (!limits?.unlimited && limits && limits.max_count > 0) {
const remaining = limits.max_count - files.length;
if (selected.length > remaining) {
antMessage.warning(`Можно прикрепить не более ${limits.max_count} файлов`);
setFileInputKey((k) => k + 1);
return;
}
}
const ok: File[] = [];
for (const f of selected) {
if (!isFileSizeOk(f)) {
antMessage.warning(`Файл «${f.name}» превышает допустимый размер`);
continue;
}
ok.push(f);
}
setFiles((prev) => [...prev, ...ok].slice(0, limits?.unlimited ? 999 : (limits?.max_count || 999)));
setFileInputKey((k) => k + 1);
e.target.value = '';
};
const removeFile = (index: number) => {
setFiles((prev) => prev.filter((_, i) => i !== index));
};
const handleSubmit = async () => {
const values = await form.validateFields().catch(() => null);
if (!values || !values.message?.trim()) return;
const token = getSessionToken();
if (!token) {
antMessage.error('Сессия не найдена. Войдите снова.');
return;
}
const fd = new FormData();
fd.append('message', values.message.trim());
if (values.subject?.trim()) fd.append('subject', values.subject.trim());
fd.append('source', source);
fd.append('session_token', token);
if (claimId) fd.append('claim_id', claimId);
files.forEach((file, i) => {
fd.append(`attachments[${i}]`, file, file.name);
});
setSubmitting(true);
try {
const res = await fetch('/api/v1/support', {
method: 'POST',
body: fd,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || res.statusText || 'Ошибка отправки');
}
antMessage.success('Запрос отправлен! Мы свяжемся с вами в ближайшее время.');
form.resetFields();
setFiles([]);
setFileInputKey((k) => k + 1);
onSuccess?.();
} catch (err) {
antMessage.error(err instanceof Error ? err.message : 'Не удалось отправить запрос. Попробуйте позже.');
} finally {
setSubmitting(false);
}
};
const limitHint =
limits && !limits.unlimited
? `Макс. ${limits.max_count || '—'} файл(ов)${limits.max_size_per_file ? `, до ${Math.round(limits.max_size_per_file / 1024 / 1024)} МБ каждый` : ''}${limits.allowed_types ? `. Типы: ${limits.allowed_types}` : ''}`
: null;
return (
<div className={compact ? 'support-form support-form--compact' : 'support-form'}>
{claimId && !hideClaimLabel && (
<p style={{ marginBottom: 12, color: '#666', fontSize: 13 }}>По обращению {claimId}</p>
)}
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item
name="message"
label="Сообщение"
rules={[{ required: true, message: 'Введите текст обращения' }]}
>
<TextArea rows={compact ? 3 : 5} placeholder="Опишите вопрос или проблему..." maxLength={5000} showCount />
</Form.Item>
<Form.Item name="subject" label="Тема (необязательно)">
<Input placeholder="Краткая тема" maxLength={200} />
</Form.Item>
<Form.Item label="Прикрепить файлы">
{limitHint && <p style={{ fontSize: 12, color: '#888', marginBottom: 8 }}>{limitHint}</p>}
<input
key={fileInputKey}
type="file"
multiple
style={{ display: 'none' }}
id="support-attachments-input"
onChange={handleFileChange}
/>
<label htmlFor="support-attachments-input">
<Button
type="button"
icon={<Paperclip size={16} style={{ verticalAlign: 'middle', marginRight: 6 }} />}
disabled={!canAddFile()}
onClick={() => document.getElementById('support-attachments-input')?.click()}
>
Прикрепить файлы
</Button>
</label>
{files.length > 0 && (
<ul style={{ marginTop: 8, paddingLeft: 20 }}>
{files.map((f, i) => (
<li key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{f.name}</span>
<button
type="button"
aria-label="Удалить"
onClick={() => removeFile(i)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4 }}
>
<X size={14} />
</button>
</li>
))}
</ul>
)}
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={submitting} block={compact}>
Отправить
</Button>
</Form.Item>
</Form>
</div>
);
}

View File

@@ -12,8 +12,10 @@ import {
ClipboardList,
FileWarning,
MessageCircle,
Scale,
} from 'lucide-react';
import './HelloAuth.css';
import { miniappLog, miniappSendLogs } from '../utils/miniappLogger';
type Status = 'idle' | 'loading' | 'success' | 'error';
@@ -24,6 +26,8 @@ interface HelloAuthProps {
const INIT_DATA_WAIT_MS = 5500;
const INIT_DATA_POLL_MS = 200;
const INIT_DATA_BG_RECOVERY_MS = 15000;
const INIT_DATA_BG_TICK_MS = 250;
export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps) {
const [status, setStatus] = useState<Status>('idle');
@@ -59,6 +63,67 @@ export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps
return null;
};
const authWithInitData = async (channel: 'telegram' | 'max', initData: string): Promise<'success' | 'need_contact' | 'error'> => {
miniappLog('auth_start', { channel, initDataLen: initData?.length ?? 0 });
const res = await fetch('/api/v1/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel, init_data: initData }),
});
const data: Record<string, unknown> = await res.json().catch((e) => {
miniappLog('auth_json_error', { err: String(e), status: res.status });
return {};
});
miniappLog('auth_response', {
status: res.status,
ok: res.ok,
keys: Object.keys(data),
success: data.success,
need_contact: data.need_contact,
message: data.message,
detail: data.detail,
});
const needContact = data?.need_contact === true || data?.need_contact === 'true' || data?.need_contact === 1;
if (needContact) {
const webApp = channel === 'telegram' ? (window as any).Telegram?.WebApp : (window as any).WebApp;
const doClose = () => {
try {
if (typeof webApp?.close === 'function') webApp.close();
else if (typeof webApp?.postEvent === 'function') webApp.postEvent('web_app_close');
} catch (_) {}
};
doClose();
setTimeout(doClose, 200);
return 'need_contact';
}
if (res.ok && data.success) {
const token = data.session_token as string | undefined;
if (token) {
try {
sessionStorage.setItem('session_token', token);
localStorage.setItem('session_token', token); // запас для TG: WebView иногда теряет sessionStorage при переходах
} catch (_) {}
}
setGreeting('Привет!');
const tgUser = (window as any).Telegram?.WebApp?.initDataUnsafe?.user;
const maxUser = (window as any).WebApp?.initDataUnsafe?.user;
const user = tgUser || maxUser;
if (user?.first_name) setGreeting(`Привет, ${user.first_name}!`);
const avatarUrl = user?.photo_url || (data.avatar_url as string);
if (avatarUrl) {
setAvatar(avatarUrl);
localStorage.setItem('user_avatar_url', avatarUrl);
onAvatarChange?.(avatarUrl);
}
setStatus('success');
return 'success';
}
setError((data.message as string) || (data.detail as string) || 'Ошибка авторизации');
setStatus('error');
void miniappSendLogs('auth_error');
return 'error';
};
const tryAuth = async () => {
setStatus('loading');
setNoInitDataAfterTimeout(false);
@@ -85,55 +150,63 @@ export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps
}
}
if (channelInit) {
const { channel, initData } = channelInit;
const res = await fetch('/api/v1/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel, init_data: initData }),
});
const data: Record<string, unknown> = await res.json().catch(() => ({}));
const needContact = data?.need_contact === true || data?.need_contact === 'true' || data?.need_contact === 1;
if (needContact) {
const webApp = channel === 'telegram' ? (window as any).Telegram?.WebApp : (window as any).WebApp;
const doClose = () => {
try {
if (typeof webApp?.close === 'function') webApp.close();
else if (typeof webApp?.postEvent === 'function') webApp.postEvent('web_app_close');
} catch (_) {}
};
doClose();
setTimeout(doClose, 200);
return;
}
if (res.ok && data.success) {
const token = data.session_token as string | undefined;
if (token) {
try {
sessionStorage.setItem('session_token', token);
} catch (_) {}
}
setGreeting('Привет!');
const tgUser = (window as any).Telegram?.WebApp?.initDataUnsafe?.user;
const maxUser = (window as any).WebApp?.initDataUnsafe?.user;
const user = tgUser || maxUser;
if (user?.first_name) setGreeting(`Привет, ${user.first_name}!`);
const avatarUrl = user?.photo_url || (data.avatar_url as string);
if (avatarUrl) {
setAvatar(avatarUrl);
localStorage.setItem('user_avatar_url', avatarUrl);
onAvatarChange?.(avatarUrl);
}
setStatus('success');
return;
}
setError((data.message as string) || (data.detail as string) || 'Ошибка авторизации');
setStatus('error');
const result = await authWithInitData(channelInit.channel, channelInit.initData);
if (result === 'success' || result === 'need_contact' || result === 'error') return;
return;
}
// 2) initData не появился за таймаут
// 2) initData не появился за таймаут — пробуем восстановить сессию по session_token (после обновления страницы)
const likelyMiniapp = window.location.href.includes('tgWebAppData') || window.location.href.includes('tgWebAppVersion') || !!(window as any).WebApp || !!(window as any).Telegram?.WebApp;
if (likelyMiniapp) {
let token: string | null = null;
try {
token = sessionStorage.getItem('session_token') || localStorage.getItem('session_token');
} catch (_) {}
if (token) {
try {
const verifyRes = await fetch('/api/v1/session/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_token: token }),
});
const verifyData = await verifyRes.json().catch(() => ({}));
if (verifyData?.valid === true) {
setGreeting('Привет!');
const tgUser = (window as any).Telegram?.WebApp?.initDataUnsafe?.user;
const maxUser = (window as any).WebApp?.initDataUnsafe?.user;
const user = tgUser || maxUser;
if (user?.first_name) setGreeting(`Привет, ${user.first_name}!`);
setStatus('success');
return;
}
} catch (_) {}
}
// 2.5) TG после refresh иногда отдаёт initData с задержкой.
// Фоном ждём ещё немного и тихо повторяем auth, не показывая сразу экран ошибки.
const bgStart = Date.now();
const bgDeadline = bgStart + INIT_DATA_BG_RECOVERY_MS;
while (Date.now() < bgDeadline) {
await new Promise((r) => setTimeout(r, INIT_DATA_BG_TICK_MS));
const lateChannelInit = getChannelAndInitData();
if (!lateChannelInit) continue;
miniappLog('hello_init_data_recovered_bg', {
waited_ms: Date.now() - bgStart,
channel: lateChannelInit.channel,
});
const result = await authWithInitData(lateChannelInit.channel, lateChannelInit.initData);
if (result === 'success' || result === 'need_contact' || result === 'error') return;
}
const ctx = {
url: window.location.href,
hasTgWebApp: !!(window as any).Telegram?.WebApp,
hasMaxWebApp: !!(window as any).WebApp,
tgInitDataLen: typeof (window as any).Telegram?.WebApp?.initData === 'string' ? (window as any).Telegram.WebApp.initData.length : 0,
maxInitDataLen: typeof (window as any).WebApp?.initData === 'string' ? (window as any).WebApp.initData.length : 0,
};
miniappLog('hello_no_init_data_after_timeout', ctx);
miniappSendLogs('no_init_data_after_timeout').catch(() => {});
setNoInitDataAfterTimeout(true);
setStatus('idle');
return;
@@ -255,6 +328,7 @@ if (data.avatar_url) {
const tiles: Array<{ title: string; icon: typeof User; color: string; href?: string }> = [
{ title: 'Мои обращения', icon: ClipboardList, color: '#6366F1', href: '/' },
{ title: 'Подать жалобу', icon: FileWarning, color: '#EA580C', href: '/new' },
{ title: 'Экспертиза', icon: Scale, color: '#0EA5E9' },
{ title: 'Консультации', icon: MessageCircle, color: '#8B5CF6' },
{ title: 'Членство', icon: IdCard, color: '#10B981' },
{ title: 'Достижения', icon: Trophy, color: '#F59E0B' },