Skip to content

Commit 72af7da

Browse files
committed
feat(chats): two-level inline keyboard for /chats command
- Refactor /chats: Level 1 shows one button per chat (name only), callback chatmenu: - Add Level 2: tapping a chat sends a new message with 3 vertical buttons (Style, Prompt, Auto-reply) - Add _build_chat_settings_keyboard() for Level 2 keyboard - Add on_chat_menu_callback() handler, register chatmenu: pattern in bot.py - Add _find_chat_name() helper to eliminate DRY violation (was repeated 3x) - Add _refresh_chat_settings() to update Level 2 message after style/auto-reply/prompt changes - Reuse settings_prompt_set/settings_prompt_empty keys instead of duplicate chats_prompt_* keys - Move auto_reply_prefix emoji and colon into the message key itself - Reorder AUTO_REPLY_OPTIONS: Ignore (-1) now second after OFF, before 1 min - Remove prompt preview from /settings message (title only) - Update README Per-chat Settings section for two-level UI - Update test_styles.py and test_settings.py for new keyboard structure and carousel order
1 parent f96d537 commit 72af7da

File tree

8 files changed

+273
-152
lines changed

8 files changed

+273
-152
lines changed

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,13 @@ By default, `/connect` prompts for a phone number. A button below the message le
9595

9696
### Per-chat Settings (`/chats`)
9797

98-
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 has three buttons:
98+
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.
9999

100-
- **Prompt** (`📝`) — tap to open the prompt editor for this chat. Shows the current prompt and lets you set a new one, clear it, or cancel. Per-chat prompt is appended to the global prompt (max 300 chars).
101-
- **Style** (`🦉 Name`) — tap to cycle through styles
102-
- **Auto-reply** (``) — tap to cycle through auto-reply timers for this chat. The last option in the cycle is **🔇 Ignore** — fully disables drafts, auto-replies, and message polling for that chat.
100+
Tapping a chat opens a **new message** with three vertical buttons:
101+
102+
- **Style** (`🦉 Style: Userlike`) — tap to cycle through styles
103+
- **Prompt** (`📝 Prompt: ✅ ON`) — tap to open the prompt editor for this chat. Shows the current prompt and lets you set a new one, clear it, or cancel. Per-chat prompt is appended to the global prompt (max 300 chars).
104+
- **Auto-reply** (`⏰ Auto-reply: ✅ OFF`) — tap to cycle through auto-reply timers for this chat. The second option in the cycle is **🔇 Ignore** — fully disables drafts, auto-replies, and message polling for that chat.
103105

104106
Per-chat settings override the global ones from `/settings`. If a per-chat value matches the global one, the override is automatically cleared. Available only to connected users.
105107

bot.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@
3838
from handlers.settings_handler import on_settings, on_settings_callback # noqa: E402
3939
from handlers.poke_handler import on_poke # noqa: E402
4040
from handlers.styles_handler import ( # noqa: E402
41-
on_auto_reply_callback, on_chat_prompt_callback, on_chat_prompt_cancel_callback,
41+
on_auto_reply_callback, on_chat_menu_callback, on_chat_prompt_callback,
42+
on_chat_prompt_cancel_callback,
4243
on_chat_prompt_clear_callback, on_chats, on_chats_callback,
4344
)
4445
from utils.pyrogram_utils import restore_sessions # noqa: E402
@@ -79,6 +80,7 @@ def main() -> None:
7980
app.add_handler(CommandHandler("poke", on_poke, filters=PRIVATE_ONLY_FILTER))
8081
app.add_handler(CallbackQueryHandler(on_settings_callback, pattern=r"^settings:"))
8182
app.add_handler(CallbackQueryHandler(on_chats_callback, pattern=r"^chats:"))
83+
app.add_handler(CallbackQueryHandler(on_chat_menu_callback, pattern=r"^chatmenu:"))
8284
app.add_handler(CallbackQueryHandler(on_auto_reply_callback, pattern=r"^autoreply:"))
8385
app.add_handler(CallbackQueryHandler(on_chat_prompt_callback, pattern=r"^chatprompt:"))
8486
app.add_handler(CallbackQueryHandler(on_chat_prompt_cancel_callback, pattern=r"^chatprompt_cancel:"))

