Skip to content

Commit a230722

Browse files
committed
refactor(settings): remove drafts, fix global ignore, paginate /chats
- Remove drafts_enabled setting from UI, handler, utils, schema, README, system_messages - Fix is_chat_ignored: global auto_reply=-1 now blocks all chats (per-chat override takes priority) - Add pagination to /chats: _get_relevant_dialogs returns full sorted list, _build_styles_keyboard slices to visible_count with Show more button - Register on_chats_more_callback handler for chatsmore: pattern in bot.py - Show only per-chat auto-reply override icons in chat list (not global effective) - Fix x402gate dashboard: call dash_stats.update_balance immediately after topup to prevent Topped Up miscalculation - Fix trailing whitespace in clients/x402gate/__init__.py - Update tests: remove drafts toggle tests, add global ignore / per-chat override / pagination / show-more tests - Update test_database_users to use style instead of drafts_enabled in merge test
1 parent 55cca31 commit a230722

File tree

12 files changed

+211
-82
lines changed

12 files changed

+211
-82
lines changed

README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ python bot.py
7070
| Command | Description |
7171
|---------|-------------|
7272
| `/start` | Welcome message and quick usage guide |
73-
| `/settings` | Settings: drafts, model (FREE/PRO), prompt, communication style, auto-reply timer, timezone |
73+
| `/settings` | Settings: model (FREE/PRO), prompt, communication style, auto-reply timer, timezone |
7474
| `/chats` | Per-chat settings: individual style, auto-reply timer, and system prompt for each chat (connected users only) |
7575
| `/poke` | Scan the 16 most recent private chats and draft replies to unanswered messages and follow-ups (connected users only) |
7676
| `/status` | Connection status |
@@ -87,16 +87,15 @@ By default, `/connect` prompts for a phone number. A button below the message le
8787

8888
| Setting | Description | Default |
8989
|---------|-------------|:-------:|
90-
| **Drafts** (✏️) | Enable/disable draft instruction processing. When disabled, the bot won't edit drafts based on instructions but will continue creating auto-replies to incoming messages. | ✅ ON |
9190
| **Model** (🤖) | AI mode: FREE (Gemini 3.1 Flash Lite) or PRO. In PRO mode, the model is selected by communication style: GPT-5.4 for most styles, Gemini 3.1 Pro Preview for seducer. | PRO |
9291
| **Prompt** (📝) | Custom prompt: describe your persona and add instructions (max 600 chars). The AI uses this to build a *USER PROFILE & CUSTOM INSTRUCTIONS* block. **We recommend adding a self-description** — gender, age, occupation, and texting habits — so the AI mimics your style more accurately. Example: "I'm a 28 y/o guy, designer. I text short, 1–2 sentences, never use periods at the end. I swear a lot and use stickers." Applied to all chats. Applied to drafts and auto-replies. | ❌ OFF |
9392
| **Style** (🦉/🍻/💕/💼/💰/🕵️/😈) | Communication style: Userlike, Friend, Romance, Business, Sales, Paranoid, Seducer. Sets the tone and manner of replies (including direct bot chat). | 🦉 Userlike |
94-
| **Auto-reply** (⏰) | Auto-reply timer. If the user doesn't send the draft within the specified time, the bot sends the message itself. Options: OFF, 🔇 Ignore, 1 min, 5 min, 15 min, 1 hour, 16 hours. **Ignore** disables auto-sending globally but does **not** block draft generation (unlike per-chat 🔇 Ignore in `/chats`, which fully disables both drafts and auto-replies for that chat). Actual delay: from base to 2×base (e.g. 16 h → 16–32 h, avg 24 h). | OFF |
93+
| **Auto-reply** (⏰) | Auto-reply timer. If the user doesn't send the draft within the specified time, the bot sends the message itself. Options: OFF, 🔇 Ignore, 1 min, 5 min, 15 min, 1 hour, 16 hours. **Ignore** disables drafts and auto-replies by default for all chats, but any per-chat override in `/chats` still takes priority. Actual delay: from base to 2×base (e.g. 16 h → 16–32 h, avg 24 h). | OFF |
9594
| **Timezone** (🕐) | User timezone. The button shows the current time — tap to cycle through 30 popular UTC offsets (including +3:30, +4:30, +5:30, +9:30). Affects message timestamps in AI context. | UTC0 |
9695

9796
### Per-chat Settings (`/chats`)
9897

99-
The `/chats` command shows only chats where the bot has actually set a draft or replied, as well as chats with custom settings. Each chat is shown as a single button with the chat name.
98+
The `/chats` command shows recent chats, prioritizing chats with per-chat auto-reply overrides first, then chats where the bot has replied or where custom per-chat settings already exist. Each chat is shown as a single button with the chat name.
10099

101100
Tapping a chat opens a **new message** with three vertical buttons:
102101

