625 lines
26 KiB
Python
625 lines
26 KiB
Python
import re
|
|
import html
|
|
import json
|
|
from pathlib import Path
|
|
from typing import List, Optional, Dict, Any
|
|
|
|
def escape_html(text: str) -> str:
|
|
"""Safely escapes text for Telegram HTML parse mode."""
|
|
if not text:
|
|
return ""
|
|
return html.escape(text, quote=False)
|
|
|
|
def format_token_count(n: int) -> str:
|
|
"""Formats an integer token count to a human-readable string (e.g. 360K, 1.2M)."""
|
|
if n >= 1_000_000:
|
|
val = n / 1_000_000
|
|
if val.is_integer() or f"{val:.1f}".endswith(".0"):
|
|
return f"{int(val)}M"
|
|
return f"{val:.1f}M"
|
|
elif n >= 1_000:
|
|
return f"{round(n / 1_000)}K"
|
|
return str(n)
|
|
|
|
|
|
def format_duration_compact(seconds: float) -> str:
|
|
"""Formats duration in seconds to M:SS or H:MM:SS format (e.g. 9:47, 1:02:15)."""
|
|
sec = int(round(seconds))
|
|
if sec >= 3600:
|
|
h, rem = divmod(sec, 3600)
|
|
m, s = divmod(rem, 60)
|
|
return f"{h}:{m:02d}:{s:02d}"
|
|
m, s = divmod(sec, 60)
|
|
return f"{m}:{s:02d}"
|
|
|
|
|
|
def get_model_display_name(model: Optional[str] = None, effort: Optional[str] = None) -> str:
|
|
"""Returns a clean user-friendly display name for the model and reasoning effort."""
|
|
if not model:
|
|
model = "gemini-3.7-flash-auto"
|
|
|
|
m_clean = model.lower().strip()
|
|
eff_clean = (effort or "").lower().strip()
|
|
if not eff_clean:
|
|
if "flash-high" in m_clean:
|
|
eff_clean = "high"
|
|
elif "flash-auto" in m_clean or "3.7-flash" in m_clean or "3.6-flash" in m_clean or "3.1-pro" in m_clean:
|
|
eff_clean = "medium"
|
|
|
|
eff_map = {"high": "High", "medium": "Med", "med": "Med", "low": "Low"}
|
|
eff_tag = f" [{eff_map.get(eff_clean, eff_clean.capitalize())}]" if eff_clean else ""
|
|
|
|
if "flash-auto" in m_clean or m_clean == "gemini-3.7-flash-auto":
|
|
return f"Gemini 3.7 Flash Auto{eff_tag}"
|
|
elif "flash-high" in m_clean:
|
|
return "Gemini 3.7 Flash [High]"
|
|
elif "3.7-flash" in m_clean:
|
|
return f"Gemini 3.7 Flash{eff_tag}"
|
|
elif "3.6-flash" in m_clean:
|
|
return f"Gemini 3.6 Flash{eff_tag}"
|
|
elif "3.1-pro" in m_clean or "pro" in m_clean:
|
|
return f"Gemini 3.1 Pro{eff_tag}"
|
|
elif "opus" in m_clean:
|
|
return f"Claude Opus 4.6 Thinking{eff_tag}"
|
|
elif "sonnet" in m_clean or "claude" in m_clean:
|
|
return f"Claude Sonnet 4.6 Thinking{eff_tag}"
|
|
elif "gpt" in m_clean:
|
|
return "GPT-120B"
|
|
else:
|
|
return f"{model}{eff_tag}"
|
|
|
|
|
|
def format_context_stats(
|
|
usage: Optional[Dict[str, Any]] = None,
|
|
duration: float = 0.0,
|
|
model: str = "",
|
|
effort: Optional[str] = None,
|
|
lang: str = "fa",
|
|
project_name: Optional[str] = None,
|
|
conversation_id: Optional[str] = None,
|
|
) -> str:
|
|
"""Formats token usage, project name, executed model/effort, and context length into an elegant 3-line footnote."""
|
|
p_name = project_name or "default"
|
|
is_fa = (lang or "").lower() in ("fa", "farsi", "persian", "🇮🇷 persian / farsi (فارسی)")
|
|
|
|
m_label = "Gemini 3.7 Flash"
|
|
if model:
|
|
m_clean = model.lower().strip()
|
|
eff_clean = (effort or "").lower().strip()
|
|
if "flash-auto" in m_clean or m_clean == "gemini-3.7-flash-auto":
|
|
if eff_clean == "high":
|
|
m_label = "3.7-Auto [High]"
|
|
elif eff_clean == "medium":
|
|
m_label = "3.7-Auto [Med]"
|
|
else:
|
|
m_label = "3.7-Auto [Low]"
|
|
elif "flash-high" in m_clean:
|
|
m_label = "3.7-Flash [High]"
|
|
elif "3.7-flash" in m_clean:
|
|
m_label = f"3.7-Flash [{eff_clean.capitalize()}]" if eff_clean else "3.7-Flash"
|
|
elif "3.6-flash" in m_clean:
|
|
m_label = f"3.6-Flash [{eff_clean.capitalize()}]" if eff_clean else "3.6-Flash"
|
|
elif "3.1-pro" in m_clean:
|
|
m_label = f"3.1-Pro [{eff_clean.capitalize()}]" if eff_clean else "3.1-Pro"
|
|
elif "opus" in m_clean:
|
|
m_label = "Opus 4.6"
|
|
elif "sonnet" in m_clean or "claude" in m_clean:
|
|
m_label = "Sonnet 4.6"
|
|
elif "gpt" in m_clean:
|
|
m_label = "GPT-120B"
|
|
else:
|
|
m_label = model
|
|
|
|
input_tokens = usage.get("input_tokens", 0) if usage else 0
|
|
output_tokens = usage.get("output_tokens", 0) if usage else 0
|
|
total_tokens = usage.get("total_tokens", input_tokens + output_tokens) if usage else 0
|
|
|
|
# Estimate model context window capacity
|
|
model_lower = (model or "").lower()
|
|
if "3.1-pro" in model_lower or "pro" in model_lower:
|
|
max_context = 2_000_000
|
|
elif "claude" in model_lower:
|
|
max_context = 200_000
|
|
elif "gpt" in model_lower:
|
|
max_context = 128_000
|
|
else:
|
|
max_context = 1_000_000
|
|
|
|
# Calculate actual active conversation tokens if conversation_id is provided
|
|
active_tokens = input_tokens
|
|
if conversation_id:
|
|
try:
|
|
from agy_engine import get_conversation_context_tokens
|
|
real_tok = get_conversation_context_tokens(conversation_id)
|
|
if real_tok > 0:
|
|
active_tokens = real_tok
|
|
except Exception:
|
|
pass
|
|
|
|
pct = min(100.0, max(0.0, (active_tokens / max_context) * 100)) if max_context else 0
|
|
pct_str = f"{int(round(pct))}%"
|
|
display_input = active_tokens if (active_tokens > 0) else input_tokens
|
|
ti_str = format_token_count(display_input) if display_input > 0 else "0"
|
|
to_str = format_token_count(output_tokens) if output_tokens > 0 else "0"
|
|
|
|
dur_str = format_duration_compact(duration) if duration > 0 else "<1s"
|
|
|
|
line1 = f"<code>{escape_html(p_name)} {escape_html(m_label)}</code>"
|
|
line2 = f"<code>CL:{pct_str} Ti:{ti_str} To:{to_str} D:{dur_str}</code>"
|
|
|
|
return f"{line1}\n{line2}"
|
|
|
|
def format_thought(
|
|
thought_text: str,
|
|
project_name: Optional[str] = None,
|
|
model_name: Optional[str] = None,
|
|
lang: str = "fa",
|
|
) -> str:
|
|
"""Formats model reasoning / thinking process with model context."""
|
|
if not thought_text.strip():
|
|
return ""
|
|
escaped = escape_html(thought_text.strip())
|
|
is_fa = (lang or "").lower() in ("fa", "farsi", "persian", "🇮🇷 persian / farsi (فارسی)")
|
|
|
|
label = model_name or project_name
|
|
if label:
|
|
badge = escape_html(label)
|
|
if model_name:
|
|
header = f"💭 <b>تفکر و استدلال (مدل: {badge}):</b>" if is_fa else f"💭 <b>Reasoning / Thinking (Model: {badge}):</b>"
|
|
else:
|
|
header = f"💭 <b>تفکر و استدلال ({badge}):</b>" if is_fa else f"💭 <b>Reasoning / Thinking ({badge}):</b>"
|
|
else:
|
|
header = "💭 <b>تفکر و استدلال:</b>" if is_fa else "💭 <b>Reasoning / Thinking:</b>"
|
|
return f"<blockquote expandable>{header}\n{escaped}</blockquote>\n"
|
|
|
|
TOOL_EMOJI_MAP: Dict[str, str] = {
|
|
"run_command": "💻",
|
|
"manage_task": "⚙️",
|
|
"view_file": "👁️",
|
|
"view_image": "👁️",
|
|
"replace_file_content": "✏️",
|
|
"write_to_file": "📝",
|
|
"list_dir": "📂",
|
|
"grep_search": "🔍",
|
|
"find_by_name": "📁",
|
|
"search_web": "🌐",
|
|
"read_url_content": "🔗",
|
|
"generate_image": "🎨",
|
|
"schedule": "⏰",
|
|
"invoke_subagent": "🤖",
|
|
"define_subagent": "🧠",
|
|
"manage_subagents": "👥",
|
|
"send_message": "💬",
|
|
"ask_question": "❓",
|
|
}
|
|
|
|
def format_tool_call(tool_name: str, tool_args: Any, lang: str = "fa") -> str:
|
|
"""Formats an active tool execution notification into a clean, concise badge."""
|
|
is_fa = (lang or "").lower() in ("fa", "farsi", "persian", "🇮🇷 persian / farsi (فارسی)")
|
|
label = "ابزار" if is_fa else "Tool"
|
|
tool_emoji = TOOL_EMOJI_MAP.get(tool_name, "🔧")
|
|
|
|
# Parse tool arguments if passed as JSON string or dict
|
|
params = {}
|
|
if isinstance(tool_args, dict):
|
|
params = tool_args
|
|
elif isinstance(tool_args, str):
|
|
try:
|
|
params = json.loads(tool_args)
|
|
except Exception:
|
|
params = {}
|
|
|
|
# Extract friendly description/action
|
|
action = ""
|
|
target = ""
|
|
if isinstance(params, dict) and params:
|
|
action = params.get("toolAction") or params.get("toolSummary") or params.get("Description") or ""
|
|
|
|
# Tool specific highlights
|
|
if tool_name == "run_command":
|
|
cmd = params.get("CommandLine", "")
|
|
target = f"💻 <code>{escape_html(cmd[:120])}</code>" if cmd else ""
|
|
elif tool_name == "write_to_file":
|
|
file_p = params.get("TargetFile", "")
|
|
instr = params.get("Instruction", "")
|
|
if file_p:
|
|
target = f"📝 <code>{escape_html(Path(file_p).name if '/' in file_p else file_p)}</code>"
|
|
if instr and not action:
|
|
action = instr[:100]
|
|
elif tool_name == "replace_file_content":
|
|
file_p = params.get("TargetFile", "")
|
|
instr = params.get("Instruction", "")
|
|
if file_p:
|
|
target = f"✏️ <code>{escape_html(Path(file_p).name if '/' in file_p else file_p)}</code>"
|
|
if instr and not action:
|
|
action = instr[:100]
|
|
elif tool_name in ("view_file", "view_image"):
|
|
file_p = params.get("AbsolutePath", "")
|
|
if file_p:
|
|
target = f"👁️ <code>{escape_html(Path(file_p).name if '/' in file_p else file_p)}</code>"
|
|
elif tool_name == "list_dir":
|
|
dir_p = params.get("DirectoryPath", "")
|
|
if dir_p:
|
|
target = f"📂 <code>{escape_html(dir_p[:80])}</code>"
|
|
elif tool_name == "grep_search":
|
|
q = params.get("Query", "")
|
|
if q:
|
|
target = f"🔍 <code>{escape_html(q[:80])}</code>"
|
|
elif tool_name == "find_by_name":
|
|
pat = params.get("Pattern", "")
|
|
if pat:
|
|
target = f"📁 <code>{escape_html(pat[:80])}</code>"
|
|
elif tool_name == "search_web":
|
|
q = params.get("query", "")
|
|
if q:
|
|
target = f"🌐 <code>{escape_html(q[:80])}</code>"
|
|
elif tool_name == "read_url_content":
|
|
u = params.get("Url", "")
|
|
if u:
|
|
target = f"🔗 <code>{escape_html(u[:80])}</code>"
|
|
elif tool_name == "generate_image":
|
|
p = params.get("Prompt", "")
|
|
if p:
|
|
target = f"🎨 <code>{escape_html(p[:80])}</code>"
|
|
elif tool_name == "schedule":
|
|
timing = params.get("CronExpression") or (f"{params.get('DurationSeconds')}s" if params.get("DurationSeconds") else "") or params.get("Prompt", "")
|
|
if timing:
|
|
target = f"⏰ <code>{escape_html(str(timing)[:80])}</code>"
|
|
elif tool_name == "manage_task":
|
|
act = params.get("Action", "")
|
|
tid = params.get("TaskId", "")
|
|
target = f"⚙️ <code>{escape_html(act)} {escape_html(tid)}</code>"
|
|
elif tool_name == "invoke_subagent":
|
|
subagents = params.get("Subagents", [])
|
|
roles = [s.get("Role", s.get("TypeName", "Agent")) for s in subagents if isinstance(s, dict)]
|
|
if roles:
|
|
target = f"🤖 <code>{escape_html(', '.join(roles)[:80])}</code>"
|
|
elif tool_name == "define_subagent":
|
|
name = params.get("name", "")
|
|
if name:
|
|
target = f"🧠 <code>{escape_html(name[:80])}</code>"
|
|
elif tool_name == "manage_subagents":
|
|
act = params.get("Action", "")
|
|
target = f"👥 <code>{escape_html(act)}</code>" if act else ""
|
|
elif tool_name == "send_message":
|
|
msg = params.get("Message", "")
|
|
target = f"💬 <code>{escape_html(msg[:80])}</code>" if msg else ""
|
|
elif tool_name == "ask_question":
|
|
target = "❓ <code>درخواست تایید / سوال</code>"
|
|
|
|
if not action and not target and isinstance(tool_args, str) and tool_args.strip():
|
|
# Fallback short string preview
|
|
short_args = tool_args.strip().replace("\n", " ")
|
|
if len(short_args) > 100:
|
|
short_args = short_args[:100] + "..."
|
|
target = f"<code>{escape_html(short_args)}</code>"
|
|
|
|
action_text = f" ({escape_html(action)})" if action else ""
|
|
out = f"{tool_emoji} <b>{label}:</b> <code>{escape_html(tool_name)}</code>{action_text}"
|
|
if target:
|
|
out += f"\n ↳ {target}"
|
|
return out + "\n"
|
|
|
|
def markdown_to_telegram_html(text: str) -> str:
|
|
"""
|
|
Converts standard GitHub-flavored Markdown text to valid Telegram HTML.
|
|
Preserves fenced code blocks with syntax highlighting, inline code,
|
|
headings, blockquotes, bold, italics, strikethrough, links, and bullet lists.
|
|
"""
|
|
if not text:
|
|
return ""
|
|
|
|
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
|
|
# If stream ends with unclosed code block, temporarily close it for formatting
|
|
fences = re.findall(r"^```", text, flags=re.MULTILINE)
|
|
if len(fences) % 2 != 0:
|
|
text += "\n```"
|
|
|
|
# 1. Protect fenced code blocks: ```lang ... ```
|
|
code_blocks = []
|
|
def code_block_sub(match):
|
|
lang = (match.group(1) or "").strip()
|
|
code = match.group(2)
|
|
idx = len(code_blocks)
|
|
code_blocks.append((lang, code))
|
|
return f"\x00CB{idx}\x00"
|
|
|
|
code_block_pattern = re.compile(r"```([a-zA-Z0-9_-]*)\n?(.*?)```", re.DOTALL)
|
|
processed = code_block_pattern.sub(code_block_sub, text)
|
|
|
|
# 2. Protect inline code: `code`
|
|
inline_codes = []
|
|
def inline_code_sub(match):
|
|
code = match.group(1)
|
|
idx = len(inline_codes)
|
|
inline_codes.append(code)
|
|
return f"\x00IC{idx}\x00"
|
|
|
|
processed = re.sub(r"`([^`\n]+)`", inline_code_sub, processed)
|
|
|
|
# 3. Escape general HTML characters
|
|
processed = escape_html(processed)
|
|
|
|
# 4. Markdown Headings: # Heading, ## Heading, ### Heading, etc.
|
|
processed = re.sub(r"^(?:#{1,6})\s+(.+)$", r"<b>\1</b>", processed, flags=re.MULTILINE)
|
|
|
|
# 5. Horizontal rules: ---, ***, ___ on a line by itself
|
|
processed = re.sub(r"^[ \t]*(?:-{3,}|\*{3,}|_{3,})[ \t]*$", "──────────────", processed, flags=re.MULTILINE)
|
|
|
|
# 6. Blockquotes: lines starting with > (since > was escaped)
|
|
processed = re.sub(r"^>\s*(.+)$", r"<blockquote>\1</blockquote>", processed, flags=re.MULTILINE)
|
|
processed = re.sub(r"</blockquote>\n<blockquote>", "\n", processed)
|
|
|
|
# 7. Unordered lists: * item or - item
|
|
processed = re.sub(r"^([ \t]*)[*\-]\s+(.+)$", r"\1• \2", processed, flags=re.MULTILINE)
|
|
|
|
# 8. Bold: **text** or __text__
|
|
processed = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", processed)
|
|
processed = re.sub(r"__(.+?)__", r"<b>\1</b>", processed)
|
|
|
|
# 9. Italic: *text* or _text_ (ensure not touching word internals or placeholders)
|
|
processed = re.sub(r"(?<!\w)\*([^\*\n]+)\*(?!\w)", r"<i>\1</i>", processed)
|
|
processed = re.sub(r"(?<!\w)_([^_\n]+)_(?!\w)", r"<i>\1</i>", processed)
|
|
|
|
# 10. Strikethrough: ~~text~~
|
|
processed = re.sub(r"~~(.+?)~~", r"<s>\1</s>", processed)
|
|
|
|
# 11. Links: [text](url) - Only http/https/tg/mailto supported in Telegram HTML
|
|
def link_sub(match):
|
|
label = match.group(1)
|
|
url = match.group(2)
|
|
if re.match(r"^(https?://|tg://|mailto:)", url, re.IGNORECASE):
|
|
return f'<a href="{url}">{label}</a>'
|
|
else:
|
|
return f"<code>{label}</code>"
|
|
|
|
processed = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", link_sub, processed)
|
|
|
|
# 12. Restore inline code
|
|
for idx, code in enumerate(inline_codes):
|
|
escaped_code = escape_html(code)
|
|
processed = processed.replace(f"\x00IC{idx}\x00", f"<code>{escaped_code}</code>")
|
|
|
|
# 13. Restore code blocks
|
|
for idx, (lang, code) in enumerate(code_blocks):
|
|
escaped_code = escape_html(code.strip("\n"))
|
|
lang_attr = f' class="language-{escape_html(lang)}"' if lang else ""
|
|
replacement = f"<pre><code{lang_attr}>{escaped_code}</code></pre>"
|
|
processed = processed.replace(f"\x00CB{idx}\x00", replacement)
|
|
|
|
return processed
|
|
|
|
def balance_tags(chunk: str, open_tags: List[tuple]) -> List[tuple]:
|
|
"""Finds unclosed HTML tags in chunk."""
|
|
tag_pattern = re.compile(r"<\s*(/)?\s*([a-zA-Z0-9]+)(?:\s+[^>]*?)?>")
|
|
active_tags = list(open_tags)
|
|
for match in tag_pattern.finditer(chunk):
|
|
is_closing = bool(match.group(1))
|
|
tag_name = match.group(2).lower()
|
|
|
|
if tag_name in ("br", "hr", "img"):
|
|
continue
|
|
|
|
if is_closing:
|
|
for i in range(len(active_tags) - 1, -1, -1):
|
|
if active_tags[i][0] == tag_name:
|
|
active_tags.pop(i)
|
|
break
|
|
else:
|
|
active_tags.append((tag_name, match.group(0)))
|
|
return active_tags
|
|
|
|
def split_message(text: str, max_length: int = 4000) -> List[str]:
|
|
"""
|
|
Splits long messages into Telegram-compliant chunks (<4096 chars).
|
|
Ensures that HTML tags remain balanced across split boundaries.
|
|
"""
|
|
if len(text) <= max_length:
|
|
return [text]
|
|
|
|
chunks = []
|
|
current_pos = 0
|
|
total_len = len(text)
|
|
active_tags = []
|
|
|
|
while current_pos < total_len:
|
|
prefix = "".join(full_tag for tag_name, full_tag in active_tags)
|
|
effective_max = max_length - len(prefix) - 60
|
|
if effective_max < 100:
|
|
effective_max = max_length // 2
|
|
|
|
if total_len - current_pos <= effective_max:
|
|
chunk = prefix + text[current_pos:]
|
|
chunks.append(chunk)
|
|
break
|
|
|
|
split_candidate = current_pos + effective_max
|
|
|
|
# Find best split point (newline > space > hard cut)
|
|
newline_pos = text.rfind("\n", current_pos, split_candidate)
|
|
if newline_pos != -1 and newline_pos > current_pos + (effective_max // 3):
|
|
split_at = newline_pos + 1
|
|
else:
|
|
space_pos = text.rfind(" ", current_pos, split_candidate)
|
|
if space_pos != -1 and space_pos > current_pos + (effective_max // 3):
|
|
split_at = space_pos + 1
|
|
else:
|
|
split_at = split_candidate
|
|
|
|
raw_chunk = text[current_pos:split_at]
|
|
chunk_active = balance_tags(raw_chunk, active_tags)
|
|
|
|
suffix = "".join(f"</{tag_name}>" for tag_name, _ in reversed(chunk_active))
|
|
|
|
final_chunk = prefix + raw_chunk + suffix
|
|
chunks.append(final_chunk)
|
|
|
|
active_tags = chunk_active
|
|
current_pos = split_at
|
|
|
|
return chunks
|
|
|
|
|
|
def format_git_info(
|
|
project_name: str,
|
|
web_url: str,
|
|
clone_url: str,
|
|
branch: str = "main",
|
|
last_commit: Optional[Dict[str, Any]] = None,
|
|
dirty: bool = False,
|
|
files_count: int = 0,
|
|
lang: str = "fa",
|
|
) -> str:
|
|
"""Formats Gitea / Git repository status in Telegram HTML."""
|
|
is_fa = (lang or "").lower() in ("fa", "farsi", "persian", "🇮🇷 persian / farsi (فارسی)")
|
|
clean_p = escape_html(project_name)
|
|
|
|
commit_str = "—"
|
|
if last_commit:
|
|
c_hash = escape_html(last_commit.get("hash", ""))
|
|
c_msg = escape_html(last_commit.get("message", ""))
|
|
c_time = escape_html(last_commit.get("time", ""))
|
|
commit_str = f"<code>{c_hash}</code> - {c_msg} (<i>{c_time}</i>)"
|
|
|
|
dirty_str = f"⚠️ {files_count} فایل تغییریافته (منتظر کامیت)" if dirty else "✅ درخت کاری تمیز است (همگام)"
|
|
dirty_str_en = f"⚠️ {files_count} modified files (uncommitted)" if dirty else "✅ Working tree clean (in sync)"
|
|
|
|
if is_fa:
|
|
return (
|
|
f"🐙 <b>مدیریت مخزن گیت (Gitea Version Control)</b>\n\n"
|
|
f"• 📁 <b>پروژه:</b> <code>{clean_p}</code>\n"
|
|
f"• 🌐 <b>مخزن تحت وب (Gitea):</b>\n<a href=\"{web_url}\">{web_url}</a>\n"
|
|
f"• 📥 <b>دستور کلون در سیستم شخصی:</b>\n<code>git clone {clone_url}</code>\n\n"
|
|
f"• 🌿 <b>شاخه فعال:</b> <code>{escape_html(branch)}</code>\n"
|
|
f"• 🔖 <b>آخرین کامیت:</b> {commit_str}\n"
|
|
f"• 📊 <b>وضعیت تغییرات محلی:</b> {dirty_str}\n\n"
|
|
f"💡 <i>تمامی تغییرات اعمالشده توسط هوش مصنوعی به صورت خودکار کامیت و در مخزن Gitea پوش میشوند.</i>"
|
|
)
|
|
else:
|
|
return (
|
|
f"🐙 <b>Git Repository Manager (Gitea)</b>\n\n"
|
|
f"• 📁 <b>Project:</b> <code>{clean_p}</code>\n"
|
|
f"• 🌐 <b>Web Repository (Gitea):</b>\n<a href=\"{web_url}\">{web_url}</a>\n"
|
|
f"• 📥 <b>Clone Command:</b>\n<code>git clone {clone_url}</code>\n\n"
|
|
f"• 🌿 <b>Active Branch:</b> <code>{escape_html(branch)}</code>\n"
|
|
f"• 🔖 <b>Last Commit:</b> {commit_str}\n"
|
|
f"• 📊 <b>Local Status:</b> {dirty_str_en}\n\n"
|
|
f"💡 <i>All changes made by AI are automatically committed and pushed to your Gitea repo.</i>"
|
|
)
|
|
|
|
|
|
def format_git_commits_page(
|
|
project_name: str,
|
|
commits: List[Dict[str, Any]],
|
|
current_page: int = 1,
|
|
total_pages: int = 1,
|
|
branch: str = "main",
|
|
lang: str = "fa",
|
|
) -> str:
|
|
"""Formats a page of commit history list."""
|
|
is_fa = (lang or "").lower() in ("fa", "farsi", "persian", "🇮🇷 persian / farsi (فارسی)")
|
|
clean_p = escape_html(project_name)
|
|
|
|
if not commits:
|
|
if is_fa:
|
|
return f"📜 <b>تاریخچه کامیتهای پروژه <code>{clean_p}</code></b>\n\n⚠️ هیچ کامیتی در این مخزن یافت نشد."
|
|
else:
|
|
return f"📜 <b>Commit History for <code>{clean_p}</code></b>\n\n⚠️ No commits found in this repository."
|
|
|
|
if is_fa:
|
|
lines = [
|
|
f"📜 <b>تاریخچه ۲۰ کامیت اخیر (پروژه: <code>{clean_p}</code>)</b>",
|
|
f"🌿 <b>شاخه/موقعیت فعلی:</b> <code>{escape_html(branch)}</code>",
|
|
f"📄 <b>صفحه:</b> {current_page} از {total_pages}\n",
|
|
]
|
|
for c in commits:
|
|
short_h = escape_html(c.get("short_hash", ""))
|
|
author = escape_html(c.get("author", ""))
|
|
time_rel = escape_html(c.get("relative_time", ""))
|
|
subject = escape_html(c.get("subject", ""))
|
|
is_curr = c.get("is_current", False)
|
|
|
|
badge = "📍 [فعال] " if is_curr else ""
|
|
lines.append(f"{badge}• <code>{short_h}</code> - <b>{subject}</b>\n 👤 <i>{author}</i> ({time_rel})")
|
|
|
|
lines.append("\n💡 <i>جهت مشاهده جزئیات، جابهجایی یا بازگشت به هر کامیت، دکمه مربوطه را انتخاب نمایید:</i>")
|
|
return "\n".join(lines)
|
|
else:
|
|
lines = [
|
|
f"📜 <b>Recent Commits (Project: <code>{clean_p}</code>)</b>",
|
|
f"🌿 <b>Active Branch/Ref:</b> <code>{escape_html(branch)}</code>",
|
|
f"📄 <b>Page:</b> {current_page} of {total_pages}\n",
|
|
]
|
|
for c in commits:
|
|
short_h = escape_html(c.get("short_hash", ""))
|
|
author = escape_html(c.get("author", ""))
|
|
time_rel = escape_html(c.get("relative_time", ""))
|
|
subject = escape_html(c.get("subject", ""))
|
|
is_curr = c.get("is_current", False)
|
|
|
|
badge = "📍 [HEAD] " if is_curr else ""
|
|
lines.append(f"{badge}• <code>{short_h}</code> - <b>{subject}</b>\n 👤 <i>{author}</i> ({time_rel})")
|
|
|
|
lines.append("\n💡 <i>Select a commit button below to inspect, checkout, or reset:</i>")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def format_commit_detail_view(
|
|
project_name: str,
|
|
commit_detail: Dict[str, Any],
|
|
web_url: str = "",
|
|
lang: str = "fa",
|
|
) -> str:
|
|
"""Formats full details of a specific commit."""
|
|
is_fa = (lang or "").lower() in ("fa", "farsi", "persian", "🇮🇷 persian / farsi (فارسی)")
|
|
clean_p = escape_html(project_name)
|
|
|
|
full_h = escape_html(commit_detail.get("hash", ""))
|
|
short_h = escape_html(commit_detail.get("short_hash", ""))
|
|
author = escape_html(commit_detail.get("author", ""))
|
|
email = escape_html(commit_detail.get("email", ""))
|
|
time_rel = escape_html(commit_detail.get("relative_time", ""))
|
|
date_str = escape_html(commit_detail.get("date", ""))
|
|
subject = escape_html(commit_detail.get("subject", ""))
|
|
stat = escape_html(commit_detail.get("stat", ""))
|
|
is_curr = commit_detail.get("is_current", False)
|
|
|
|
commit_link = f"{web_url}/commit/{full_h}" if web_url else ""
|
|
link_html = f"\n• 🌐 <b>مشاهده در وب:</b> <a href=\"{commit_link}\">{commit_link}</a>" if commit_link else ""
|
|
|
|
stat_block = f"\n\n📊 <b>فایلهای تغییریافته:</b>\n<pre>{stat}</pre>" if stat else ""
|
|
stat_block_en = f"\n\n📊 <b>Changed Files:</b>\n<pre>{stat}</pre>" if stat else ""
|
|
|
|
status_tag = "📍 <b>این کامیت هماکنون کامیت فعال (HEAD) پروژه است.</b>\n" if is_curr else ""
|
|
status_tag_en = "📍 <b>This commit is currently the active HEAD of the project.</b>\n" if is_curr else ""
|
|
|
|
if is_fa:
|
|
return (
|
|
f"🔖 <b>مشخصات و جزئیات کامیت گیت</b>\n\n"
|
|
f"• 📁 <b>پروژه:</b> <code>{clean_p}</code>\n"
|
|
f"• 🔑 <b>شناسه کامیت:</b> <code>{full_h}</code>\n"
|
|
f"• 👤 <b>نویسنده:</b> {author} <{email}>\n"
|
|
f"• 📅 <b>زمان:</b> {date_str} (<i>{time_rel}</i>)\n"
|
|
f"• 📝 <b>پیام کامیت:</b>\n<blockquote><b>{subject}</b></blockquote>"
|
|
f"{link_html}"
|
|
f"{stat_block}\n\n"
|
|
f"{status_tag}"
|
|
f"⚠️ <i>توجه: با بازگردانی (Reset)، وضعیت پروژه به این کامیت بازمیگردد. با سوییچ (Checkout) میتوانید بدون تغییر شاخه اصلی، وضعیت این نسخه را مشاهده یا تست کنید.</i>"
|
|
)
|
|
else:
|
|
return (
|
|
f"🔖 <b>Git Commit Details</b>\n\n"
|
|
f"• 📁 <b>Project:</b> <code>{clean_p}</code>\n"
|
|
f"• 🔑 <b>Hash:</b> <code>{full_h}</code>\n"
|
|
f"• 👤 <b>Author:</b> {author} <{email}>\n"
|
|
f"• 📅 <b>Date:</b> {date_str} (<i>{time_rel}</i>)\n"
|
|
f"• 📝 <b>Subject:</b>\n<blockquote><b>{subject}</b></blockquote>"
|
|
f"{link_html}"
|
|
f"{stat_block_en}\n\n"
|
|
f"{status_tag_en}"
|
|
f"⚠️ <i>Note: Resetting will restore project files to this state. Checkout lets you inspect this snapshot without affecting branch history.</i>"
|
|
)
|
|
|
|
|