vibego 0.2.38__py3-none-any.whl → 0.2.40__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of vibego might be problematic. Click here for more details.
- bot.py +45 -33
- {vibego-0.2.38.dist-info → vibego-0.2.40.dist-info}/METADATA +1 -1
- {vibego-0.2.38.dist-info → vibego-0.2.40.dist-info}/RECORD +7 -8
- {vibego-0.2.38.dist-info → vibego-0.2.40.dist-info}/top_level.txt +0 -1
- vibego_cli/__init__.py +1 -1
- telegram_markdown/__init__.py +0 -269
- {vibego-0.2.38.dist-info → vibego-0.2.40.dist-info}/WHEEL +0 -0
- {vibego-0.2.38.dist-info → vibego-0.2.40.dist-info}/entry_points.txt +0 -0
bot.py
CHANGED
|
@@ -69,8 +69,6 @@ from tasks.fsm import (
|
|
|
69
69
|
TaskNoteStates,
|
|
70
70
|
TaskPushStates,
|
|
71
71
|
)
|
|
72
|
-
from telegram_markdown import render_markdown_to_telegram
|
|
73
|
-
|
|
74
72
|
# --- 简单 .env 加载 ---
|
|
75
73
|
def load_env(p: str = ".env"):
|
|
76
74
|
"""从指定路径加载 dotenv 格式的键值对到进程环境变量。"""
|
|
@@ -128,7 +126,7 @@ TEST_PHASE_PROMPT = f"进入测试阶段{AGENTS_PHASE_SUFFIX}"
|
|
|
128
126
|
# 报告缺陷时的专用前缀,插入在统一提示语之前
|
|
129
127
|
BUG_REPORT_PREFIX = "报告一个缺陷,详见底部最新的缺陷描述。"
|
|
130
128
|
|
|
131
|
-
_parse_mode_env = (os.environ.get("TELEGRAM_PARSE_MODE") or "
|
|
129
|
+
_parse_mode_env = (os.environ.get("TELEGRAM_PARSE_MODE") or "Markdown").strip()
|
|
132
130
|
_parse_mode_key = _parse_mode_env.replace("-", "").replace("_", "").lower()
|
|
133
131
|
MODEL_OUTPUT_PARSE_MODE: Optional[ParseMode]
|
|
134
132
|
if _parse_mode_key in _PARSE_MODE_CANDIDATES:
|
|
@@ -136,7 +134,12 @@ if _parse_mode_key in _PARSE_MODE_CANDIDATES:
|
|
|
136
134
|
if MODEL_OUTPUT_PARSE_MODE is None:
|
|
137
135
|
worker_log.info("模型输出将按纯文本发送")
|
|
138
136
|
else:
|
|
139
|
-
|
|
137
|
+
mode_value = (
|
|
138
|
+
MODEL_OUTPUT_PARSE_MODE.value
|
|
139
|
+
if isinstance(MODEL_OUTPUT_PARSE_MODE, ParseMode)
|
|
140
|
+
else str(MODEL_OUTPUT_PARSE_MODE)
|
|
141
|
+
)
|
|
142
|
+
worker_log.info("模型输出 parse_mode:%s", mode_value)
|
|
140
143
|
else:
|
|
141
144
|
MODEL_OUTPUT_PARSE_MODE = ParseMode.MARKDOWN_V2
|
|
142
145
|
worker_log.warning(
|
|
@@ -503,8 +506,8 @@ def _is_already_escaped(text: str) -> bool:
|
|
|
503
506
|
|
|
504
507
|
# 对于短文本,放宽检测条件
|
|
505
508
|
if len(text) < 20:
|
|
506
|
-
#
|
|
507
|
-
if len(matches) >=
|
|
509
|
+
# 短文本出现任意转义字符即可认定为已转义,防止重复转义
|
|
510
|
+
if len(matches) >= 1:
|
|
508
511
|
return True
|
|
509
512
|
else:
|
|
510
513
|
# 检查转义字符密度(降低到 3%)
|
|
@@ -576,29 +579,17 @@ def _unescape_if_already_escaped(text: str) -> str:
|
|
|
576
579
|
def _prepare_model_payload(text: str) -> str:
|
|
577
580
|
if _IS_MARKDOWN_V2:
|
|
578
581
|
cleaned = _unescape_if_already_escaped(text)
|
|
579
|
-
return
|
|
582
|
+
return _escape_markdown_v2(cleaned)
|
|
580
583
|
if _IS_MARKDOWN:
|
|
581
584
|
return _normalize_legacy_markdown(text)
|
|
582
585
|
return text
|
|
583
586
|
|
|
584
587
|
|
|
585
|
-
def _optimize_markdown_v2_payload(payload: str) -> str:
|
|
586
|
-
"""占位函数:保留严格转义版本,确保 MarkdownV2 合规。"""
|
|
587
|
-
|
|
588
|
-
return payload
|
|
589
|
-
|
|
590
|
-
|
|
591
588
|
def _prepare_model_payload_variants(text: str) -> tuple[str, Optional[str]]:
|
|
592
|
-
"""
|
|
593
|
-
|
|
594
|
-
strict_payload = _prepare_model_payload(text)
|
|
595
|
-
if not _IS_MARKDOWN_V2:
|
|
596
|
-
return strict_payload, None
|
|
589
|
+
"""返回首选与备用内容,默认为单一格式。"""
|
|
597
590
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
return optimized_payload, strict_payload
|
|
601
|
-
return strict_payload, None
|
|
591
|
+
payload = _prepare_model_payload(text)
|
|
592
|
+
return payload, None
|
|
602
593
|
|
|
603
594
|
|
|
604
595
|
def _extract_bad_request_message(exc: TelegramBadRequest) -> str:
|
|
@@ -704,20 +695,12 @@ async def _send_with_markdown_guard(
|
|
|
704
695
|
if raw_sender is None:
|
|
705
696
|
raise
|
|
706
697
|
|
|
707
|
-
# Markdown 彻底失败时退回纯文本发送,需要先清理 MarkdownV2 转义符号
|
|
708
|
-
fallback_payload = _force_unescape_markdown(text)
|
|
709
|
-
if fallback_payload != text:
|
|
710
|
-
worker_log.debug(
|
|
711
|
-
"Markdown 降级为纯文本发送,已移除转义符号",
|
|
712
|
-
extra={"original_length": len(text), "fallback_length": len(fallback_payload)},
|
|
713
|
-
)
|
|
714
|
-
|
|
715
698
|
worker_log.warning(
|
|
716
699
|
"Markdown 解析仍失败,将以纯文本发送",
|
|
717
700
|
extra={"length": len(text)},
|
|
718
701
|
)
|
|
719
|
-
await raw_sender(
|
|
720
|
-
return
|
|
702
|
+
await raw_sender(text)
|
|
703
|
+
return text
|
|
721
704
|
|
|
722
705
|
|
|
723
706
|
async def _notify_send_failure_message(chat_id: int) -> None:
|
|
@@ -4266,6 +4249,7 @@ async def _deliver_pending_messages(
|
|
|
4266
4249
|
"chat": chat_id,
|
|
4267
4250
|
},
|
|
4268
4251
|
)
|
|
4252
|
+
return False
|
|
4269
4253
|
else:
|
|
4270
4254
|
worker_log.info(
|
|
4271
4255
|
"模型输出已发送且计划完成",
|
|
@@ -4592,7 +4576,13 @@ async def _interrupt_long_poll(chat_id: int) -> None:
|
|
|
4592
4576
|
线程安全:使用 asyncio.Lock 保护状态访问。
|
|
4593
4577
|
"""
|
|
4594
4578
|
if CHAT_LONG_POLL_LOCK is None:
|
|
4595
|
-
|
|
4579
|
+
state = CHAT_LONG_POLL_STATE.get(chat_id)
|
|
4580
|
+
if state is not None:
|
|
4581
|
+
state["interrupted"] = True
|
|
4582
|
+
worker_log.info(
|
|
4583
|
+
"标记延迟轮询为待中断",
|
|
4584
|
+
extra={"chat": chat_id},
|
|
4585
|
+
)
|
|
4596
4586
|
return
|
|
4597
4587
|
|
|
4598
4588
|
async with CHAT_LONG_POLL_LOCK:
|
|
@@ -4685,6 +4675,13 @@ async def _watch_and_notify(chat_id: int, session_path: Path,
|
|
|
4685
4675
|
"max_rounds": long_poll_max_rounds,
|
|
4686
4676
|
"interrupted": False,
|
|
4687
4677
|
}
|
|
4678
|
+
else:
|
|
4679
|
+
CHAT_LONG_POLL_STATE[chat_id] = {
|
|
4680
|
+
"active": True,
|
|
4681
|
+
"round": 0,
|
|
4682
|
+
"max_rounds": long_poll_max_rounds,
|
|
4683
|
+
"interrupted": False,
|
|
4684
|
+
}
|
|
4688
4685
|
worker_log.info(
|
|
4689
4686
|
"首次发送成功,启动延迟轮询模式",
|
|
4690
4687
|
extra={
|
|
@@ -4706,6 +4703,10 @@ async def _watch_and_notify(chat_id: int, session_path: Path,
|
|
|
4706
4703
|
state = CHAT_LONG_POLL_STATE.get(chat_id)
|
|
4707
4704
|
if state is not None:
|
|
4708
4705
|
state["round"] = 0
|
|
4706
|
+
else:
|
|
4707
|
+
state = CHAT_LONG_POLL_STATE.get(chat_id)
|
|
4708
|
+
if state is not None:
|
|
4709
|
+
state["round"] = 0
|
|
4709
4710
|
worker_log.info(
|
|
4710
4711
|
"延迟轮询中收到新消息,重置计数",
|
|
4711
4712
|
extra={
|
|
@@ -4721,6 +4722,10 @@ async def _watch_and_notify(chat_id: int, session_path: Path,
|
|
|
4721
4722
|
state = CHAT_LONG_POLL_STATE.get(chat_id)
|
|
4722
4723
|
if state is not None:
|
|
4723
4724
|
state["round"] = long_poll_rounds
|
|
4725
|
+
else:
|
|
4726
|
+
state = CHAT_LONG_POLL_STATE.get(chat_id)
|
|
4727
|
+
if state is not None:
|
|
4728
|
+
state["round"] = long_poll_rounds
|
|
4724
4729
|
|
|
4725
4730
|
if long_poll_rounds >= long_poll_max_rounds:
|
|
4726
4731
|
worker_log.info(
|
|
@@ -4757,6 +4762,13 @@ async def _watch_and_notify(chat_id: int, session_path: Path,
|
|
|
4757
4762
|
"监听任务退出,已清理延迟轮询状态",
|
|
4758
4763
|
extra={"chat": chat_id},
|
|
4759
4764
|
)
|
|
4765
|
+
else:
|
|
4766
|
+
if chat_id in CHAT_LONG_POLL_STATE:
|
|
4767
|
+
CHAT_LONG_POLL_STATE.pop(chat_id, None)
|
|
4768
|
+
worker_log.debug(
|
|
4769
|
+
"监听任务退出,已清理延迟轮询状态",
|
|
4770
|
+
extra={"chat": chat_id},
|
|
4771
|
+
)
|
|
4760
4772
|
|
|
4761
4773
|
|
|
4762
4774
|
def _read_pointer_path(pointer: Path) -> Optional[Path]:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
bot.py,sha256=
|
|
1
|
+
bot.py,sha256=TthqY_Rvndzd3pgLH6lvPcsDXQbwkuMfB4W9qt_Li8I,273832
|
|
2
2
|
logging_setup.py,sha256=gvxHi8mUwK3IhXJrsGNTDo-DR6ngkyav1X-tvlBF_IE,4613
|
|
3
3
|
master.py,sha256=ZW4A3Gh0MUKFnfZX-VJ7OCNnBzlEcOWkPKYH92OfyKA,112967
|
|
4
4
|
project_repository.py,sha256=UcthtSGOJK0cTE5bQCneo3xkomRG-kyc1N1QVqxeHIs,17577
|
|
@@ -426,15 +426,14 @@ tasks/constants.py,sha256=tS1kZxBIUm3JJUMHm25XI-KHNUZl5NhbbuzjzL_rF-c,299
|
|
|
426
426
|
tasks/fsm.py,sha256=rKXXLEieQQU4r2z_CZUvn1_70FXiZXBBugF40gpe_tQ,1476
|
|
427
427
|
tasks/models.py,sha256=N_qqRBo9xMSV0vbn4k6bLBXT8C_dp_oTFUxvdx16ZQM,2459
|
|
428
428
|
tasks/service.py,sha256=w_S_aWiVqRXzXEpimLDsuCCCX2lB5uDkff9aKThBw9c,41916
|
|
429
|
-
|
|
430
|
-
vibego_cli/__init__.py,sha256=TIfBvbASwv0PjEHa0uOiVJkU2YO8pvPm9O9aRMy5kDE,311
|
|
429
|
+
vibego_cli/__init__.py,sha256=mkajWsGOmqjBWy1uSiLsUbptT642DZPYn7LoFD0LU7o,311
|
|
431
430
|
vibego_cli/__main__.py,sha256=qqTrYmRRLe4361fMzbI3-CqpZ7AhTofIHmfp4ykrrBY,158
|
|
432
431
|
vibego_cli/config.py,sha256=VxkPJMq01tA3h3cOkH-z_tiP7pMgfSGGicRvUnCWkhI,3054
|
|
433
432
|
vibego_cli/deps.py,sha256=1nRXI7Dd-S1hYE8DligzK5fIluQWETRUj4_OKL0DikQ,1419
|
|
434
433
|
vibego_cli/main.py,sha256=X__NXwZnIDIFbdKSTbNyZgZHKcPlN0DQz9sqTI1aQ9E,12158
|
|
435
434
|
vibego_cli/data/worker_requirements.txt,sha256=QSt30DSSSHtfucTFPpc7twk9kLS5rVLNTcvDiagxrZg,62
|
|
436
|
-
vibego-0.2.
|
|
437
|
-
vibego-0.2.
|
|
438
|
-
vibego-0.2.
|
|
439
|
-
vibego-0.2.
|
|
440
|
-
vibego-0.2.
|
|
435
|
+
vibego-0.2.40.dist-info/METADATA,sha256=XiZcCoOO5eGJ6nG9Xt92Bt9R-zViJfjf3In31pzE78M,10519
|
|
436
|
+
vibego-0.2.40.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
437
|
+
vibego-0.2.40.dist-info/entry_points.txt,sha256=Lsy_zm-dlyxt8-9DL9blBReIwU2k22c8-kifr46ND1M,48
|
|
438
|
+
vibego-0.2.40.dist-info/top_level.txt,sha256=R56CT3nW5H5v3ce0l3QDN4-C4qxTrNWzRTwrxnkDX4U,69
|
|
439
|
+
vibego-0.2.40.dist-info/RECORD,,
|
vibego_cli/__init__.py
CHANGED
telegram_markdown/__init__.py
DELETED
|
@@ -1,269 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
自定义 Markdown → Telegram MarkdownV2 渲染器。
|
|
3
|
-
|
|
4
|
-
依赖 markdown-it-py 提供的 CommonMark 解析能力,将常见 Markdown
|
|
5
|
-
语法(段落、标题、列表、引用、加粗、斜体、代码块、行内代码、链接等)
|
|
6
|
-
转换为符合 Telegram MarkdownV2 语法的文本。
|
|
7
|
-
|
|
8
|
-
Telegram 对 MarkdownV2 的语法要求非常严格,特殊字符必须转义,否则会直接
|
|
9
|
-
返回 “can't parse entities” 错误。借助解析树,我们可以准确地区分格式化标记
|
|
10
|
-
与普通文本,做到“格式保留 + 无额外反斜杠”。
|
|
11
|
-
"""
|
|
12
|
-
from __future__ import annotations
|
|
13
|
-
|
|
14
|
-
import re
|
|
15
|
-
from typing import Iterable, List, Optional, Tuple
|
|
16
|
-
|
|
17
|
-
from markdown_it import MarkdownIt
|
|
18
|
-
from markdown_it.token import Token
|
|
19
|
-
|
|
20
|
-
_TELEGRAM_SPECIAL_CHARS = re.compile(r"([_*\[\]()~`>#+\-=|{}.!\\])")
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def _escape_plain_text(value: str) -> str:
|
|
24
|
-
"""转义 Telegram MarkdownV2 的特殊字符。"""
|
|
25
|
-
if not value:
|
|
26
|
-
return ""
|
|
27
|
-
return _TELEGRAM_SPECIAL_CHARS.sub(r"\\\1", value)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def _escape_url(value: str) -> str:
|
|
31
|
-
"""URL 中统一转义括号、空格等特殊字符。"""
|
|
32
|
-
if not value:
|
|
33
|
-
return ""
|
|
34
|
-
escaped = value.replace("\\", "\\\\")
|
|
35
|
-
escaped = escaped.replace(")", r"\)").replace("(", r"\(")
|
|
36
|
-
return escaped
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def _escape_inline_code(value: str) -> str:
|
|
40
|
-
"""行内代码仅需要处理反斜杠与反引号。"""
|
|
41
|
-
if not value:
|
|
42
|
-
return "``"
|
|
43
|
-
escaped = value.replace("\\", "\\\\").replace("`", r"\`")
|
|
44
|
-
return f"`{escaped}`"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def _escape_code_block(value: str, language: str = "") -> str:
|
|
48
|
-
"""多行代码块同样保留原样,只需保护反引号。"""
|
|
49
|
-
content = (value or "").rstrip("\n")
|
|
50
|
-
content = content.replace("```", r"\`\`\`")
|
|
51
|
-
header = f"```{language.strip()}\n" if language else "```\n"
|
|
52
|
-
return f"{header}{content}\n```"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
class _TelegramMarkdownRenderer:
|
|
56
|
-
"""对 markdown-it 解析出的 Token 序列进行 Telegram MarkdownV2 序列化。"""
|
|
57
|
-
|
|
58
|
-
def __init__(self) -> None:
|
|
59
|
-
self._parser = MarkdownIt("commonmark", {"linkify": True})
|
|
60
|
-
|
|
61
|
-
# ---- 外部接口 ---------------------------------------------------------
|
|
62
|
-
def render(self, markdown_text: str) -> str:
|
|
63
|
-
tokens = self._parser.parse(markdown_text or "")
|
|
64
|
-
return self._render_block_tokens(tokens, 0, len(tokens)).strip()
|
|
65
|
-
|
|
66
|
-
# ---- Block 级渲染 -----------------------------------------------------
|
|
67
|
-
def _render_block_tokens(self, tokens: List[Token], start: int, end: int) -> str:
|
|
68
|
-
blocks: List[str] = []
|
|
69
|
-
index = start
|
|
70
|
-
while index < end:
|
|
71
|
-
token = tokens[index]
|
|
72
|
-
token_type = token.type
|
|
73
|
-
|
|
74
|
-
if token_type == "paragraph_open":
|
|
75
|
-
inline = tokens[index + 1]
|
|
76
|
-
blocks.append(self._render_inline(inline.children or []))
|
|
77
|
-
index += 3 # 跳过 inline + paragraph_close
|
|
78
|
-
continue
|
|
79
|
-
|
|
80
|
-
if token_type == "heading_open":
|
|
81
|
-
inline = tokens[index + 1]
|
|
82
|
-
content = self._render_inline(inline.children or [])
|
|
83
|
-
blocks.append(f"*{content}*")
|
|
84
|
-
index += 3
|
|
85
|
-
continue
|
|
86
|
-
|
|
87
|
-
if token_type == "bullet_list_open":
|
|
88
|
-
close_idx = self._find_matching(tokens, index, "bullet_list_close")
|
|
89
|
-
blocks.append(self._render_list(tokens, index + 1, close_idx, ordered=False))
|
|
90
|
-
index = close_idx + 1
|
|
91
|
-
continue
|
|
92
|
-
|
|
93
|
-
if token_type == "ordered_list_open":
|
|
94
|
-
close_idx = self._find_matching(tokens, index, "ordered_list_close")
|
|
95
|
-
start_number = int(token.attrGet("start") or "1")
|
|
96
|
-
blocks.append(
|
|
97
|
-
self._render_list(tokens, index + 1, close_idx, ordered=True, start_number=start_number)
|
|
98
|
-
)
|
|
99
|
-
index = close_idx + 1
|
|
100
|
-
continue
|
|
101
|
-
|
|
102
|
-
if token_type == "blockquote_open":
|
|
103
|
-
close_idx = self._find_matching(tokens, index, "blockquote_close")
|
|
104
|
-
quoted = self._render_block_tokens(tokens, index + 1, close_idx)
|
|
105
|
-
lines = quoted.splitlines()
|
|
106
|
-
blocks.append("\n".join(f"> {line}" if line else ">" for line in lines))
|
|
107
|
-
index = close_idx + 1
|
|
108
|
-
continue
|
|
109
|
-
|
|
110
|
-
if token_type in {"fence", "code_block"}:
|
|
111
|
-
language = token.info or "" if token_type == "fence" else ""
|
|
112
|
-
blocks.append(_escape_code_block(token.content, language))
|
|
113
|
-
index += 1
|
|
114
|
-
continue
|
|
115
|
-
|
|
116
|
-
if token_type == "inline":
|
|
117
|
-
blocks.append(self._render_inline(token.children or []))
|
|
118
|
-
index += 1
|
|
119
|
-
continue
|
|
120
|
-
|
|
121
|
-
if token_type == "hr":
|
|
122
|
-
blocks.append("―" * 3)
|
|
123
|
-
index += 1
|
|
124
|
-
continue
|
|
125
|
-
|
|
126
|
-
# 其他 Block token(如 html_block)直接忽略或转换为原始文本
|
|
127
|
-
if token_type == "html_block":
|
|
128
|
-
blocks.append(_escape_plain_text(token.content))
|
|
129
|
-
index += 1
|
|
130
|
-
continue
|
|
131
|
-
|
|
132
|
-
index += 1
|
|
133
|
-
|
|
134
|
-
return "\n\n".join(part for part in blocks if part)
|
|
135
|
-
|
|
136
|
-
def _render_list(
|
|
137
|
-
self,
|
|
138
|
-
tokens: List[Token],
|
|
139
|
-
start: int,
|
|
140
|
-
end: int,
|
|
141
|
-
*,
|
|
142
|
-
ordered: bool,
|
|
143
|
-
start_number: int = 1,
|
|
144
|
-
) -> str:
|
|
145
|
-
parts: List[str] = []
|
|
146
|
-
index = start
|
|
147
|
-
counter = start_number
|
|
148
|
-
while index < end:
|
|
149
|
-
token = tokens[index]
|
|
150
|
-
if token.type != "list_item_open":
|
|
151
|
-
index += 1
|
|
152
|
-
continue
|
|
153
|
-
|
|
154
|
-
close_idx = self._find_matching(tokens, index, "list_item_close")
|
|
155
|
-
body = self._render_block_tokens(tokens, index + 1, close_idx)
|
|
156
|
-
lines = body.splitlines()
|
|
157
|
-
prefix = f"{counter}. " if ordered else "- "
|
|
158
|
-
if lines:
|
|
159
|
-
rendered = [prefix + lines[0]]
|
|
160
|
-
rendered.extend((" " if ordered else " ") + line for line in lines[1:])
|
|
161
|
-
parts.append("\n".join(rendered))
|
|
162
|
-
else:
|
|
163
|
-
parts.append(prefix.rstrip())
|
|
164
|
-
|
|
165
|
-
if ordered:
|
|
166
|
-
counter += 1
|
|
167
|
-
index = close_idx + 1
|
|
168
|
-
|
|
169
|
-
return "\n".join(parts)
|
|
170
|
-
|
|
171
|
-
# ---- Inline 渲染 ------------------------------------------------------
|
|
172
|
-
def _render_inline(self, tokens: Iterable[Token]) -> str:
|
|
173
|
-
rendered, _ = self._render_inline_segment(list(tokens), 0, None)
|
|
174
|
-
return rendered
|
|
175
|
-
|
|
176
|
-
def _render_inline_segment(
|
|
177
|
-
self,
|
|
178
|
-
tokens: List[Token],
|
|
179
|
-
start: int,
|
|
180
|
-
closing_type: Optional[str],
|
|
181
|
-
) -> Tuple[str, int]:
|
|
182
|
-
parts: List[str] = []
|
|
183
|
-
index = start
|
|
184
|
-
|
|
185
|
-
while index < len(tokens):
|
|
186
|
-
token = tokens[index]
|
|
187
|
-
token_type = token.type
|
|
188
|
-
|
|
189
|
-
if closing_type and token_type == closing_type:
|
|
190
|
-
return "".join(parts), index + 1
|
|
191
|
-
|
|
192
|
-
if token_type == "text":
|
|
193
|
-
parts.append(_escape_plain_text(token.content))
|
|
194
|
-
index += 1
|
|
195
|
-
continue
|
|
196
|
-
|
|
197
|
-
if token_type in {"softbreak", "hardbreak"}:
|
|
198
|
-
parts.append("\n")
|
|
199
|
-
index += 1
|
|
200
|
-
continue
|
|
201
|
-
|
|
202
|
-
if token_type == "code_inline":
|
|
203
|
-
parts.append(_escape_inline_code(token.content))
|
|
204
|
-
index += 1
|
|
205
|
-
continue
|
|
206
|
-
|
|
207
|
-
if token_type == "strong_open":
|
|
208
|
-
inner, next_index = self._render_inline_segment(tokens, index + 1, "strong_close")
|
|
209
|
-
parts.append(f"*{inner}*")
|
|
210
|
-
index = next_index
|
|
211
|
-
continue
|
|
212
|
-
|
|
213
|
-
if token_type == "em_open":
|
|
214
|
-
inner, next_index = self._render_inline_segment(tokens, index + 1, "em_close")
|
|
215
|
-
parts.append(f"_{inner}_")
|
|
216
|
-
index = next_index
|
|
217
|
-
continue
|
|
218
|
-
|
|
219
|
-
if token_type == "link_open":
|
|
220
|
-
href = token.attrGet("href") or ""
|
|
221
|
-
inner, next_index = self._render_inline_segment(tokens, index + 1, "link_close")
|
|
222
|
-
parts.append(f"[{inner}]({_escape_url(href)})")
|
|
223
|
-
index = next_index
|
|
224
|
-
continue
|
|
225
|
-
|
|
226
|
-
if token_type == "image":
|
|
227
|
-
alt_text = self._render_inline(token.children or [])
|
|
228
|
-
parts.append(f"[{alt_text}]")
|
|
229
|
-
index += 1
|
|
230
|
-
continue
|
|
231
|
-
|
|
232
|
-
if token_type == "html_inline":
|
|
233
|
-
parts.append(_escape_plain_text(token.content))
|
|
234
|
-
index += 1
|
|
235
|
-
continue
|
|
236
|
-
|
|
237
|
-
if token_type == "strikethrough_open":
|
|
238
|
-
inner, next_index = self._render_inline_segment(tokens, index + 1, "strikethrough_close")
|
|
239
|
-
parts.append(f"~{inner}~")
|
|
240
|
-
index = next_index
|
|
241
|
-
continue
|
|
242
|
-
|
|
243
|
-
index += 1
|
|
244
|
-
|
|
245
|
-
return "".join(parts), len(tokens)
|
|
246
|
-
|
|
247
|
-
# ---- 工具方法 ---------------------------------------------------------
|
|
248
|
-
@staticmethod
|
|
249
|
-
def _find_matching(tokens: List[Token], start_idx: int, closing_type: str) -> int:
|
|
250
|
-
"""使用 nesting 深度寻找匹配的关闭 token。"""
|
|
251
|
-
open_type = tokens[start_idx].type
|
|
252
|
-
depth = 0
|
|
253
|
-
for idx in range(start_idx, len(tokens)):
|
|
254
|
-
token = tokens[idx]
|
|
255
|
-
if token.type == open_type and token.nesting == 1:
|
|
256
|
-
depth += 1
|
|
257
|
-
elif token.type == closing_type and token.nesting == -1:
|
|
258
|
-
depth -= 1
|
|
259
|
-
if depth == 0:
|
|
260
|
-
return idx
|
|
261
|
-
raise ValueError(f"未找到匹配的 {closing_type}")
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
_RENDERER = _TelegramMarkdownRenderer()
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
def render_markdown_to_telegram(markdown_text: str) -> str:
|
|
268
|
-
"""将 Markdown 文本转换为 Telegram MarkdownV2。"""
|
|
269
|
-
return _RENDERER.render(markdown_text)
|
|
File without changes
|
|
File without changes
|