bot.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
from handlers.styles_handler import ( # noqa: E402
4242
on_auto_reply_callback, on_chat_menu_callback, on_chat_prompt_callback,
4343
on_chat_prompt_cancel_callback,
44-
on_chat_prompt_clear_callback, on_chats, on_chats_callback,
44+
on_chat_prompt_clear_callback, on_chats, on_chats_callback, on_chats_more_callback,
4545
)
4646
from utils.pyrogram_utils import restore_sessions # noqa: E402
4747

@@ -103,6 +103,7 @@ def main() -> None:
103103
app.add_handler(CommandHandler("chats", on_chats, filters=PRIVATE_ONLY_FILTER))
104104
app.add_handler(CommandHandler("poke", on_poke, filters=PRIVATE_ONLY_FILTER))
105105
app.add_handler(CallbackQueryHandler(on_settings_callback, pattern=r"^settings:"))
106+
app.add_handler(CallbackQueryHandler(on_chats_more_callback, pattern=r"^chatsmore:"))
106107
app.add_handler(CallbackQueryHandler(on_chats_callback, pattern=r"^chats:"))
107108
app.add_handler(CallbackQueryHandler(on_chat_menu_callback, pattern=r"^chatmenu:"))
108109
app.add_handler(CallbackQueryHandler(on_auto_reply_callback, pattern=r"^autoreply:"))

clients/x402gate/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,11 @@ async def topup(self, amount_usd: float | None = None) -> float:
207207

208208
result = response.json()
209209
self._prepaid_balance = float(result.get("balance", 0))
210+
211+
# Сразу фиксируем пополнение в дашборде, чтобы стоимость
212+
# следующего запроса не вычиталась из суммы пополнения "Topped Up"
213+
dash_stats.update_balance(self._prepaid_balance)
214+
210215
credited = result.get("credited", "?")
211216

212217
wallet_usdc = await self._get_wallet_usdc_balance()

