Убрал long-press меню каналов и обновил deploy-проверку sudo

This commit is contained in:
AidarKC
2026-05-31 19:30:36 +04:00
parent 1b0e1cf1d4
commit 5899bd2f77
25 changed files with 3343 additions and 192 deletions
+400 -26
View File
@@ -28,6 +28,14 @@ DEFAULT_ALLOWED_PLAYERS = ",".join([
"dimasol1:Дима",
])
TASK_STATUS_LABELS = {
"new": "новая",
"approved": "одобрена",
"rejected": "отклонена",
"needs_work": "на доработку",
"done": "сделана",
}
def now_iso() -> str:
return dt.datetime.now(dt.timezone.utc).isoformat()
@@ -57,6 +65,10 @@ def parse_allowed_players(raw: str) -> dict[str, str]:
return players
def compact_spaces(text: str) -> str:
return re.sub(r"\s+", " ", (text or "").strip())
def split_long_text(text: str, chunk_size: int = 3500) -> list[str]:
text = (text or "").strip()
if not text:
@@ -355,9 +367,12 @@ class ShinePyBotService:
self.lock_file = config.data_dir / "py_app.lock"
self.history_dir = config.data_dir / "history"
self.history_archive_dir = self.history_dir / "archive"
self.task_center_dir = config.data_dir / "task_center"
self.task_center_file = self.task_center_dir / "items.json"
self.max_processed_updates = 5000
self.queue_lock = threading.RLock()
self.task_center_lock = threading.RLock()
self.stop_event = threading.Event()
self.worker = threading.Thread(target=self._worker_loop, name="shine-py-bot-worker", daemon=True)
@@ -387,6 +402,54 @@ class ShinePyBotService:
uname = normalize_username(username)
return self.cfg.allowed_players.get(uname, uname)
def _display_name(self, username: str) -> str:
uname = normalize_username(username)
if uname == self.cfg.allowed_username:
return "Айдар"
return self._player_name(uname)
def _known_usernames(self) -> dict[str, str]:
users = {self.cfg.allowed_username: "Айдар"}
users.update(self.cfg.allowed_players)
return users
def _find_user_by_text(self, text: str) -> str:
source = normalize_username(text)
if source in self._known_usernames():
return source
source_lower = (text or "").strip().lower()
aliases = {
"айдар": self.cfg.allowed_username,
"айдару": self.cfg.allowed_username,
"айдара": self.cfg.allowed_username,
"милана": "malvviiina",
"милане": "malvviiina",
"милану": "malvviiina",
"миланы": "malvviiina",
"сергей": "zodiaktechnika32",
"сергею": "zodiaktechnika32",
"сергея": "zodiaktechnika32",
"иван": "oidasyda",
"ивану": "oidasyda",
"ивана": "oidasyda",
"ворон": "blackbyrd1",
"ворону": "blackbyrd1",
"ворона": "blackbyrd1",
"дима": "dimasol1",
"диме": "dimasol1",
"диму": "dimasol1",
"димы": "dimasol1",
}
for alias, username in aliases.items():
if re.search(rf"(^|\W){re.escape(alias)}($|\W)", source_lower, flags=re.IGNORECASE):
return username
for username, name in self._known_usernames().items():
if username and username in source_lower:
return username
if name and name.lower() in source_lower:
return username
return ""
def run(self) -> None:
self._ensure_dirs()
self._acquire_single_instance_lock()
@@ -438,6 +501,7 @@ class ShinePyBotService:
self.cfg.data_dir.mkdir(parents=True, exist_ok=True)
self.history_dir.mkdir(parents=True, exist_ok=True)
self.history_archive_dir.mkdir(parents=True, exist_ok=True)
self.task_center_dir.mkdir(parents=True, exist_ok=True)
def _acquire_single_instance_lock(self) -> None:
self.lock_file.parent.mkdir(parents=True, exist_ok=True)
@@ -611,7 +675,7 @@ class ShinePyBotService:
user_settings = {}
settings[uname] = user_settings
if not isinstance(user_settings.get("voice_replies_enabled"), bool):
user_settings["voice_replies_enabled"] = False
user_settings["voice_replies_enabled"] = True
if not isinstance(user_settings.get("voice_rewrite_enabled"), bool):
user_settings["voice_rewrite_enabled"] = True
return user_settings
@@ -659,6 +723,155 @@ class ShinePyBotService:
history_path = self._current_history_file_for_user(username or self.cfg.allowed_username)
self._append_history(history_path, "system_event", {"event": event_type, **payload})
def _load_task_items(self) -> list[dict[str, Any]]:
with self.task_center_lock:
if not self.task_center_file.exists():
return []
try:
data = json.loads(self.task_center_file.read_text(encoding="utf-8"))
except Exception:
return []
return data if isinstance(data, list) else []
def _save_task_items(self, items: list[dict[str, Any]]) -> None:
with self.task_center_lock:
self.task_center_file.parent.mkdir(parents=True, exist_ok=True)
tmp = self.task_center_file.with_suffix(".tmp")
tmp.write_text(json.dumps(items, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(self.task_center_file)
def _next_task_item_id(self, items: list[dict[str, Any]]) -> str:
max_num = 0
for item in items:
raw = str(item.get("id") or "")
match = re.match(r"TC-(\d+)$", raw)
if match:
max_num = max(max_num, int(match.group(1)))
return f"TC-{max_num + 1:04d}"
def _create_task_item(
self,
*,
kind: str,
title: str,
text: str,
source_username: str,
target_username: str,
source_message_id: int | None = None,
source_chat_id: int | None = None,
opinion: str = "",
) -> dict[str, Any]:
items = self._load_task_items()
item = {
"id": self._next_task_item_id(items),
"kind": kind,
"status": "new",
"title": compact_spaces(title)[:160] or ("Предложение" if kind == "proposal" else "Задача"),
"text": (text or "").strip(),
"opinion": (opinion or "").strip(),
"source_username": normalize_username(source_username),
"source_name": self._display_name(source_username),
"target_username": normalize_username(target_username),
"target_name": self._display_name(target_username),
"source_chat_id": source_chat_id,
"source_message_id": source_message_id,
"created_at": now_iso(),
"updated_at": now_iso(),
}
items.append(item)
self._save_task_items(items)
self._append_history_event("task_center_item_created", {
"itemId": item["id"],
"kind": kind,
"sourceUsername": item["source_username"],
"targetUsername": item["target_username"],
"title": item["title"],
}, username=source_username)
return item
def _task_items_for_user(self, username: str, *, include_done: bool = False) -> list[dict[str, Any]]:
uname = normalize_username(username)
items = self._load_task_items()
result = [
item for item in items
if normalize_username(item.get("target_username")) == uname
and (include_done or item.get("status") != "done")
]
return sorted(result, key=lambda x: str(x.get("created_at") or ""))
def _task_center_counts_text(self, username: str) -> str:
counts: dict[str, int] = {}
for item in self._task_items_for_user(username):
status = str(item.get("status") or "new")
counts[status] = counts.get(status, 0) + 1
active_total = sum(counts.values())
if active_total <= 0:
return ""
parts = []
for status in ("new", "approved", "needs_work", "rejected"):
count = counts.get(status, 0)
if count:
parts.append(f"{TASK_STATUS_LABELS.get(status, status)}: {count}")
return f"Напоминание по задачам: всего активных {active_total}; " + ", ".join(parts) + "."
def _format_task_items(self, username: str, *, include_done: bool = False) -> str:
items = self._task_items_for_user(username, include_done=include_done)
if not items:
return f"Для {self._display_name(username)} активных задач и предложений нет."
lines = [f"Задачи и предложения для {self._display_name(username)}:"]
for item in items[:15]:
kind = "предложение" if item.get("kind") == "proposal" else "задача"
status = TASK_STATUS_LABELS.get(str(item.get("status") or "new"), str(item.get("status") or "new"))
source = item.get("source_name") or item.get("source_username") or "неизвестно"
title = item.get("title") or "(без названия)"
lines.append(f"{item.get('id')} [{status}] {kind} от {source}: {title}")
if len(items) > 15:
lines.append(f"...и ещё {len(items) - 15}")
return "\n".join(lines)
def _update_task_item_status(self, item_id: str, status: str) -> dict[str, Any] | None:
item_id = (item_id or "").strip().upper()
items = self._load_task_items()
updated = None
for item in items:
if str(item.get("id") or "").upper() == item_id:
item["status"] = status
item["updated_at"] = now_iso()
updated = item
break
if updated is not None:
self._save_task_items(items)
return updated
def _find_first_task_item(self, *, source_username: str = "", target_username: str = "", kind: str = "") -> dict[str, Any] | None:
source = normalize_username(source_username)
target = normalize_username(target_username)
for item in self._load_task_items():
if item.get("status") == "done":
continue
if source and normalize_username(item.get("source_username")) != source:
continue
if target and normalize_username(item.get("target_username")) != target:
continue
if kind and item.get("kind") != kind:
continue
return item
return None
def _notify_user_about_task_item(self, username: str, item: dict[str, Any]) -> None:
chat_id = self._private_chat_id_for_user(username)
if chat_id is None:
return
kind = "предложение" if item.get("kind") == "proposal" else "задача"
source = item.get("source_name") or item.get("source_username") or "кто-то"
title = item.get("title") or "(без названия)"
status = TASK_STATUS_LABELS.get(str(item.get("status") or "new"), str(item.get("status") or "new"))
if item.get("status") == "new":
text = f"У тебя новое {kind} от {source}: {title}\nID: {item.get('id')}"
else:
text = f"Обновление по {kind} {item.get('id')}: статус «{status}».\n{title}"
self._safe_send(chat_id, text)
def _send_player_welcome_once(self, chat_id: int, message_id: int, username: str) -> None:
uname = normalize_username(username)
sent = self.state.get("player_welcome_sent")
@@ -673,6 +886,9 @@ class ShinePyBotService:
"Можно задавать вопросы по проекту, просить анализ, идеи и подготовку готового ТЗ.\n"
"Команда /new начинает новую сессию и архивирует текущую историю."
)
reminder = self._task_center_counts_text(uname)
if reminder:
text = f"{text}\n\n{reminder}\nКоманда /tasks покажет список."
self._safe_send(chat_id, text, reply_to=message_id)
sent[uname] = now_iso()
self._persist_state()
@@ -829,6 +1045,9 @@ class ShinePyBotService:
self._handle_command(chat_id, message_id, actor_username, text)
return
if self._handle_task_center_text(chat_id, message_id, actor_username, text):
return
self._append_history(history_path, "incoming_text", {
"chatId": chat_id,
"messageId": message_id,
@@ -933,6 +1152,104 @@ class ShinePyBotService:
"active_since": None,
}
def _handle_task_center_text(self, chat_id: int, message_id: int, username: str, text: str) -> bool:
source_text = (text or "").strip()
lower = source_text.lower()
is_owner = self._is_owner(username)
if re.search(r"\b(покажи|список|какие)\b.*\b(задач|задачи|предложени)", lower):
target = username
explicit_target = self._find_user_by_text(source_text)
if is_owner and explicit_target:
target = explicit_target
self._safe_send(chat_id, self._format_task_items(target), reply_to=message_id)
return True
if is_owner:
assign_match = re.search(
r"(?:поставь|добавь|создай|запиши)\s+(?:задачу|задание)\s+(.+?)(?::|\s+-\s+|\s+—\s+)(.+)",
source_text,
flags=re.IGNORECASE | re.DOTALL,
)
if assign_match:
target = self._find_user_by_text(assign_match.group(1))
body = assign_match.group(2).strip()
if target and body:
item = self._create_task_item(
kind="task",
title=body,
text=body,
source_username=username,
target_username=target,
source_message_id=message_id,
source_chat_id=chat_id,
)
self._notify_user_about_task_item(target, item)
self._safe_send(
chat_id,
f"Задача добавлена для {self._display_name(target)}: {item['id']}{item['title']}",
reply_to=message_id,
)
return True
status_match = re.search(r"\b(одобрить|отклонить|доработать|закрыть|сделано|закрыта)\b(?:\s+(.+))?", lower, flags=re.IGNORECASE)
if status_match and ("задач" in lower or "предложени" in lower or re.search(r"tc-\d+", lower, flags=re.IGNORECASE)):
action = status_match.group(1)
tail = status_match.group(2) or ""
status = {
"одобрить": "approved",
"отклонить": "rejected",
"доработать": "needs_work",
"закрыть": "done",
"сделано": "done",
"закрыта": "done",
}.get(action, "new")
id_match = re.search(r"tc-\d+", source_text, flags=re.IGNORECASE)
item = None
if id_match:
item = self._update_task_item_status(id_match.group(0), status)
else:
source_user = self._find_user_by_text(tail)
item = self._find_first_task_item(
source_username=source_user,
target_username=username,
kind="proposal" if "предложени" in lower else "",
)
if item:
item = self._update_task_item_status(str(item.get("id")), status)
if item:
label = TASK_STATUS_LABELS.get(status, status)
self._safe_send(chat_id, f"{item.get('id')} обновлена: {label}.", reply_to=message_id)
source_user = normalize_username(item.get("source_username"))
if source_user and source_user != username:
self._notify_user_about_task_item(source_user, item)
return True
if not is_owner:
proposal_match = re.match(r"\s*(?:предложение|идея|заявка)\s*[:-]\s*(.+)", source_text, flags=re.IGNORECASE | re.DOTALL)
if proposal_match:
body = proposal_match.group(1).strip()
if body:
item = self._create_task_item(
kind="proposal",
title=body,
text=body,
opinion="Нужно решение Айдара: одобрить, отклонить или отправить на доработку.",
source_username=username,
target_username=self.cfg.allowed_username,
source_message_id=message_id,
source_chat_id=chat_id,
)
self._notify_user_about_task_item(self.cfg.allowed_username, item)
self._safe_send(
chat_id,
f"Предложение отправлено Айдару как {item['id']}. Статус: новая.",
reply_to=message_id,
)
return True
return False
def _handle_command(self, chat_id: int, message_id: int, username: str, text: str) -> None:
lower = text.lower()
command = lower.split(maxsplit=1)[0].split("@", 1)[0]
@@ -946,6 +1263,15 @@ class ShinePyBotService:
if command == "/queue":
self._safe_send(chat_id, self._queue_text(), reply_to=message_id)
return
if command in ("/tasks", "/my_tasks"):
parts = text.split(maxsplit=1)
target = username
if is_owner and len(parts) > 1:
parsed = self._find_user_by_text(parts[1])
if parsed:
target = parsed
self._safe_send(chat_id, self._format_task_items(target), reply_to=message_id)
return
if command == "/voice_on":
self._set_voice_replies_enabled(username, True)
self._append_history_event("voice_replies_enabled", {"username": normalize_username(username)}, username=username)
@@ -956,14 +1282,8 @@ class ShinePyBotService:
self._append_history_event("voice_replies_disabled", {"username": normalize_username(username)}, username=username)
self._safe_send(chat_id, "Озвучивание финальных ответов выключено для вашего пользователя.", reply_to=message_id)
return
if command == "/voice_status":
status = "включено" if self._voice_replies_enabled(username) else "выключено"
rewrite_status = "включена" if self._voice_rewrite_enabled(username) else "выключена"
self._safe_send(
chat_id,
f"Озвучивание финальных ответов: {status}.\nАдаптация текста перед озвучкой: {rewrite_status}.",
reply_to=message_id,
)
if command in ("/voice_status", "/voice_rewrite_status"):
self._safe_send(chat_id, self._status_text(username), reply_to=message_id)
return
if command == "/voice_rewrite_on":
self._set_voice_rewrite_enabled(username, True)
@@ -975,10 +1295,6 @@ class ShinePyBotService:
self._append_history_event("voice_rewrite_disabled", {"username": normalize_username(username)}, username=username)
self._safe_send(chat_id, "Адаптация текста перед озвучкой выключена для вашего пользователя.", reply_to=message_id)
return
if command == "/voice_rewrite_status":
status = "включена" if self._voice_rewrite_enabled(username) else "выключена"
self._safe_send(chat_id, f"Адаптация текста перед озвучкой: {status}.", reply_to=message_id)
return
if command == "/new":
archived = self._rotate_history("command_new", username)
self._safe_send(chat_id, f"История очищена. Новый диалог начат.\nАрхив: {archived.name}", reply_to=message_id)
@@ -987,17 +1303,33 @@ class ShinePyBotService:
if not is_owner:
self._safe_send(chat_id, "Команда недоступна.", reply_to=message_id)
return
self._append_history_event("restart_service_requested", {
self._append_history_event("restart_service_deferred_requested", {
"chatId": chat_id,
"messageId": message_id,
"username": username,
}, username=username)
self._safe_send(
chat_id,
"Перезапускаю сервис. Если задача была активна, после старта она вернётся в очередь и продолжится.",
"Отложенный рестарт принят. Если задача сейчас выполняется, сервис перезапустится после её завершения и до следующей задачи.",
reply_to=message_id,
)
self._schedule_self_restart()
self._request_deferred_restart()
return
if command in ("/restart_hard", "/restart_now", "/restart_force"):
if not is_owner:
self._safe_send(chat_id, "Команда недоступна.", reply_to=message_id)
return
self._append_history_event("restart_service_hard_requested", {
"chatId": chat_id,
"messageId": message_id,
"username": username,
}, username=username)
self._safe_send(
chat_id,
"Выполняю жёсткий рестарт сервиса прямо сейчас. Активная задача, если есть, будет прервана и после старта вернётся в очередь.",
reply_to=message_id,
)
self._schedule_self_restart("hard_restart_requested", force=True)
return
if command == "/stop":
stopped = self._cancel_active_job("stopped_by_user")
@@ -1030,6 +1362,7 @@ class ShinePyBotService:
"Доступные команды:",
"/status — активная задача и размер очереди",
"/queue — список задач в очереди",
"/tasks — список ваших задач и предложений",
"/stop — остановить текущую задачу",
"/cancel <id|all> — удалить задачу по id (префикс) или все",
"/new — архивировать историю и начать новую",
@@ -1037,11 +1370,12 @@ class ShinePyBotService:
"/voice_off — выключить озвучивание финальных ответов",
"/voice_rewrite_on — включить адаптацию текста перед озвучкой",
"/voice_rewrite_off — выключить адаптацию текста перед озвучкой",
"/voice_status — показать состояние голосовых функций",
"/help — эта справка",
]
if is_owner:
lines.insert(-1, "/restart_service — перезапустить сервис через systemd")
lines.insert(-1, "/tasks <пользователь> — список задач игрока")
lines.insert(-1, "/restart — отложенный рестарт после текущей задачи")
lines.insert(-1, "/restart_hard — жёсткий рестарт прямо сейчас")
return "\n".join(lines)
def _status_text(self, username: str) -> str:
@@ -1054,8 +1388,9 @@ class ShinePyBotService:
f"Голосовые ответы: {voice_status}\n"
f"Адаптация текста перед озвучкой: {rewrite_status}"
)
restart_text = "\nОтложенный рестарт: ожидает завершения текущей задачи" if self.restart_requested else ""
if not active:
return f"Статус: активной задачи нет.\nВ очереди pending: {pending}\n{settings_text}"
return f"Статус: активной задачи нет.\nВ очереди pending: {pending}\n{settings_text}{restart_text}"
elapsed = int(time.time() - (self.active_job_started_at or time.time()))
return (
f"Статус: активная задача #{active.get('num', '?')}\n"
@@ -1063,7 +1398,7 @@ class ShinePyBotService:
f"Попытка: {int(active.get('attempts', 0)) + 1}/{self.cfg.max_retries}\n"
f"Выполняется: {elapsed}с\n"
f"Pending: {pending}\n"
f"{settings_text}"
f"{settings_text}{restart_text}"
)
def _queue_text(self) -> str:
@@ -1115,6 +1450,9 @@ class ShinePyBotService:
def _worker_loop(self) -> None:
while not self.stop_event.is_set():
if self.restart_requested:
self._exit_for_restart("deferred_restart_before_next_job")
return
job = None
with self.queue_lock:
for item in self.queue:
@@ -1135,6 +1473,9 @@ class ShinePyBotService:
self._process_job(job)
self.active_job_id = None
self.active_job_started_at = None
if self.restart_requested:
self._exit_for_restart("deferred_restart_after_job")
return
def _process_job(self, job: dict[str, Any]) -> None:
job_id = job["id"]
@@ -1162,6 +1503,7 @@ class ShinePyBotService:
self._safe_send(chat_id, chunk, reply_to=message_id)
self._append_history(history_path, "codex_response", {"jobId": job_id, "text": answer})
self._send_private_job_public_report(job, answer)
self._send_task_center_reminder(job)
if self._voice_replies_enabled(job.get("username") or ""):
self._send_voice_reply_for_answer(job, answer, history_path, job_id)
self._safe_send(chat_id, f"Готово #{job_num}.", reply_to=message_id)
@@ -1390,6 +1732,15 @@ class ShinePyBotService:
self.queue = [j for j in self.queue if j.get("id") != job_id]
self._persist_queue()
def _send_task_center_reminder(self, job: dict[str, Any]) -> None:
if job.get("chat_type") != "private":
return
username = job.get("username") or ""
reminder = self._task_center_counts_text(username)
if not reminder:
return
self._safe_send(int(job["chat_id"]), reminder, reply_to=int(job["message_id"]))
def _remember_public_report_chat(self, chat_id: int) -> None:
if self.state.get("public_report_chat_id") == chat_id:
return
@@ -1575,14 +1926,36 @@ class ShinePyBotService:
print(f"[py-bot] sendMessage error: {e}", flush=True)
return None
def _schedule_self_restart(self) -> None:
def _request_deferred_restart(self) -> None:
if self.restart_requested:
return
self.restart_requested = True
self._append_history_event("restart_service_deferred_scheduled", {})
with self.queue_lock:
has_active = any(j.get("status") == "active" for j in self.queue)
if not has_active:
threading.Thread(
target=lambda: self._exit_for_restart("deferred_restart_no_active_job"),
name="shine-py-bot-deferred-restart",
daemon=True,
).start()
def _exit_for_restart(self, reason: str) -> None:
print(f"[py-bot] restart now: {reason}", flush=True)
self._append_history_event("restart_service_executing", {"reason": reason})
time.sleep(0.5)
os._exit(0)
def _schedule_self_restart(self, reason: str = "restart_requested", *, force: bool = False) -> None:
if self.restart_requested and not force:
return
self.restart_requested = True
def restart() -> None:
time.sleep(1.5)
print("[py-bot] restart requested by Telegram command", flush=True)
print(f"[py-bot] restart requested by Telegram command: {reason}", flush=True)
if force:
self._stop_active_codex_process()
os._exit(0)
threading.Thread(target=restart, name="shine-py-bot-self-restart", daemon=True).start()
@@ -1725,10 +2098,11 @@ class ShinePyBotService:
{
"role": "system",
"content": (
"Ты готовишь короткую русскую голосовую версию финального ответа технического агента. "
"Сохрани итог, важные предупреждения и действия. Убери длинные пути, хэши, команды, номера версий, "
"JSON, списки файлов и другие строки, которые плохо воспринимаются на слух. "
"Не добавляй новых фактов. Пиши естественно, кратко, без markdown."
"Ты готовишь русскую версию финального ответа технического агента для озвучивания. "
"Не пересказывай заново и не меняй смысл: сохрани порядок мыслей, итог, предупреждения, статусы и важные действия. "
"Мягко убери только то, что плохо воспринимается на слух: длинные пути, хэши, ID, команды, JSON, "
"длинные списки файлов, точные размеры и счётчики символов. Если деталь важна, замени её коротким описанием. "
"Не добавляй новых фактов. Пиши естественно, без markdown, близко к исходному тексту."
),
},
{