Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -488,8 +488,8 @@ TD1: = T{N} keyboard + dev-commands в menu (bot.py)
- T0: анонимный / без профиля (UITier.T0)
- T1: привязан к Aisystant, нет активной подписки БР (UITier.T1)
- T2: подписка «Инженерия интеллекта» на Aisystant (UITier.T2_LEARNING)
- T3: T2 + ЦД подключён
- T4: T3 + GitHub подключён
- T3: T2 + подключён любой AI-клиент (claude.ai / Claude Code / VS Code / Telegram) — WP-406 Ф13. **НЕ** «ЦД подключён»: сигнал AI-клиента бот читает через `_is_ai_client_connected` (Telegram OAuth ИЛИ persona traits `mcp_connected`/`tier>=T3`, выставляет шлюз при claude.ai OAuth)
- T4: T3 + GitHub подключён (требует T3 — `is_github AND is_ai_client`)
- TD1: DEVELOPER_CHAT_ID
- TG Stars = донаты (благодарность), НЕ влияют на тир/доступ
- Тир падает до T1 при истечении подписки БР (WP-210 Ф2a)
Expand Down
5 changes: 3 additions & 2 deletions core/platform_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,9 @@ async def _check_subscription(chat_id: int) -> bool:


async def _check_dt(chat_id: int) -> bool:
from core.tier_detector import _is_dt_connected
return await _is_dt_connected(chat_id)
# AI-client connection = T3 condition (WP-406 Ф13): any interface, not only Digital Twin.
from core.tier_detector import _is_ai_client_connected
return await _is_ai_client_connected(chat_id)


async def _check_github(chat_id: int) -> bool:
Expand Down
2 changes: 1 addition & 1 deletion core/tier_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
T0: not linked to Aisystant (brand new user)
T1: linked to Aisystant, no БР subscription
T2: Aisystant «Инженерия интеллекта» subscription active
T3: T2 + DT connected
T3: T2 + AI client connected (any interface: claude.ai / Claude Code / VS Code / Telegram) — WP-406 Ф13
T4: T3 + GitHub connected
T5: admin (DEVELOPER_CHAT_ID) — menu set in bot.py, NOT here

Expand Down
74 changes: 64 additions & 10 deletions core/tier_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
"""
UI Tier detection — subscription + connections.

Source-of-truth: WP-52 + WP-79 + WP-85
Source-of-truth: WP-52 + WP-79 + WP-85 + WP-406 Ф13/Ф14 (T3 semantics)

Tier model (cumulative, payment-first):
T0 (0): not linked to Aisystant
T1 (1): linked, no active БР subscription
T2: Aisystant «Инженерия интеллекта» subscription active
T3: T2 + Digital Twin connected
T3: T2 + AI client connected (any interface: claude.ai / Claude Code / VS Code / Telegram).
WP-406 Ф13 consensus: T3 condition is "any AI client connected", NOT "Digital Twin".
The platform records the signal in persona traits (tier>=T3 or mcp_connected) when a
user connects via claude.ai; the bot must honor it so it never downgrades such a user.
T4: T3 + GitHub connected
T5: platform admin (DEVELOPER_CHAT_ID)

Expand Down Expand Up @@ -87,18 +90,22 @@ async def detect_ui_tier(chat_id: int) -> int:
if not aisystant_id:
new_tier = UITier.T0
else:
# Parallel: subscription check (Aisystant HTTP) + github (secrets pool) + dt (in-memory).
# Parallel: subscription check (Aisystant HTTP) + github (secrets pool) + AI-client.
# Все три нужны только для T1+; запускаем параллельно после подтверждения aisystant_id.
has_sub, is_github, is_dt = await asyncio.gather(
has_sub, is_github, is_ai_client = await asyncio.gather(
_has_active_subscription(chat_id, aisystant_id),
_is_github_connected(chat_id),
_is_dt_connected(chat_id),
_is_ai_client_connected(chat_id),
)
# Subscription first — its expiry always downgrades (no traits can override it).
if not has_sub:
new_tier = UITier.T1
elif is_github:
# DRIFT-3 fix (WP-406 Ф14): T4 requires T3 — GitHub alone never grants T4.
elif is_github and is_ai_client:
new_tier = UITier.T4_CREATION
elif is_dt:
# DRIFT-1 fix (WP-406 Ф14): T3 = any AI client connected (Telegram OAuth OR
# claude.ai/Claude Code signal recorded in persona traits).
elif is_ai_client:
new_tier = UITier.T3_PERSONALIZATION
else:
new_tier = UITier.T2_LEARNING
Expand Down Expand Up @@ -221,12 +228,59 @@ async def _is_github_connected(chat_id: int) -> bool:
return False


async def _is_dt_connected(chat_id: int) -> bool:
"""Check if Digital Twin is connected (has valid Ory tokens via Gateway)."""
async def _is_ai_client_connected(chat_id: int) -> bool:
"""Check if the user has ANY AI client connected (T3 condition, WP-406 Ф13).

Two signal sources, unioned:
1. Telegram-side gateway OAuth (in-memory) — set when the user runs /connect_external.
2. Persona traits signal — set by the platform gateway when the user connects via
claude.ai / Claude Code (POST /tier/mcp-signal writes tier=T3 + mcp_connected=true).

Source 2 is why a claude.ai-only user must not be downgraded by the bot: the platform
already recorded the AI-client connection in persona traits.

TEMP (WP-406 Ф14): reading persona traits makes the bot (authoritative tier computer
per WP-392) depend on a derived store. This is a deliberate stopgap until WP-430
consolidates tier ownership into the platform (bot becomes a pure reader).
"""
try:
from clients.gateway_mcp import gateway_mcp
return gateway_mcp.is_connected(chat_id)
if gateway_mcp.is_connected(chat_id):
return True
except Exception:
pass
return await _persona_has_ai_signal(chat_id)


async def _persona_has_ai_signal(chat_id: int) -> bool:
"""Read persona traits to detect a platform-recorded AI-client connection.

True if traits.mcp_connected is true OR traits.tier already at T3/T4 — both are set by
the gateway mcp-signal on claude.ai/Claude Code OAuth. Fails closed (False) on any error.
"""
try:
ory_id = await _get_ory_id(chat_id)
if not ory_id:
return False
from db.connection import get_persona_pool
persona_pool = await get_persona_pool()
async with persona_pool.acquire() as pconn:
row = await pconn.fetchrow(
"""
SELECT traits->>'mcp_connected' AS mcp_connected,
traits->>'tier' AS tier
FROM public.ory_identity
WHERE account_id = $1::uuid
""",
ory_id,
)
if not row:
return False
if str(row["mcp_connected"]).lower() == "true":
return True
return row["tier"] in ("T3", "T4")
except Exception as e:
logger.warning(f"[Tier] persona AI-signal read failed for {chat_id}: {e}")
return False


Expand Down
Loading