config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,12 +109,12 @@
109109
CHAT_IGNORED_SENTINEL = -1 # Sentinel: чат полностью игнорируется (нет черновиков и автоответа)
110110
AUTO_REPLY_OPTIONS: dict[int | None, str] = {
111111
None: "auto_reply_off",
112+
CHAT_IGNORED_SENTINEL: "auto_reply_ignore",
112113
60: "auto_reply_1m",
113114
300: "auto_reply_5m",
114115
900: "auto_reply_15m",
115116
3600: "auto_reply_1h",
116117
57600: "auto_reply_16h",
117-
CHAT_IGNORED_SENTINEL: "auto_reply_ignore",
118118
}
119119

120120
# Чаты, полностью игнорируемые ботом (не генерируются черновики и автоответы).

handlers/settings_handler.py

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,8 @@ def _build_settings_keyboard(settings: dict, messages: dict) -> InlineKeyboardMa
5858
if ar_key == "auto_reply_ignore":
5959
auto_label = ar_base
6060
else:
61-
ar_prefix = messages.get("auto_reply_prefix", "Auto-reply")
62-
auto_label = f"{ar_prefix}: {ar_base}"
61+
ar_prefix = messages.get("auto_reply_prefix", "Auto-reply:")
62+
auto_label = f"{ar_prefix} {ar_base}"
6363
style_label = messages.get(STYLE_OPTIONS.get(settings.get("style"), "settings_style_userlike"))
6464

