.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
dist/
|
||||
references/
|
||||
__pycache__/
|
||||
Generated
+10
@@ -0,0 +1,10 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
@@ -1,2 +1,66 @@
|
||||
# chat-summaries
|
||||
# AI Chat Summaries Plugin for exteraGram
|
||||
|
||||
Advanced AI-powered Telegram chat summarization plugin for [exteraGram](https://exteragram.app).
|
||||
|
||||
## Features
|
||||
|
||||
- **ChatGPT OAuth Integration**: Secure browser authorization with automatic tier detection (Free: `luna`, `gpt-4o-mini`; Plus/Pro/Business: `terra`, `sol`, `gpt-4o`, `o1`, `o3-mini`).
|
||||
- **Custom AI Provider**: OpenAI-compatible custom endpoints (OpenRouter, DeepSeek, Together AI, local vLLM/Ollama) with **Multi-Stage Verification**:
|
||||
- Stage 1: URL reachability test.
|
||||
- Stage 2: Lightweight probe test (`max_tokens: 10`, thinking disabled).
|
||||
- Stage 3: Error logs with one-click full sanitized request log copier.
|
||||
- Stage 4: Context window token budgeting (8k to 1M) preserving up to 8,000 characters per message.
|
||||
- **Built-in Connectors**: Direct API key connectors for OpenAI, Anthropic Claude (with Claude 3.7 reasoning tokens), Google Gemini, and Ollama.
|
||||
- **Dual Triggers**:
|
||||
1. **Pinned Header AI Button**: Top-right AI action button next to the pinned message bar.
|
||||
2. **Unread Badge Long-Press**: Long-press on floating unread badge or unread divider in chat with auto-filled message count.
|
||||
3. **Chat Action Bar Menu**: Fallback action item for universal accessibility.
|
||||
- **Animated Progress Widget**: Dynamic in-header pulsing progress indicator that shrinks `PinnedMessageView` to prevent text overlap.
|
||||
- **Live Thinking Stream Preview**: Real-time streaming inspector for reasoning and thought process tokens (`reasoning_content`, `<think>...</think>`, `delta.thought`). Can be toggled off in settings to save mobile bandwidth.
|
||||
- **Per-Chat Customization**: Pre-request bottom sheet with message count slider, style chips (Brief, Detailed, Key Highlights, Custom), and per-chat prompt override.
|
||||
- **Rich Result Dialog**: Formatted Markdown rendering with Share, Copy Summary, Copy Full Prompt & Data, Regenerate, and Insert into Chat Composer.
|
||||
- **Localization**: Full English (`en`) and Russian (`ru`) translations.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
exteragram-chat-summaries/
|
||||
├── ai_chat_summary.plugin # Main entrypoint & metadata header
|
||||
├── build.py # Standalone bundler & syntax validator
|
||||
├── src/
|
||||
│ ├── config.py # Configuration constants & model tiers
|
||||
│ ├── localization.py # LocalizationManager (EN/RU dictionaries)
|
||||
│ ├── providers/
|
||||
│ │ ├── base.py # Base provider interface, debug logger, prompt builder
|
||||
│ │ ├── oauth.py # ChatGPT OAuth handler & tier mapper
|
||||
│ │ ├── custom.py # Custom provider with multi-stage verification
|
||||
│ │ ├── builtin.py # OpenAI, Anthropic, Gemini, Ollama direct handlers
|
||||
│ │ └── dispatcher.py # Unified dispatcher & live streaming state manager
|
||||
│ ├── services/
|
||||
│ │ └── message_fetcher.py # TLRPC history pagination & media formatting
|
||||
│ ├── ui/
|
||||
│ │ ├── settings.py # Dynamic settings layout & verification dialogs
|
||||
│ │ ├── pre_request.py # Pre-request bottom sheet & prompt customizer
|
||||
│ │ ├── progress_widget.py # In-header progress widget & pinned view shrinker
|
||||
│ │ ├── thinking_sheet.py # Live streaming reasoning viewer
|
||||
│ │ └── summary_dialog.py # Markdown result presentation & error log viewer
|
||||
│ └── hooks/
|
||||
│ ├── pinned_hook.py # PinnedMessageView top bar hook
|
||||
│ └── unread_hook.py # ChatUnreadCell & floating badge long-press hook
|
||||
├── tests/
|
||||
│ └── test_plugin.py # Unit & simulation test suite
|
||||
└── dist/
|
||||
└── ai_chat_summary.plugin # Bundled single-file distributable artifact
|
||||
```
|
||||
|
||||
## Bundling & Testing
|
||||
|
||||
To bundle into a single-file distributable artifact:
|
||||
```bash
|
||||
python3 build.py
|
||||
```
|
||||
|
||||
To run the unit and simulation test suite:
|
||||
```bash
|
||||
python3 -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 324 KiB |
@@ -0,0 +1,884 @@
|
||||
from typing import Any, List
|
||||
|
||||
from base_plugin import BasePlugin, MenuItemData, MenuItemType
|
||||
from client_utils import (
|
||||
get_last_fragment, get_messages_controller,
|
||||
run_on_queue, send_request, PLUGINS_QUEUE,
|
||||
)
|
||||
from android_utils import run_on_ui_thread, log, OnClickListener, copy_to_clipboard
|
||||
from ui.settings import Header, Divider, Selector, Input, Text
|
||||
from ui.bulletin import BulletinHelper
|
||||
from org.telegram.messenger import AndroidUtilities
|
||||
from org.telegram.ui.ActionBar import Theme
|
||||
|
||||
__id__ = "chat_summary"
|
||||
__name__ = "Chat Summary"
|
||||
__description__ = "Саммари любого чата через AI — OpenAI, Anthropic, Gemini, Ollama или свой endpoint."
|
||||
__author__ = "@aaxnet"
|
||||
__version__ = "2.2.0" # Обновили версию в связи с добавлением дебага и ретраев
|
||||
__icon__ = "KOSHAKIEBANIYE/44"
|
||||
__app_version__ = ">=12.5.1"
|
||||
__sdk_version__ = ">=1.4.3.3"
|
||||
__requirements__ = ["requests"]
|
||||
|
||||
PROVIDER_OPENAI = 0
|
||||
PROVIDER_ANTHROPIC = 1
|
||||
PROVIDER_GEMINI = 2
|
||||
PROVIDER_OLLAMA = 3
|
||||
PROVIDER_CUSTOM = 4
|
||||
|
||||
PROVIDERS = [
|
||||
"OpenAI (GPT-4o)",
|
||||
"Anthropic (Claude)",
|
||||
"Gemini",
|
||||
"Ollama (local)",
|
||||
"Custom endpoint",
|
||||
]
|
||||
|
||||
DEFAULT_MODELS = [
|
||||
"gpt-4o",
|
||||
"claude-sonnet-4-5",
|
||||
"gemini-2.0-flash",
|
||||
"llama3",
|
||||
"",
|
||||
]
|
||||
|
||||
STYLE_BRIEF = 0
|
||||
STYLE_DETAILED = 1
|
||||
STYLE_BULLETS = 2
|
||||
|
||||
STYLES = ["Краткий", "Подробный", "По пунктам"]
|
||||
STYLE_HINTS = [
|
||||
"2–3 sentences о главном",
|
||||
"Все темы и детали",
|
||||
"Список ключевых моментов",
|
||||
]
|
||||
|
||||
LANG_AUTO = 0
|
||||
LANG_RU = 1
|
||||
LANG_EN = 2
|
||||
|
||||
LANGS = ["Авто", "Русский", "English"]
|
||||
|
||||
MSG_COUNTS = [50, 100, 200, 500]
|
||||
|
||||
MAX_CONTENT_CHARS = 48000
|
||||
|
||||
KEY_PREFIXES = {
|
||||
PROVIDER_OPENAI: "sk-",
|
||||
PROVIDER_ANTHROPIC: "sk-ant-",
|
||||
PROVIDER_GEMINI: "AIza",
|
||||
PROVIDER_CUSTOM: "sk-",
|
||||
}
|
||||
|
||||
PROVIDER_MODEL_LABELS = {
|
||||
PROVIDER_OPENAI: "OpenAI",
|
||||
PROVIDER_ANTHROPIC: "Anthropic",
|
||||
PROVIDER_GEMINI: "Gemini",
|
||||
PROVIDER_OLLAMA: "Ollama",
|
||||
PROVIDER_CUSTOM: "Custom",
|
||||
}
|
||||
|
||||
|
||||
def check_key_format(provider, api_key):
|
||||
"""Returns (is_valid, hint_text) for a quick format check, not a real auth check."""
|
||||
api_key = (api_key or "").strip()
|
||||
if provider == PROVIDER_OLLAMA:
|
||||
return True, "Ключ не требуется"
|
||||
if not api_key:
|
||||
return False, "Ключ не указан"
|
||||
prefix = KEY_PREFIXES.get(provider)
|
||||
if prefix and not api_key.startswith(prefix):
|
||||
return False, "Похоже на неверный формат (ожидается " + prefix + "...)"
|
||||
if len(api_key) < 16:
|
||||
return False, "Слишком короткий ключ"
|
||||
return True, "Формат похож на правильный"
|
||||
|
||||
|
||||
def get_theme_color(key, fallback):
|
||||
try:
|
||||
return Theme.getColor(getattr(Theme, key))
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
|
||||
def build_system_prompt(style, lang):
|
||||
lang_instructions = {
|
||||
LANG_AUTO: "Respond in the same language as the conversation.",
|
||||
LANG_RU: "Respond in Russian.",
|
||||
LANG_EN: "Respond in English.",
|
||||
}
|
||||
style_instructions = {
|
||||
STYLE_BRIEF: "Write a concise 2-3 sentence summary of the key points.",
|
||||
STYLE_DETAILED: "Write a detailed summary covering all important topics discussed.",
|
||||
STYLE_BULLETS: "Write a bullet-point list of the main topics and decisions.",
|
||||
}
|
||||
lang_part = lang_instructions.get(lang, "")
|
||||
style_part = style_instructions.get(style, "")
|
||||
return (
|
||||
"You are a helpful assistant that summarizes Telegram chat conversations. "
|
||||
+ style_part + " " + lang_part + " "
|
||||
"Focus only on content. Do not include meta-commentary."
|
||||
)
|
||||
|
||||
|
||||
def parse_java_list(java_list):
|
||||
result = {}
|
||||
if not java_list:
|
||||
return result
|
||||
try:
|
||||
size = java_list.size()
|
||||
for i in range(size):
|
||||
item = java_list.get(i)
|
||||
try:
|
||||
result[int(item.id)] = item
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def get_sender_name(raw_msg, user_map, chat_map):
|
||||
fid = getattr(raw_msg, "from_id", None)
|
||||
if fid is None:
|
||||
fid = getattr(raw_msg, "peer_id", None)
|
||||
if fid is None:
|
||||
return "Unknown"
|
||||
|
||||
uid = getattr(fid, "user_id", None)
|
||||
channel_id = getattr(fid, "channel_id", None)
|
||||
chat_id = getattr(fid, "chat_id", None)
|
||||
|
||||
if uid:
|
||||
uid = int(uid)
|
||||
user = user_map.get(uid)
|
||||
if user:
|
||||
first = getattr(user, "first_name", "") or ""
|
||||
last = getattr(user, "last_name", "") or ""
|
||||
name = (first + " " + last).strip()
|
||||
return name if name else "User" + str(uid)
|
||||
return "User" + str(uid)
|
||||
|
||||
if channel_id:
|
||||
channel_id = int(channel_id)
|
||||
chat = chat_map.get(channel_id)
|
||||
if chat:
|
||||
return getattr(chat, "title", None) or "Channel" + str(channel_id)
|
||||
return "Channel" + str(channel_id)
|
||||
|
||||
if chat_id:
|
||||
chat_id = int(chat_id)
|
||||
chat = chat_map.get(chat_id)
|
||||
if chat:
|
||||
return getattr(chat, "title", None) or "Chat" + str(chat_id)
|
||||
return "Chat" + str(chat_id)
|
||||
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def get_message_text(msg, raw_msg):
|
||||
mt = getattr(msg, "messageText", None)
|
||||
if mt:
|
||||
return str(mt)
|
||||
raw_text = getattr(raw_msg, "message", None)
|
||||
if raw_text:
|
||||
return str(raw_text)
|
||||
return ""
|
||||
|
||||
|
||||
def build_transcript(messages, users=None, chats=None):
|
||||
user_map = parse_java_list(users)
|
||||
chat_map = parse_java_list(chats)
|
||||
|
||||
size = messages.size() if hasattr(messages, "size") else len(messages)
|
||||
lines = []
|
||||
used = 0
|
||||
|
||||
for i in range(size - 1, -1, -1):
|
||||
try:
|
||||
msg = messages.get(i) if hasattr(messages, "get") else messages[i]
|
||||
raw = getattr(msg, "messageOwner", msg)
|
||||
|
||||
cls = type(raw).__name__
|
||||
if "Service" in cls or "Empty" in cls:
|
||||
continue
|
||||
|
||||
sender = get_sender_name(raw, user_map, chat_map)
|
||||
text = get_message_text(msg, raw)
|
||||
|
||||
if text.strip():
|
||||
lines.append(sender + ": " + text)
|
||||
used += 1
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
full_text = "\n".join(lines)
|
||||
if len(full_text) > MAX_CONTENT_CHARS:
|
||||
full_text = "...[обрезано]\n" + full_text[-MAX_CONTENT_CHARS:]
|
||||
|
||||
return full_text, used
|
||||
|
||||
|
||||
PROVIDER_EMOJIS = ["🤖", "🧠", "✨", "💻", "🔗"]
|
||||
|
||||
LONG_SUMMARY_THRESHOLD = 600 # chars, above this we add a scroll + "via model" footer is still shown either way
|
||||
|
||||
|
||||
def show_result(act, summary, msg_count, provider, model):
|
||||
try:
|
||||
from ui.alert import AlertDialogBuilder
|
||||
from android.widget import LinearLayout, TextView, ScrollView
|
||||
from android.view import Gravity
|
||||
from android.util import TypedValue
|
||||
|
||||
dp = AndroidUtilities.dp
|
||||
|
||||
provider_label = PROVIDER_MODEL_LABELS.get(provider, "AI")
|
||||
model_label = model.strip() if model and model.strip() else (
|
||||
DEFAULT_MODELS[provider] if 0 <= provider < len(DEFAULT_MODELS) else ""
|
||||
)
|
||||
footer_text = "via " + provider_label + (" · " + model_label if model_label else "")
|
||||
|
||||
def on_copy(b, w):
|
||||
copy_to_clipboard(summary)
|
||||
BulletinHelper.show_info("Скопировано ✓")
|
||||
b.dismiss()
|
||||
|
||||
def on_share(b, w):
|
||||
try:
|
||||
from android.content import Intent
|
||||
intent = Intent(Intent.ACTION_SEND)
|
||||
intent.setType("text/plain")
|
||||
intent.putExtra(Intent.EXTRA_TEXT, summary)
|
||||
act.startActivity(Intent.createChooser(intent, "Поделиться саммари"))
|
||||
except Exception as ex:
|
||||
log("chat_summary: share error: " + str(ex))
|
||||
b.dismiss()
|
||||
|
||||
root = LinearLayout(act)
|
||||
root.setOrientation(LinearLayout.VERTICAL)
|
||||
root.setPadding(dp(24), dp(8), dp(24), dp(4))
|
||||
|
||||
scroll = ScrollView(act)
|
||||
max_height_px = dp(360)
|
||||
|
||||
body = TextView(act)
|
||||
body.setText(summary)
|
||||
body.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 15)
|
||||
body.setTextColor(get_theme_color("key_dialogTextBlack", 0xFF212121))
|
||||
body.setLineSpacing(dp(2), 1.0)
|
||||
scroll.addView(body)
|
||||
|
||||
lp_scroll = LinearLayout.LayoutParams(-1, -2)
|
||||
if len(summary) > LONG_SUMMARY_THRESHOLD:
|
||||
lp_scroll = LinearLayout.LayoutParams(-1, max_height_px)
|
||||
root.addView(scroll, lp_scroll)
|
||||
|
||||
if len(summary) > LONG_SUMMARY_THRESHOLD:
|
||||
hint = TextView(act)
|
||||
hint.setText("↕ прокрути для остального текста")
|
||||
hint.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 11)
|
||||
hint.setGravity(Gravity.CENTER)
|
||||
hint.setTextColor(get_theme_color("key_windowBackgroundWhiteGrayText", 0xFF888888))
|
||||
lp_hint = LinearLayout.LayoutParams(-1, -2)
|
||||
lp_hint.topMargin = dp(6)
|
||||
root.addView(hint, lp_hint)
|
||||
|
||||
footer = TextView(act)
|
||||
footer.setText(footer_text)
|
||||
footer.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 11)
|
||||
footer.setGravity(Gravity.CENTER)
|
||||
footer.setTextColor(get_theme_color("key_windowBackgroundWhiteGrayText", 0xFF999999))
|
||||
lp_footer = LinearLayout.LayoutParams(-1, -2)
|
||||
lp_footer.topMargin = dp(14)
|
||||
lp_footer.bottomMargin = dp(4)
|
||||
root.addView(footer, lp_footer)
|
||||
|
||||
builder = AlertDialogBuilder(act)
|
||||
builder.set_title("✨ Саммари · " + str(msg_count) + " сообщений")
|
||||
builder.set_view(root)
|
||||
builder.set_positive_button("Закрыть", lambda b, w: b.dismiss())
|
||||
builder.set_negative_button("Копировать", on_copy)
|
||||
builder.set_neutral_button("Поделиться", on_share)
|
||||
builder.show()
|
||||
except Exception as e:
|
||||
log("chat_summary: show_result error: " + str(e))
|
||||
try:
|
||||
from ui.alert import AlertDialogBuilder
|
||||
builder = AlertDialogBuilder(act)
|
||||
builder.set_title("✨ Саммари · " + str(msg_count) + " сообщений")
|
||||
builder.set_message(summary)
|
||||
builder.set_positive_button("Закрыть", lambda b, w: b.dismiss())
|
||||
builder.set_negative_button("Копировать", lambda b, w: (copy_to_clipboard(summary), b.dismiss()))
|
||||
builder.show()
|
||||
except Exception as e2:
|
||||
log("chat_summary: fallback dialog error: " + str(e2))
|
||||
|
||||
|
||||
def show_spinner(act, title="Анализирую"):
|
||||
try:
|
||||
from ui.alert import AlertDialogBuilder
|
||||
from android.os import Handler, Looper
|
||||
|
||||
d = AlertDialogBuilder(act, AlertDialogBuilder.ALERT_TYPE_SPINNER)
|
||||
d.set_title(title + "…")
|
||||
d.set_cancelable(False)
|
||||
d.show()
|
||||
|
||||
dots = ["", ".", "..", "..."]
|
||||
state = [0, True]
|
||||
handler = Handler(Looper.getMainLooper())
|
||||
|
||||
def tick():
|
||||
if not state[1]:
|
||||
return
|
||||
state[0] = (state[0] + 1) % len(dots)
|
||||
try:
|
||||
d.set_title(title + dots[state[0]])
|
||||
except Exception:
|
||||
state[1] = False
|
||||
return
|
||||
handler.postDelayed(tick, 500)
|
||||
|
||||
handler.postDelayed(tick, 500)
|
||||
|
||||
original_dismiss = d.dismiss
|
||||
def dismiss_and_stop():
|
||||
state[1] = False
|
||||
try:
|
||||
original_dismiss()
|
||||
except Exception:
|
||||
pass
|
||||
d.dismiss = dismiss_and_stop
|
||||
|
||||
return d
|
||||
except Exception as e:
|
||||
log("chat_summary: spinner error: " + str(e))
|
||||
return None
|
||||
|
||||
|
||||
def dismiss_dialog(dlg):
|
||||
if dlg is None:
|
||||
return
|
||||
try:
|
||||
dlg.dismiss()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def call_openai(api_key, model, system, content, url=None):
|
||||
import requests
|
||||
model = model or "gpt-4o"
|
||||
url = url or "https://api.openai.com/v1/chat/completions"
|
||||
resp = requests.post(
|
||||
url,
|
||||
headers={
|
||||
"Authorization": "Bearer " + api_key,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": content},
|
||||
],
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["choices"][0]["message"]["content"]
|
||||
|
||||
|
||||
def call_anthropic(api_key, model, system, content):
|
||||
import requests
|
||||
model = model or "claude-sonnet-4-5"
|
||||
resp = requests.post(
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
headers={
|
||||
"x-api-key": api_key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": model,
|
||||
"max_tokens": 1024,
|
||||
"system": system,
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["content"][0]["text"]
|
||||
|
||||
|
||||
def call_gemini(api_key, model, system, content):
|
||||
import requests
|
||||
model = model or "gemini-2.0-flash"
|
||||
url = (
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/"
|
||||
+ model
|
||||
+ ":generateContent?key="
|
||||
+ api_key
|
||||
)
|
||||
resp = requests.post(
|
||||
url,
|
||||
json={"contents": [{"parts": [{"text": system + "\n\n" + content}]}]},
|
||||
timeout=60,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["candidates"][0]["content"]["parts"][0]["text"]
|
||||
|
||||
|
||||
def call_ollama(model, system, content):
|
||||
import requests
|
||||
model = model or "llama3"
|
||||
resp = requests.post(
|
||||
"http://localhost:11434/api/chat",
|
||||
json={
|
||||
"model": model,
|
||||
"stream": False,
|
||||
"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": content},
|
||||
],
|
||||
},
|
||||
timeout=120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["message"]["content"]
|
||||
|
||||
|
||||
# ОБНОВЛЕНО: Добавлен алгоритм Exponential Backoff для обработки ошибки 429
|
||||
def call_ai(provider, api_key, model, system, content, custom_url):
|
||||
import time
|
||||
import random
|
||||
|
||||
max_retries = 3 # Количество попыток автоматического перезапуска
|
||||
delay = 2 # Начальная задержка в секундах
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
if provider == PROVIDER_OPENAI:
|
||||
return call_openai(api_key, model, system, content)
|
||||
if provider == PROVIDER_ANTHROPIC:
|
||||
return call_anthropic(api_key, model, system, content)
|
||||
if provider == PROVIDER_GEMINI:
|
||||
return call_gemini(api_key, model, system, content)
|
||||
if provider == PROVIDER_OLLAMA:
|
||||
return call_ollama(model, system, content)
|
||||
if provider == PROVIDER_CUSTOM:
|
||||
return call_openai(api_key, model, system, content, url=custom_url)
|
||||
raise ValueError("Unknown provider: " + str(provider))
|
||||
except Exception as e:
|
||||
err_msg = str(e)
|
||||
# Если словили 429 (Превышение лимитов) и лимит попыток не исчерпан
|
||||
if "429" in err_msg and attempt < max_retries - 1:
|
||||
sleep_time = delay + random.uniform(0, 1)
|
||||
log("chat_summary: Hit 429 Rate Limit. Retrying in " + str(round(sleep_time, 2)) + "s...")
|
||||
time.sleep(sleep_time)
|
||||
delay *= 2 # Удваиваем время ожидания для следующего шага
|
||||
continue
|
||||
# Во всех остальных случаях (или если попытки кончились) выбрасываем ошибку дальше
|
||||
raise e
|
||||
|
||||
|
||||
# ОБНОВЛЕНО: Добавлено явное и красивое описание ошибки 429 при тесте связи
|
||||
def test_connection(provider, api_key, model, custom_url):
|
||||
"""Sends a minimal ping request, returns (ok, message)."""
|
||||
try:
|
||||
system = "Reply with exactly one word: OK."
|
||||
content = "ping"
|
||||
result = call_ai(provider, api_key, model, system, content, custom_url)
|
||||
if result and result.strip():
|
||||
return True, "Подключение работает ✓"
|
||||
return False, "Пустой ответ от сервера"
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
if "429" in msg:
|
||||
return False, "Превышен лимит запросов (ошибка 429). Подожди немного."
|
||||
if "401" in msg or "Unauthorized" in msg:
|
||||
return False, "Неверный API ключ"
|
||||
if "404" in msg:
|
||||
return False, "Модель не найдена"
|
||||
if "timeout" in msg.lower() or "timed out" in msg.lower():
|
||||
return False, "Таймаут — сервер не отвечает"
|
||||
return False, "Ошибка: " + msg[:80]
|
||||
|
||||
|
||||
class ChatSummaryPlugin(BasePlugin):
|
||||
|
||||
def on_plugin_load(self):
|
||||
self.last_error = "Ошибок пока не зафиксировано" # Трекер для Debug-кнопки
|
||||
self.add_menu_item(MenuItemData(
|
||||
menu_type=MenuItemType.CHAT_ACTION_MENU,
|
||||
text="Summarize Chat",
|
||||
subtext="Краткое AI-саммари",
|
||||
icon="msg_info",
|
||||
on_click=self.on_menu_click,
|
||||
))
|
||||
|
||||
def on_plugin_unload(self):
|
||||
pass
|
||||
|
||||
def on_menu_click(self, context):
|
||||
dialog_id = context.get("dialog_id")
|
||||
if not dialog_id:
|
||||
return
|
||||
fragment = context.get("fragment") or get_last_fragment()
|
||||
if not fragment:
|
||||
return
|
||||
act = fragment.getParentActivity()
|
||||
if not act:
|
||||
return
|
||||
|
||||
provider = self.get_setting("provider", PROVIDER_OPENAI)
|
||||
api_key = self.get_setting("api_key", "") or ""
|
||||
if provider != PROVIDER_OLLAMA and not api_key.strip():
|
||||
BulletinHelper.show_error("Сначала укажи API ключ в настройках плагина")
|
||||
return
|
||||
|
||||
run_on_ui_thread(lambda: self.show_count_picker(act, dialog_id))
|
||||
|
||||
def show_count_picker(self, act, dialog_id):
|
||||
try:
|
||||
from android.widget import LinearLayout, TextView
|
||||
from android.view import Gravity
|
||||
from android.util import TypedValue
|
||||
from android.graphics.drawable import GradientDrawable
|
||||
from org.telegram.ui.ActionBar import BottomSheet
|
||||
|
||||
dp = AndroidUtilities.dp
|
||||
sheet_holder = [None]
|
||||
|
||||
provider = self.get_setting("provider", PROVIDER_OPENAI)
|
||||
emoji = PROVIDER_EMOJIS[provider] if 0 <= provider < len(PROVIDER_EMOJIS) else "🤖"
|
||||
provider_name = PROVIDERS[provider] if 0 <= provider < len(PROVIDERS) else "AI"
|
||||
|
||||
def pick(count):
|
||||
if sheet_holder[0]:
|
||||
sheet_holder[0].dismiss()
|
||||
BulletinHelper.show_info("Загружаю " + str(count) + " сообщений…")
|
||||
run_on_queue(
|
||||
lambda: self.fetch_and_summarize(act, dialog_id, count),
|
||||
PLUGINS_QUEUE, 0,
|
||||
)
|
||||
|
||||
root = LinearLayout(act)
|
||||
root.setOrientation(LinearLayout.VERTICAL)
|
||||
root.setPadding(dp(20), dp(20), dp(20), dp(28))
|
||||
try:
|
||||
root.setBackgroundColor(Theme.getColor(Theme.key_dialogBackground))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
icon_tv = TextView(act)
|
||||
icon_tv.setText(emoji)
|
||||
icon_tv.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 36)
|
||||
icon_tv.setGravity(Gravity.CENTER)
|
||||
lp = LinearLayout.LayoutParams(-1, -2)
|
||||
lp.bottomMargin = dp(8)
|
||||
root.addView(icon_tv, lp)
|
||||
|
||||
title = TextView(act)
|
||||
title.setText("Сколько сообщений?")
|
||||
title.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 18)
|
||||
title.setGravity(Gravity.CENTER)
|
||||
title.setTextColor(get_theme_color("key_dialogTextBlack", 0xFF212121))
|
||||
try:
|
||||
title.setTypeface(AndroidUtilities.bold())
|
||||
except Exception:
|
||||
pass
|
||||
lp = LinearLayout.LayoutParams(-1, -2)
|
||||
lp.bottomMargin = dp(4)
|
||||
root.addView(title, lp)
|
||||
|
||||
sub = TextView(act)
|
||||
sub.setText("Провайдер: " + provider_name)
|
||||
sub.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 13)
|
||||
sub.setGravity(Gravity.CENTER)
|
||||
sub.setTextColor(get_theme_color("key_windowBackgroundWhiteGrayText", 0xFF888888))
|
||||
lp = LinearLayout.LayoutParams(-1, -2)
|
||||
lp.bottomMargin = dp(20)
|
||||
root.addView(sub, lp)
|
||||
|
||||
count_hints = ["быстро", "оптимально", "подробно", "полный анализ"]
|
||||
|
||||
for i, count in enumerate(MSG_COUNTS):
|
||||
row = LinearLayout(act)
|
||||
row.setOrientation(LinearLayout.HORIZONTAL)
|
||||
row.setGravity(Gravity.CENTER_VERTICAL)
|
||||
row.setPadding(dp(16), dp(13), dp(16), dp(13))
|
||||
row.setClickable(True)
|
||||
row.setFocusable(True)
|
||||
|
||||
bg = GradientDrawable()
|
||||
bg.setCornerRadius(dp(14))
|
||||
try:
|
||||
accent = Theme.getColor(Theme.key_windowBackgroundWhiteBlueHeader)
|
||||
if i == 1:
|
||||
bg.setColor(accent & 0x22FFFFFF | 0x15000000)
|
||||
else:
|
||||
bg.setColor(get_theme_color("key_windowBackgroundWhite", 0xFFF5F5F5))
|
||||
except Exception:
|
||||
bg.setColor(0xFFF5F5F5)
|
||||
row.setBackground(bg)
|
||||
|
||||
label = TextView(act)
|
||||
label.setText(str(count) + " сообщений")
|
||||
label.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 15)
|
||||
label.setTextColor(get_theme_color("key_windowBackgroundWhiteBlackText", 0xFF212121))
|
||||
try:
|
||||
if i == 1:
|
||||
label.setTypeface(AndroidUtilities.bold())
|
||||
except Exception:
|
||||
pass
|
||||
lp_label = LinearLayout.LayoutParams(0, -2, 1.0)
|
||||
row.addView(label, lp_label)
|
||||
|
||||
hint = TextView(act)
|
||||
hint_text = count_hints[i] if i < len(count_hints) else ""
|
||||
if i == 1:
|
||||
hint_text = "⭐ " + hint_text
|
||||
hint.setText(hint_text)
|
||||
hint.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 12)
|
||||
hint.setTextColor(get_theme_color("key_windowBackgroundWhiteGrayText", 0xFF888888))
|
||||
hint.setGravity(Gravity.END)
|
||||
row.addView(hint)
|
||||
|
||||
n = count
|
||||
row.setOnClickListener(OnClickListener(lambda v, c=n: pick(c)))
|
||||
|
||||
lp = LinearLayout.LayoutParams(-1, -2)
|
||||
lp.bottomMargin = dp(8)
|
||||
root.addView(row, lp)
|
||||
|
||||
sheet = BottomSheet.Builder(act).setCustomView(root).create()
|
||||
sheet_holder[0] = sheet
|
||||
sheet.show()
|
||||
|
||||
except Exception as e:
|
||||
log("chat_summary: count picker error: " + str(e))
|
||||
|
||||
def fetch_and_summarize(self, act, dialog_id, count):
|
||||
try:
|
||||
from org.telegram.tgnet import TLRPC
|
||||
|
||||
req = TLRPC.TL_messages_getHistory()
|
||||
req.peer = get_messages_controller().getInputPeer(int(dialog_id))
|
||||
req.offset_id = 0
|
||||
req.offset_date = 0
|
||||
req.add_offset = 0
|
||||
req.limit = count
|
||||
req.max_id = 0
|
||||
req.min_id = 0
|
||||
req.hash = 0
|
||||
|
||||
self_ref = [self]
|
||||
act_ref = [act]
|
||||
|
||||
def on_response(response, error):
|
||||
if error:
|
||||
err = getattr(error, "text", "unknown")
|
||||
run_on_ui_thread(lambda: BulletinHelper.show_error("Ошибка загрузки: " + err))
|
||||
return
|
||||
try:
|
||||
msgs = getattr(response, "messages", None)
|
||||
if not msgs:
|
||||
run_on_ui_thread(lambda: BulletinHelper.show_error("Нет сообщений."))
|
||||
return
|
||||
|
||||
users = getattr(response, "users", None)
|
||||
chats = getattr(response, "chats", None)
|
||||
text, used = build_transcript(msgs, users, chats)
|
||||
|
||||
if not text.strip():
|
||||
run_on_ui_thread(lambda: BulletinHelper.show_error("Текстовых сообщений не найдено."))
|
||||
return
|
||||
|
||||
spinner = [None]
|
||||
|
||||
def start_spinner():
|
||||
spinner[0] = show_spinner(act_ref[0])
|
||||
|
||||
run_on_ui_thread(start_spinner)
|
||||
run_on_queue(
|
||||
lambda: self_ref[0].run_ai(act_ref[0], text, used, spinner),
|
||||
PLUGINS_QUEUE, 0,
|
||||
)
|
||||
except Exception as ex:
|
||||
log("chat_summary: parse error: " + str(ex))
|
||||
run_on_ui_thread(lambda: BulletinHelper.show_error("Ошибка парсинга сообщений."))
|
||||
|
||||
send_request(req, on_response)
|
||||
|
||||
except Exception as e:
|
||||
log("chat_summary: fetch error: " + str(e))
|
||||
run_on_ui_thread(lambda: BulletinHelper.show_error("Не удалось начать загрузку."))
|
||||
|
||||
def run_ai(self, act, text, used, spinner):
|
||||
try:
|
||||
provider = self.get_setting("provider", PROVIDER_OPENAI)
|
||||
api_key = self.get_setting("api_key", "") or ""
|
||||
model = self.get_setting("model", "") or ""
|
||||
style = self.get_setting("style", STYLE_BRIEF)
|
||||
lang = self.get_setting("lang", LANG_AUTO)
|
||||
custom_url = self.get_setting("custom_endpoint", "") or ""
|
||||
|
||||
system = build_system_prompt(style, lang)
|
||||
content = "Chat conversation to summarize:\n\n" + text
|
||||
|
||||
result = call_ai(provider, api_key, model, system, content, custom_url)
|
||||
|
||||
def on_done():
|
||||
dismiss_dialog(spinner[0])
|
||||
show_result(act, result, used, provider, model)
|
||||
|
||||
run_on_ui_thread(on_done)
|
||||
|
||||
except Exception as e:
|
||||
err = str(e)
|
||||
self.last_error = "Ошибка в run_ai: " + err # ОБНОВЛЕНО: запись лога
|
||||
log("chat_summary: ai error: " + err)
|
||||
|
||||
def on_error():
|
||||
dismiss_dialog(spinner[0])
|
||||
BulletinHelper.show_error("Ошибка AI: " + err[:120])
|
||||
|
||||
run_on_ui_thread(on_error)
|
||||
|
||||
def create_settings(self):
|
||||
provider = self.get_setting("provider", PROVIDER_OPENAI)
|
||||
style = self.get_setting("style", STYLE_BRIEF)
|
||||
api_key = self.get_setting("api_key", "") or ""
|
||||
default_model = DEFAULT_MODELS[provider] if 0 <= provider < len(DEFAULT_MODELS) else ""
|
||||
style_hint = STYLE_HINTS[style] if 0 <= style < len(STYLE_HINTS) else ""
|
||||
|
||||
key_valid, key_hint = check_key_format(provider, api_key)
|
||||
key_icon = "msg_invite" if key_valid else "msg_secret"
|
||||
key_subtext = ("✓ " if key_valid else "✗ ") + key_hint
|
||||
|
||||
items = [
|
||||
Header(text="AI провайдер"),
|
||||
Selector(
|
||||
key="provider",
|
||||
text="Провайдер",
|
||||
default=PROVIDER_OPENAI,
|
||||
items=PROVIDERS,
|
||||
icon="msg_robot",
|
||||
on_change=lambda idx: self.set_setting("provider", idx, reload_settings=True),
|
||||
),
|
||||
Input(
|
||||
key="api_key",
|
||||
text="API ключ",
|
||||
subtext=key_subtext,
|
||||
icon=key_icon,
|
||||
default="",
|
||||
on_change=lambda val: self.set_setting("api_key", val, reload_settings=True),
|
||||
),
|
||||
Input(
|
||||
key="model",
|
||||
text="Модель",
|
||||
subtext="По умолчанию: " + default_model if default_model else "Укажи название модели",
|
||||
icon="msg_topic",
|
||||
default="",
|
||||
),
|
||||
]
|
||||
|
||||
if provider == PROVIDER_CUSTOM:
|
||||
items.append(Input(
|
||||
key="custom_endpoint",
|
||||
text="URL endpoint",
|
||||
subtext="OpenAI-совместимый адрес",
|
||||
icon="msg_link",
|
||||
default="",
|
||||
))
|
||||
|
||||
items.append(Text(
|
||||
text="Проверить подключение",
|
||||
subtext="Отправит тестовый запрос к API",
|
||||
icon="msg_pin_code",
|
||||
on_click=lambda v: self.run_connection_test(),
|
||||
))
|
||||
|
||||
# ОБНОВЛЕНО: Добавлена кнопка сохранения отладочного лога
|
||||
def action_copy_debug():
|
||||
provider_str = PROVIDERS[provider] if 0 <= provider < len(PROVIDERS) else str(provider)
|
||||
key_valid, key_hint = check_key_format(provider, api_key)
|
||||
|
||||
debug_text = (
|
||||
"=== CHAT SUMMARY DEBUG LOG ===\n"
|
||||
"Plugin Version: " + str(__version__) + "\n"
|
||||
"SDK Version: " + str(__sdk_version__) + "\n"
|
||||
"Provider: " + str(provider_str) + "\n"
|
||||
"Model: " + str(self.get_setting("model", "")) + "\n"
|
||||
"Has Custom Endpoint: " + str(bool(self.get_setting("custom_endpoint", ""))) + "\n"
|
||||
"API Key Valid Format: " + str(key_valid) + " (" + str(key_hint) + ")\n"
|
||||
"Style: " + str(self.get_setting("style", 0)) + "\n"
|
||||
"Lang: " + str(self.get_setting("lang", 0)) + "\n"
|
||||
"Last Recorded Error: " + str(getattr(self, "last_error", "None")) + "\n"
|
||||
"=============================="
|
||||
)
|
||||
copy_to_clipboard(debug_text)
|
||||
BulletinHelper.show_success("Дебаг-лог скопирован в буфер!")
|
||||
|
||||
items.append(Text(
|
||||
text="Скопировать дебаг-лог",
|
||||
subtext="Скопирует статус плагина и последнюю ошибку для отправки разработчику",
|
||||
icon="msg_edit",
|
||||
on_click=lambda v: action_copy_debug(),
|
||||
))
|
||||
|
||||
items += [
|
||||
Divider(),
|
||||
Header(text="Саммари"),
|
||||
Selector(
|
||||
key="style",
|
||||
text="Стиль",
|
||||
default=STYLE_BRIEF,
|
||||
items=STYLES,
|
||||
icon="msg_list",
|
||||
on_change=lambda idx: self.set_setting("style", idx, reload_settings=True),
|
||||
),
|
||||
Text(
|
||||
text=style_hint,
|
||||
icon="msg_info",
|
||||
),
|
||||
Selector(
|
||||
key="lang",
|
||||
text="Язык ответа",
|
||||
default=LANG_AUTO,
|
||||
items=LANGS,
|
||||
icon="msg_translate",
|
||||
),
|
||||
]
|
||||
|
||||
return items
|
||||
|
||||
def run_connection_test(self):
|
||||
provider = self.get_setting("provider", PROVIDER_OPENAI)
|
||||
api_key = self.get_setting("api_key", "") or ""
|
||||
model = self.get_setting("model", "") or ""
|
||||
custom_url = self.get_setting("custom_endpoint", "") or ""
|
||||
|
||||
if provider != PROVIDER_OLLAMA and not api_key.strip():
|
||||
BulletinHelper.show_error("Сначала укажи API ключ")
|
||||
return
|
||||
|
||||
BulletinHelper.show_info("Проверяю подключение…")
|
||||
|
||||
def worker():
|
||||
ok, message = test_connection(provider, api_key, model, custom_url)
|
||||
# ОБНОВЛЕНО: Если тест провалился, записываем сообщение в лог
|
||||
if not ok:
|
||||
self.last_error = "Ошибка теста связи: " + message
|
||||
|
||||
def show():
|
||||
if ok:
|
||||
BulletinHelper.show_success(message)
|
||||
else:
|
||||
BulletinHelper.show_error(message)
|
||||
|
||||
run_on_ui_thread(show)
|
||||
|
||||
run_on_queue(worker, PLINS_QUEUE if 'PLINS_QUEUE' in globals() else PLUGINS_QUEUE, 0)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user