handlers/pyrogram_handlers.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
from utils.utils import (
1616
format_chat_history,
1717
get_effective_auto_reply,
18-
get_effective_drafts,
1918
get_effective_model,
2019
get_effective_prompt,
2120
get_effective_style,
@@ -669,10 +668,6 @@ async def on_pyrogram_draft(user_id: int, chat_id: int, draft_text: str) -> None
669668
user = await get_user(user_id)
670669
user_settings = (user or {}).get("settings") or {}
671670
lang = (user or {}).get("language_code")
672-
if not get_effective_drafts(user_settings):
673-
if DEBUG_PRINT:
674-
print(f"{get_timestamp()} [PYROGRAM] Drafts disabled for user {user_id}, skipping draft")
675-
return
676671

677672
# Per-user ignore: пользователь пометил чат как 🔇 в /chats (из БД)
678673
if is_chat_ignored(user_settings, chat_id):

handlers/settings_handler.py

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
from utils.telegram_user import ensure_effective_user
1515
from handlers.connect_handler import clear_pending_input
1616
from utils.utils import (
17-
get_effective_drafts,
1817
get_effective_pro_model,
1918
get_timestamp,
2019
normalize_auto_reply,
@@ -46,11 +45,9 @@ def _build_timezone_label(offset: float) -> str:
4645

4746
def _build_settings_keyboard(settings: dict, messages: dict) -> InlineKeyboardMarkup:
4847
"""Формирует InlineKeyboard с текущими настройками пользователя."""
49-
drafts_enabled = get_effective_drafts(settings)
5048
pro_model = get_effective_pro_model(settings)
5149
has_prompt = bool(settings.get("custom_prompt"))
5250

53-
drafts_label = messages.get("settings_drafts_on") if drafts_enabled else messages.get("settings_drafts_off")
5451
model_label = messages.get("settings_model_pro") if pro_model else messages.get("settings_model_free")
5552
prompt_label = messages.get("settings_prompt_set") if has_prompt else messages.get("settings_prompt_empty")
5653
auto_reply = normalize_auto_reply(settings.get("auto_reply"))
@@ -69,7 +66,6 @@ def _build_settings_keyboard(settings: dict, messages: dict) -> InlineKeyboardMa
6966
keyboard = [
7067
[InlineKeyboardButton(model_label, callback_data="settings:model")],
7168
[InlineKeyboardButton(style_label, callback_data="settings:style")],
72-
[InlineKeyboardButton(drafts_label, callback_data="settings:drafts")],
7369
[InlineKeyboardButton(prompt_label, callback_data="settings:prompt")],
7470
[InlineKeyboardButton(auto_label, callback_data="settings:auto_reply")],
7571
[
@@ -134,17 +130,7 @@ async def on_settings_callback(update: Update, context: ContextTypes.DEFAULT_TYP
134130
if not action.startswith("settings:prompt"):
135131
await clear_pending_input(context, u.id, context.bot)
136132

137-
if action == "settings:drafts":
138-
current = get_effective_drafts(settings)
139-
updated_settings = await update_user_settings(
140-
u.id,
141-
{"drafts_enabled": not current},
142-
current_settings=settings,
143-
)
144-
if updated_settings is None:
145-
await _send_settings_error(query, u.language_code)
146-
return
147-
elif action == "settings:model":
133+
if action == "settings:model":
148134
current = get_effective_pro_model(settings)
149135
updated_settings = await update_user_settings(
150136
u.id,

handlers/styles_handler.py

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,20 @@ def _find_chat_name(context: ContextTypes.DEFAULT_TYPE, chat_id: int) -> str:
7272
def _build_styles_keyboard(
7373
dialogs: list[dict],
7474
user_settings: dict,
75+
messages: dict,
76+
visible_count: int | None = None,
7577
) -> InlineKeyboardMarkup:
76-
"""Формирует inline-клавиатуру: одна кнопка с именем чата и иконками настроек."""
78+
"""Формирует inline-клавиатуру списка чатов с пагинацией."""
7779
chat_styles = user_settings.get("chat_styles") or {}
7880
chat_prompts = user_settings.get("chat_prompts") or {}
81+
chat_auto_replies = user_settings.get("chat_auto_replies") or {}
7982
global_style = user_settings.get("style") or DEFAULT_STYLE
8083

8184
keyboard = []
82-
for d in dialogs:
85+
if visible_count is None:
86+
visible_count = ACTIVE_CHATS_LIMIT
87+
visible_dialogs = dialogs[:visible_count]
88+
for d in visible_dialogs:
8389
chat_id = d["chat_id"]
8490
name = _chat_display_name(d)
8591

@@ -88,15 +94,24 @@ def _build_styles_keyboard(
8894
icons = _style_emoji(style)
8995
if chat_prompts.get(str(chat_id)):
9096
icons += "📝"
91-
ar = get_effective_auto_reply(user_settings, chat_id)
92-
if ar == CHAT_IGNORED_SENTINEL:
97+
98+
# В списке /chats показываем только per-chat override для auto-reply.
99+
# Глобальное effective значение здесь намеренно не отражаем.
100+
ar_override = chat_auto_replies.get(str(chat_id))
101+
if ar_override == CHAT_IGNORED_SENTINEL:
93102
icons += "🔇"
94-
elif ar is not None:
103+
elif ar_override and ar_override > 0:
95104
icons += "⏰"
96105

97106
label = f"{icons} | {name}"
98107
btn = InlineKeyboardButton(label, callback_data=f"chatmenu:{chat_id}")
99108
keyboard.append([btn])
109+
110+
if visible_count < len(dialogs):
111+
next_count = min(visible_count + ACTIVE_CHATS_LIMIT, len(dialogs))
112+
show_more_label = messages.get("chats_show_more", "⬇️ Show more ⬇️")
113+
keyboard.append([InlineKeyboardButton(show_more_label, callback_data=f"chatsmore:{next_count}")])
114+
100115
return InlineKeyboardMarkup(keyboard)
101116

102117

@@ -139,16 +154,20 @@ def _build_chat_settings_keyboard(
139154
def _get_relevant_dialogs(
140155
all_dialogs: list[dict], user_settings: dict, user_id: int,
141156
) -> list[dict]:
142-
"""Фильтрует диалоги: только чаты, где бот ответил или есть кастомная настройка."""
157+
"""Возвращает список чатов: важные настройки сверху, затем остальные недавние."""
143158
replied = get_replied_chats(user_id)
144159
styled_ids = set(int(k) for k in (user_settings.get("chat_styles") or {}))
145160
ar_ids = set(int(k) for k in (user_settings.get("chat_auto_replies") or {}))
146161
prompt_ids = set(int(k) for k in (user_settings.get("chat_prompts") or {}))
147162
relevant_ids = replied | styled_ids | ar_ids | prompt_ids
148-
dialogs = [d for d in all_dialogs if d["chat_id"] in relevant_ids]
149-
# Чаты с per-chat auto-reply/ignore — сверху
150-
dialogs.sort(key=lambda d: d["chat_id"] not in ar_ids)
151-
return dialogs[:ACTIVE_CHATS_LIMIT]
163+
164+
override_dialogs = [d for d in all_dialogs if d["chat_id"] in ar_ids]
165+
relevant_dialogs = [
166+
d for d in all_dialogs
167+
if d["chat_id"] in relevant_ids and d["chat_id"] not in ar_ids
168+
]
169+
recent_dialogs = [d for d in all_dialogs if d["chat_id"] not in relevant_ids]
170+
return override_dialogs + relevant_dialogs + recent_dialogs
152171

153172

154173
@serialize_user_updates
@@ -182,19 +201,47 @@ async def on_chats(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
182201
await update.message.reply_text(msg)
183202
return
184203

185-
# Сохраняем dialogs в user_data для callback
204+
# Сохраняем полный список dialogs в user_data для callback и пагинации
186205
context.user_data["chats_dialogs"] = dialogs
187206

188207
messages = await get_system_messages(u.language_code)
189208
title = messages.get("chats_title", "🎭 Chats")
190-
keyboard = _build_styles_keyboard(dialogs, user_settings)
209+
keyboard = _build_styles_keyboard(dialogs, user_settings, messages)
191210
await update.message.reply_text(title, reply_markup=keyboard)
192211

193212
if DEBUG_PRINT:
194213
print(f"{get_timestamp()} [BOT] /chats from user {u.id}, {len(dialogs)} chats")
195214
dash_stats.record_command("/chats")
196215

197216

217+
@serialize_user_updates
218+
async def on_chats_more_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
219+
"""Показывает следующую страницу списка чатов в /chats."""
220+
query = update.callback_query
221+
u = update.effective_user
222+
await query.answer()
223+
await clear_pending_input(context, u.id, context.bot)
224+
225+
try:
226+
visible_count = int(query.data.split(":")[1])
227+
except (IndexError, ValueError):
228+
return
229+
230+
dialogs = context.user_data.get("chats_dialogs") or []
231+
if not dialogs:
232+
messages = await get_system_messages(u.language_code)
233+
no_chats = messages.get("chats_no_chats", "No active chats found. Start a conversation first.")
234+
await query.edit_message_text(text=no_chats)
235+
return
236+
237+
user = await get_user(u.id)
238+
user_settings = (user or {}).get("settings") or {}
239+
messages = await get_system_messages(u.language_code)
240+
title = messages.get("chats_title", "🎭 Chats")
241+
keyboard = _build_styles_keyboard(dialogs, user_settings, messages, visible_count=visible_count)
242+
await query.edit_message_text(text=title, reply_markup=keyboard)
243+
244+
198245
async def _refresh_chat_settings(
199246
query: object, u: object, context: ContextTypes.DEFAULT_TYPE,
200247
chat_id: int, updated_settings: dict,

schema.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ create table if not exists public.users (
1616
bio text, -- Биография пользователя (из getChat)
1717
tg_rating integer, -- Рейтинг Telegram Stars (из getChat)
1818
session_string text, -- Зашифрованный Pyrogram session string (Client API)
19-
settings jsonb default '{}' -- Настройки пользователя (drafts_enabled, pro_model)
19+
settings jsonb default '{}' -- Настройки пользователя (pro_model, style, custom_prompt, auto_reply, tz_offset, per-chat overrides)
2020
);
2121

2222
create index if not exists idx_users_last_msg_at on public.users(last_msg_at desc);

system_messages.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,6 @@
8888

8989
# — Settings —
9090
"settings_title": "⚙️ Settings\nApplies to all private chats by default.\nUse /chats to override per chat.\n\nTap buttons to change.",
91-
"settings_drafts_on": "✏️ Draft editing: ✅ ON",
92-
"settings_drafts_off": "✏️ Draft editing: ❌ OFF",
9391
"settings_model_free": "🤖 Model: FREE",
9492
"settings_model_pro": "🤖 Model: ⭐ PRO",
9593
"settings_prompt_set": "📝 Prompt: ✅ ON",
@@ -121,6 +119,7 @@
121119
"chats_title": "🎭 Chats",
122120
"chats_chat_title": "⚙️ {chat_name}",
123121
"chats_no_chats": "No active chats found. Start a conversation first.",
122+
"chats_show_more": "⬇️ Show more ⬇️",
124123

125124

126125
# — Chats: per-chat prompt —

tests/test_database_users.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -377,14 +377,14 @@ class TestUpdateUserSettings:
377377
async def test_updates_existing_user_settings_with_merge(self):
378378
mock_table = _make_mock_table()
379379
with patch("database.users.supabase") as mock_sb, \
380-
patch("database.users.get_user", new_callable=AsyncMock, return_value={"settings": {"drafts_enabled": True}}):
380+
patch("database.users.get_user", new_callable=AsyncMock, return_value={"settings": {"style": "friend"}}):
381381
mock_sb.table.return_value = mock_table
382382

383383
result = await update_user_settings(123, {"pro_model": True})
384384

385-
assert result == {"drafts_enabled": True, "pro_model": True}
385+
assert result == {"style": "friend", "pro_model": True}
386386
mock_table.update.assert_called_once_with(
387-
{"settings": {"drafts_enabled": True, "pro_model": True}}
387+
{"settings": {"style": "friend", "pro_model": True}}
388388
)
389389
mock_table.upsert.assert_not_called()
390390

0 commit comments

Comments
 (0)