6565
tz_offset = settings.get("tz_offset", 0) or 0
@@ -106,12 +106,8 @@ async def on_settings(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
106106

107107
messages = await get_system_messages(u.language_code)
108108

109-
# Добавляем превью промпта к заголовку
110-
custom_prompt = settings.get("custom_prompt", "")
111-
text = f"{title}\n\n📝 «{custom_prompt}»" if custom_prompt else title
112-
113109
keyboard = _build_settings_keyboard(settings, messages)
114-
await update.message.reply_text(text, reply_markup=keyboard)
110+
await update.message.reply_text(title, reply_markup=keyboard)
115111

116112
if DEBUG_PRINT:
117113
print(f"{get_timestamp()} [BOT] /settings from user {u.id}")
@@ -240,10 +236,7 @@ async def on_settings_callback(update: Update, context: ContextTypes.DEFAULT_TYP
240236
keyboard = _build_settings_keyboard(updated_settings, messages)
241237
title = messages.get("settings_title", "⚙️ Settings")
242238

243-
custom_prompt = updated_settings.get("custom_prompt", "")
244-
text = f"{title}\n\n📝 «{custom_prompt}»" if custom_prompt else title
245-
246-
await query.edit_message_text(text=text, reply_markup=keyboard)
239+
await query.edit_message_text(text=title, reply_markup=keyboard)
247240

248241
if DEBUG_PRINT:
249242
print(f"{get_timestamp()} [BOT] Settings updated by user {u.id}: {action}")

handlers/styles_handler.py

Lines changed: 102 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ def _auto_reply_label(seconds: int | None, messages: dict) -> str:
4141
ar_base = messages.get(ar_key, "")
4242
if ar_key == "auto_reply_ignore":
4343
return ar_base or "🔇"
44-
return f"⏰: {ar_base}" if ar_base else "⏰"
44+
ar_prefix = messages.get("auto_reply_prefix", "⏰ Auto-reply:")
45+
return f"{ar_prefix} {ar_base}" if ar_base else "⏰"
4546

4647

4748
def _chat_display_name(dialog_info: dict) -> str:
@@ -58,46 +59,60 @@ def _chat_display_name(dialog_info: dict) -> str:
5859
return name or dialog_info.get("username", "") or "???"
5960

6061

62+
def _find_chat_name(context: ContextTypes.DEFAULT_TYPE, chat_id: int) -> str:
63+
"""Ищет имя чата в сохранённых диалогах."""
64+
for d in context.user_data.get("chats_dialogs") or []:
65+
if d["chat_id"] == chat_id:
66+
return _chat_display_name(d)
67+
return "???"
68+
69+
6170
def _build_styles_keyboard(
6271
dialogs: list[dict],
63-
chat_styles: dict,
64-
user_settings: dict,
65-
messages: dict,
66-
global_style: str | None = None,
6772
) -> InlineKeyboardMarkup:
68-
"""Формирует inline-клавиатуру со списком чатов: стиль + автоответ + промпт."""
69-
chat_prompts = user_settings.get("chat_prompts") or {}
73+
"""Формирует inline-клавиатуру: одна кнопка с именем чата на строку."""
7074
keyboard = []
7175
for d in dialogs:
7276
chat_id = d["chat_id"]
73-
# Стиль
74-
style = chat_styles.get(str(chat_id))
75-
if style is None:
76-
style = global_style
77-
emoji = _style_emoji(style)
7877
name = _chat_display_name(d)
78+
btn = InlineKeyboardButton(name, callback_data=f"chatmenu:{chat_id}")
79+
keyboard.append([btn])
80+
return InlineKeyboardMarkup(keyboard)
81+
82+
83+
def _build_chat_settings_keyboard(
84+
chat_id: int,
85+
user_settings: dict,
86+
messages: dict,
87+
global_style: str | None = None,
88+
) -> InlineKeyboardMarkup:
89+
"""Формирует inline-клавиатуру настроек чата: стиль + промпт + автоответ в столбец."""
90+
chat_styles = user_settings.get("chat_styles") or {}
91+
chat_prompts = user_settings.get("chat_prompts") or {}
92+
93+
# Стиль
94+
style = chat_styles.get(str(chat_id))
95+
if style is None:
96+
style = global_style or DEFAULT_STYLE
97+
style_msg_key = STYLE_OPTIONS.get(style, "settings_style_userlike")
98+
style_label = messages.get(style_msg_key, f"{_style_emoji(style)} Style: {style}")
99+
100+
# Промпт
101+
has_prompt = bool(chat_prompts.get(str(chat_id)))
102+
prompt_label = messages.get(
103+
"settings_prompt_set" if has_prompt else "settings_prompt_empty",
104+
"📝",
105+
)
106+
107+
# Автоответ
108+
auto_reply = get_effective_auto_reply(user_settings, chat_id)
109+
ar_label = _auto_reply_label(auto_reply, messages)
79110

80-
# Автоответ
81-
auto_reply = get_effective_auto_reply(user_settings, chat_id)
82-
ar_label = _auto_reply_label(auto_reply, messages)
83-
84-
# Per-chat промпт
85-
has_prompt = bool(chat_prompts.get(str(chat_id)))
86-
prompt_label = messages.get(
87-
"chats_prompt_set" if has_prompt else "chats_prompt_empty",
88-
"📝",
89-
)
90-
91-
style_btn = InlineKeyboardButton(
92-
f"{emoji} {name}", callback_data=f"chats:{chat_id}",
93-
)
94-
ar_btn = InlineKeyboardButton(
95-
ar_label or "⏰", callback_data=f"autoreply:{chat_id}",
96-
)
97-
prompt_btn = InlineKeyboardButton(
98-
prompt_label, callback_data=f"chatprompt:{chat_id}",
99-
)
100-
keyboard.append([prompt_btn, style_btn, ar_btn])
111+
keyboard = [
112+
[InlineKeyboardButton(style_label, callback_data=f"chats:{chat_id}")],
113+
[InlineKeyboardButton(prompt_label, callback_data=f"chatprompt:{chat_id}")],
114+
[InlineKeyboardButton(ar_label, callback_data=f"autoreply:{chat_id}")],
115+
]
101116
return InlineKeyboardMarkup(keyboard)
102117

103118

@@ -134,7 +149,6 @@ async def on_chats(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
134149
# Читаем настройки
135150
user = await get_user(u.id)
136151
user_settings = (user or {}).get("settings") or {}
137-
chat_styles = user_settings.get("chat_styles") or {}
138152

139153
# Получаем широкий список диалогов для фильтрации
140154
all_dialogs = await pyrogram_client.get_dialog_info(
@@ -153,35 +167,57 @@ async def on_chats(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
153167

154168
messages = await get_system_messages(u.language_code)
155169
title = messages.get("chats_title", "🎭 Chats")
156-
global_style = user_settings.get("style")
157-
keyboard = _build_styles_keyboard(dialogs, chat_styles, user_settings, messages, global_style)
170+
keyboard = _build_styles_keyboard(dialogs)
158171
await update.message.reply_text(title, reply_markup=keyboard)
159172

160173
if DEBUG_PRINT:
161174
print(f"{get_timestamp()} [BOT] /chats from user {u.id}, {len(dialogs)} chats")
162175

163176

164-
async def _refresh_keyboard(
165-
query, u, context, updated_settings: dict,
177+
async def _refresh_chat_settings(
178+
query: object, u: object, context: ContextTypes.DEFAULT_TYPE,
179+
chat_id: int, updated_settings: dict,
166180
) -> None:
167-
"""Обновляет клавиатуру /chats после изменения настроек."""
168-
dialogs = context.user_data.get("chats_dialogs") or []
169-
if not dialogs:
170-
all_dialogs = await pyrogram_client.get_dialog_info(
171-
u.id, limit=CHATS_FETCH_LIMIT,
172-
)
173-
dialogs = _get_relevant_dialogs(all_dialogs, updated_settings, u.id)
174-
else:
175-
dialogs = _get_relevant_dialogs(dialogs, updated_settings, u.id)
181+
"""Обновляет клавиатуру настроек конкретного чата (Level 2 сообщение)."""
182+
messages = await get_system_messages(u.language_code)
183+
global_style = updated_settings.get("style")
184+
keyboard = _build_chat_settings_keyboard(chat_id, updated_settings, messages, global_style)
176185

177-
context.user_data["chats_dialogs"] = dialogs
186+
chat_name = _find_chat_name(context, chat_id)
187+
title = messages.get("chats_chat_title", "⚙️ {chat_name}").format(chat_name=chat_name)
188+
await query.edit_message_text(text=title, reply_markup=keyboard)
189+
190+
191+
@serialize_user_updates
192+
async def on_chat_menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
193+
"""Обработчик нажатия на чат — отправляет новое сообщение с настройками чата."""
194+
query = update.callback_query
195+
u = update.effective_user
196+
await query.answer()
197+
await clear_pending_input(context, u.id, context.bot)
198+
199+
# Извлекаем chat_id из callback_data "chatmenu:123456"
200+
try:
201+
chat_id = int(query.data.split(":")[1])
202+
except (IndexError, ValueError):
203+
return
204+
205+
# Читаем текущие настройки
206+
user = await get_user(u.id)
207+
user_settings = (user or {}).get("settings") or {}
178208

179-
chat_styles = updated_settings.get("chat_styles") or {}
180-
global_style = updated_settings.get("style")
181209
messages = await get_system_messages(u.language_code)
182-
keyboard = _build_styles_keyboard(dialogs, chat_styles, updated_settings, messages, global_style)
183-
title = messages.get("chats_title", "🎭 Chats")
184-
await query.edit_message_text(text=title, reply_markup=keyboard)
210+
global_style = user_settings.get("style")
211+
keyboard = _build_chat_settings_keyboard(chat_id, user_settings, messages, global_style)
212+
213+
chat_name = _find_chat_name(context, chat_id)
214+
title = messages.get("chats_chat_title", "⚙️ {chat_name}").format(chat_name=chat_name)
215+
await context.bot.send_message(
216+
chat_id=query.message.chat_id, text=title, reply_markup=keyboard,
217+
)
218+
219+
if DEBUG_PRINT:
220+
print(f"{get_timestamp()} [BOT] Chat menu opened for chat {chat_id} by user {u.id}")
185221

186222

187223
@serialize_user_updates
@@ -223,7 +259,7 @@ async def on_chats_callback(update: Update, context: ContextTypes.DEFAULT_TYPE)
223259
await query.edit_message_text(text=error_msg)
224260
return
225261

226-
await _refresh_keyboard(query, u, context, updated_settings)
262+
await _refresh_chat_settings(query, u, context, chat_id, updated_settings)
227263

228264
if DEBUG_PRINT:
229265
print(f"{get_timestamp()} [BOT] Style for chat {chat_id} changed to {next_value!r} by user {u.id}")
@@ -274,7 +310,7 @@ async def on_auto_reply_callback(update: Update, context: ContextTypes.DEFAULT_T
274310
await query.edit_message_text(text=error_msg)
275311
return
276312

277-
await _refresh_keyboard(query, u, context, updated_settings)
313+
await _refresh_chat_settings(query, u, context, chat_id, updated_settings)
278314

279315
if DEBUG_PRINT:
280316
print(f"{get_timestamp()} [BOT] Auto-reply for chat {chat_id} changed to {next_value!r} by user {u.id}")
@@ -299,13 +335,7 @@ async def on_chat_prompt_callback(update: Update, context: ContextTypes.DEFAULT_
299335
chat_prompts = user_settings.get("chat_prompts") or {}
300336
current_prompt = chat_prompts.get(str(chat_id), "")
301337

302-
# Имя чата из сохранённых диалогов
303-
dialogs = context.user_data.get("chats_dialogs") or []
304-
chat_name = "???"
305-
for d in dialogs:
306-
if d["chat_id"] == chat_id:
307-
chat_name = _chat_display_name(d)
308-
break
338+
chat_name = _find_chat_name(context, chat_id)
309339

310340
messages = await get_system_messages(u.language_code)
311341
if current_prompt:
@@ -327,21 +357,27 @@ async def on_chat_prompt_callback(update: Update, context: ContextTypes.DEFAULT_
327357

328358
@serialize_user_updates
329359
async def on_chat_prompt_cancel_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
330-
"""Отмена редактирования per-chat промпта — возврат к списку чатов."""
360+
"""Отмена редактирования per-chat промпта — возврат к настройкам чата."""
331361
query = update.callback_query
332362
u = update.effective_user
333363
await query.answer()
334364

335365
await clear_pending_input(context, u.id, context.bot)
336366

367+
# Извлекаем chat_id из callback_data "chatprompt_cancel:123456"
368+
try:
369+
chat_id = int(query.data.split(":")[1])
370+
except (IndexError, ValueError):
371+
return
372+
337373
user = await get_user(u.id)
338374
user_settings = (user or {}).get("settings") or {}
339-
await _refresh_keyboard(query, u, context, user_settings)
375+
await _refresh_chat_settings(query, u, context, chat_id, user_settings)
340376

341377

342378
@serialize_user_updates
343379
async def on_chat_prompt_clear_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
344-
"""Очистка per-chat промпта и возврат к списку чатов."""
380+
"""Очистка per-chat промпта и возврат к настройкам чата."""
345381
query = update.callback_query
346382
u = update.effective_user
347383
await query.answer()
@@ -359,8 +395,7 @@ async def on_chat_prompt_clear_callback(update: Update, context: ContextTypes.DE
359395
await query.edit_message_text(text=error_msg)
360396
return
361397

362-
await _refresh_keyboard(query, u, context, updated_settings)
398+
await _refresh_chat_settings(query, u, context, chat_id, updated_settings)
363399

364400
if DEBUG_PRINT:
365401
print(f"{get_timestamp()} [BOT] Chat prompt cleared for chat {chat_id} by user {u.id}")
366-

system_messages.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@
100100
"settings_prompt_current": f"📝 Global prompt (all chats):\n«{{prompt}}»\n\nSend a new prompt to replace it (Max length: {USER_PROMPT_MAX_LENGTH} chars):",
101101
"settings_prompt_no_prompt": f"📝 Global prompt (all chats): not set.\n\nSend a prompt to set it (Max length: {USER_PROMPT_MAX_LENGTH} chars):",
102102
# — Auto-reply labels (base, without prefix) —
103-
"auto_reply_prefix": "Auto-reply",
103+
"auto_reply_prefix": "Auto-reply:",
104104
"auto_reply_off": "✅ OFF",
105105
"auto_reply_1m": "⚠️ 1 min",
106106
"auto_reply_5m": "⚠️ 5 min",
@@ -118,10 +118,9 @@
118118

119119
# — Chats (per-chat settings) —
120120
"menu_chats": "Chat settings",
121-
"chats_title": "🎭 Chats\nTap to change prompt, style, or auto-reply timer.",
121+
"chats_title": "🎭 Chats",
122+
"chats_chat_title": "⚙️ {chat_name}",
122123
"chats_no_chats": "No active chats found. Start a conversation first.",
123-
"chats_prompt_set": "📝✅",
124-
"chats_prompt_empty": "📝",
125124

126125

127126
# — Chats: per-chat prompt —

0 commit comments

Comments
 (0)