feat: sync bot api formatted text parse modes
Sync telesrv 00630bc (feat(botapi): support formatted text parse modes). Skipped telesrv docs changes per public sync rules.
This commit is contained in:
parent
afc73ca761
commit
2965f5d47d
11 changed files with 1684 additions and 85 deletions
50
cmd/bots/bedolagaformat/README.md
Normal file
50
cmd/bots/bedolagaformat/README.md
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# Bedolaga formatted-text demo
|
||||||
|
|
||||||
|
这个 demo 复刻 Bedolaga 的 Bot 工厂关键配置:
|
||||||
|
|
||||||
|
```python
|
||||||
|
Bot(
|
||||||
|
...,
|
||||||
|
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
因此 `/start` 的 `message.answer()` 不显式传 `parse_mode`,仍会由 aiogram 自动向
|
||||||
|
telesrv 发送 `parse_mode=HTML`。`/formatdemo` 依次发送默认 HTML、legacy Markdown、
|
||||||
|
MarkdownV2,用于验证完整的 `aiogram → telesrv Bot API → MTProto message/update →
|
||||||
|
TDesktop` 链路。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
建议使用虚拟环境,token 只通过环境变量传入:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m venv "$env:TEMP\telesrv-bedolaga-demo-venv"
|
||||||
|
& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" -m pip install `
|
||||||
|
-r .\cmd\bots\bedolagaformat\requirements.txt
|
||||||
|
|
||||||
|
$env:TELESRV_BOT_TOKEN = "<bot_id>:<secret>"
|
||||||
|
$env:TELESRV_BOT_API_SERVER = "http://127.0.0.1:8081"
|
||||||
|
& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" `
|
||||||
|
.\cmd\bots\bedolagaformat\demo.py --drop-pending
|
||||||
|
```
|
||||||
|
|
||||||
|
随后在 TDesktop 中向 bot 发送:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/start
|
||||||
|
/formatdemo
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以不启动 polling,直接向指定私聊发送三条格式测试消息:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" `
|
||||||
|
.\cmd\bots\bedolagaformat\demo.py `
|
||||||
|
--send-only `
|
||||||
|
--send-chat-id 1780243200 `
|
||||||
|
--marker BEDOLAGA-LOCAL-VERIFY
|
||||||
|
```
|
||||||
|
|
||||||
|
`--base-url` 只接受 API server 根地址,不要追加 `/bot`。脚本不会打印 token,也不会
|
||||||
|
把 token 写入文件。
|
||||||
221
cmd/bots/bedolagaformat/demo.py
Normal file
221
cmd/bots/bedolagaformat/demo.py
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Bedolaga-style aiogram formatted-text demo for the telesrv Bot API.
|
||||||
|
|
||||||
|
The bot factory intentionally mirrors remnawave-bedolaga-telegram-bot:
|
||||||
|
DefaultBotProperties(parse_mode=ParseMode.HTML) is installed globally, while
|
||||||
|
individual sends may override it with legacy Markdown or MarkdownV2.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
from aiogram import Bot, Dispatcher, Router
|
||||||
|
from aiogram.client.default import DefaultBotProperties
|
||||||
|
from aiogram.client.session.aiohttp import AiohttpSession
|
||||||
|
from aiogram.client.telegram import TelegramAPIServer
|
||||||
|
from aiogram.enums import ParseMode
|
||||||
|
from aiogram.filters import Command, CommandStart
|
||||||
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
|
||||||
|
LOG = logging.getLogger("bedolagaformat")
|
||||||
|
MARKER_RE = re.compile(r"^[A-Za-z0-9-]{1,64}$")
|
||||||
|
MARKDOWN_V2_RESERVED_RE = re.compile(r"([_\*\[\]\(\)~`>#+\-=|{}\.!\\])")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FormatSample:
|
||||||
|
name: str
|
||||||
|
text: str
|
||||||
|
parse_mode: ParseMode | None
|
||||||
|
|
||||||
|
|
||||||
|
def default_marker() -> str:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
return now.strftime("BEDOLAGA%Y%m%dT%H%M%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def escape_markdown_v2_text(value: str) -> str:
|
||||||
|
return MARKDOWN_V2_RESERVED_RE.sub(r"\\\1", value)
|
||||||
|
|
||||||
|
|
||||||
|
def format_samples(marker: str) -> tuple[FormatSample, ...]:
|
||||||
|
"""Return deterministic messages whose labels are safe in every grammar."""
|
||||||
|
markdown_v2_marker = escape_markdown_v2_text(marker)
|
||||||
|
return (
|
||||||
|
FormatSample(
|
||||||
|
name="default_html",
|
||||||
|
text=(
|
||||||
|
f"<b>{marker} Default HTML</b> "
|
||||||
|
"<i>italic 😀</i> <u>underline</u> "
|
||||||
|
"<tg-spoiler>spoiler</tg-spoiler> "
|
||||||
|
'<a href="https://example.com/bedolaga">link</a>'
|
||||||
|
),
|
||||||
|
# Deliberately omitted from send_message: the Bedolaga factory default
|
||||||
|
# must inject HTML just as it does for message.answer() in start.py.
|
||||||
|
parse_mode=None,
|
||||||
|
),
|
||||||
|
FormatSample(
|
||||||
|
name="markdown",
|
||||||
|
text=(
|
||||||
|
f"*{marker} Markdown* _italic 😀_ "
|
||||||
|
"[link](https://example.com/bedolaga) `code`"
|
||||||
|
),
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
),
|
||||||
|
FormatSample(
|
||||||
|
name="markdown_v2",
|
||||||
|
text=(
|
||||||
|
f"*{markdown_v2_marker} MarkdownV2* _italic 😀_ __underline__ "
|
||||||
|
"~strike~ ||spoiler|| "
|
||||||
|
"[link](https://example.com/bedolaga) `code`"
|
||||||
|
),
|
||||||
|
parse_mode=ParseMode.MARKDOWN_V2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Bedolaga-style aiogram HTML/Markdown demo against telesrv"
|
||||||
|
)
|
||||||
|
parser.add_argument("--token", default=os.getenv("TELESRV_BOT_TOKEN"))
|
||||||
|
parser.add_argument(
|
||||||
|
"--base-url",
|
||||||
|
default=os.getenv("TELESRV_BOT_API_SERVER", "http://127.0.0.1:8081"),
|
||||||
|
help="API server origin; do not append /bot<TOKEN>",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--send-chat-id",
|
||||||
|
type=int,
|
||||||
|
default=int(os.environ["TELESRV_BOT_DEMO_CHAT_ID"])
|
||||||
|
if os.getenv("TELESRV_BOT_DEMO_CHAT_ID")
|
||||||
|
else None,
|
||||||
|
help="Send the complete suite proactively before polling",
|
||||||
|
)
|
||||||
|
parser.add_argument("--send-only", action="store_true")
|
||||||
|
parser.add_argument("--drop-pending", action="store_true")
|
||||||
|
parser.add_argument("--polling-timeout", type=int, default=10)
|
||||||
|
parser.add_argument("--marker", default=default_marker())
|
||||||
|
parser.add_argument("--log-level", default="INFO")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if not args.token:
|
||||||
|
parser.error("missing --token or TELESRV_BOT_TOKEN")
|
||||||
|
if args.send_only and args.send_chat_id is None:
|
||||||
|
parser.error("--send-only requires --send-chat-id")
|
||||||
|
if not MARKER_RE.fullmatch(args.marker):
|
||||||
|
parser.error("--marker must contain 1-64 ASCII letters, digits, or hyphens")
|
||||||
|
if not 0 <= args.polling_timeout <= 50:
|
||||||
|
parser.error("--polling-timeout must be between 0 and 50")
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def create_bot(token: str, base_url: str) -> Bot:
|
||||||
|
"""Mirror Bedolaga's create_bot() with a custom Telegram API server."""
|
||||||
|
session = AiohttpSession(
|
||||||
|
api=TelegramAPIServer.from_base(base_url.rstrip("/"))
|
||||||
|
)
|
||||||
|
return Bot(
|
||||||
|
token=token,
|
||||||
|
session=session,
|
||||||
|
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_format_suite(bot: Bot, chat_id: int, marker: str) -> list[int]:
|
||||||
|
message_ids: list[int] = []
|
||||||
|
for sample in format_samples(marker):
|
||||||
|
if sample.parse_mode is None:
|
||||||
|
sent = await bot.send_message(chat_id=chat_id, text=sample.text)
|
||||||
|
else:
|
||||||
|
sent = await bot.send_message(
|
||||||
|
chat_id=chat_id,
|
||||||
|
text=sample.text,
|
||||||
|
parse_mode=sample.parse_mode,
|
||||||
|
)
|
||||||
|
message_ids.append(sent.message_id)
|
||||||
|
LOG.info(
|
||||||
|
"sent sample=%s chat_id=%s message_id=%s parse_mode=%s",
|
||||||
|
sample.name,
|
||||||
|
chat_id,
|
||||||
|
sent.message_id,
|
||||||
|
sample.parse_mode.value if sample.parse_mode is not None else "default-html",
|
||||||
|
)
|
||||||
|
return message_ids
|
||||||
|
|
||||||
|
|
||||||
|
def build_dispatcher(marker: str) -> Dispatcher:
|
||||||
|
router = Router(name="telesrv-bedolaga-format")
|
||||||
|
|
||||||
|
@router.message(CommandStart())
|
||||||
|
async def start(message: Message) -> None:
|
||||||
|
# No parse_mode argument: this is the exact failure shape from Bedolaga's
|
||||||
|
# start handler when the Bot factory installs default HTML globally.
|
||||||
|
await message.answer(
|
||||||
|
f"<b>{marker} Start OK</b> <i>default HTML inherited</i> 😀"
|
||||||
|
)
|
||||||
|
LOG.info("handled /start chat_id=%s incoming_message_id=%s", message.chat.id, message.message_id)
|
||||||
|
|
||||||
|
@router.message(Command("formatdemo"))
|
||||||
|
async def format_demo(message: Message) -> None:
|
||||||
|
ids = await send_format_suite(message.bot, message.chat.id, marker)
|
||||||
|
LOG.info(
|
||||||
|
"handled /formatdemo chat_id=%s incoming_message_id=%s sent_message_ids=%s",
|
||||||
|
message.chat.id,
|
||||||
|
message.message_id,
|
||||||
|
ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
dispatcher = Dispatcher()
|
||||||
|
dispatcher.include_router(router)
|
||||||
|
return dispatcher
|
||||||
|
|
||||||
|
|
||||||
|
async def run(args: argparse.Namespace) -> None:
|
||||||
|
bot = create_bot(args.token, args.base_url)
|
||||||
|
try:
|
||||||
|
me = await bot.get_me()
|
||||||
|
LOG.info(
|
||||||
|
"authenticated bot_id=%s username=@%s bot_api=%s marker=%s",
|
||||||
|
me.id,
|
||||||
|
me.username or "",
|
||||||
|
args.base_url,
|
||||||
|
args.marker,
|
||||||
|
)
|
||||||
|
if args.send_chat_id is not None:
|
||||||
|
await send_format_suite(bot, args.send_chat_id, args.marker)
|
||||||
|
if args.send_only:
|
||||||
|
return
|
||||||
|
|
||||||
|
await bot.delete_webhook(drop_pending_updates=args.drop_pending)
|
||||||
|
dispatcher = build_dispatcher(args.marker)
|
||||||
|
LOG.info("polling started; send /start or /formatdemo to @%s", me.username or me.id)
|
||||||
|
await dispatcher.start_polling(
|
||||||
|
bot,
|
||||||
|
allowed_updates=["message"],
|
||||||
|
polling_timeout=args.polling_timeout,
|
||||||
|
close_bot_session=False,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await bot.session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, args.log_level.upper(), logging.INFO),
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
asyncio.run(run(args))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
1
cmd/bots/bedolagaformat/requirements.txt
Normal file
1
cmd/bots/bedolagaformat/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
aiogram==3.30.0
|
||||||
52
cmd/bots/bedolagaformat/test_demo.py
Normal file
52
cmd/bots/bedolagaformat/test_demo.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
from aiogram.enums import ParseMode
|
||||||
|
|
||||||
|
|
||||||
|
MODULE_PATH = Path(__file__).with_name("demo.py")
|
||||||
|
SPEC = importlib.util.spec_from_file_location("bedolagaformat_demo", MODULE_PATH)
|
||||||
|
assert SPEC is not None and SPEC.loader is not None
|
||||||
|
demo = importlib.util.module_from_spec(SPEC)
|
||||||
|
sys.modules[SPEC.name] = demo
|
||||||
|
SPEC.loader.exec_module(demo)
|
||||||
|
|
||||||
|
|
||||||
|
class SentMessage:
|
||||||
|
def __init__(self, message_id: int) -> None:
|
||||||
|
self.message_id = message_id
|
||||||
|
|
||||||
|
|
||||||
|
class BedolagaFormatDemoTest(unittest.IsolatedAsyncioTestCase):
|
||||||
|
def test_format_samples_cover_default_and_explicit_modes(self) -> None:
|
||||||
|
samples = demo.format_samples("BEDOLAGA123")
|
||||||
|
self.assertEqual(
|
||||||
|
[sample.parse_mode for sample in samples],
|
||||||
|
[None, ParseMode.MARKDOWN, ParseMode.MARKDOWN_V2],
|
||||||
|
)
|
||||||
|
self.assertIn("<b>BEDOLAGA123 Default HTML</b>", samples[0].text)
|
||||||
|
self.assertIn("*BEDOLAGA123 Markdown*", samples[1].text)
|
||||||
|
self.assertIn("*BEDOLAGA123 MarkdownV2*", samples[2].text)
|
||||||
|
|
||||||
|
def test_markdown_v2_escapes_reserved_marker_characters(self) -> None:
|
||||||
|
samples = demo.format_samples("BEDOLAGA-FULL-20260720")
|
||||||
|
self.assertIn(r"BEDOLAGA\-FULL\-20260720", samples[2].text)
|
||||||
|
|
||||||
|
async def test_send_suite_omits_parse_mode_only_for_default_html(self) -> None:
|
||||||
|
bot = AsyncMock()
|
||||||
|
bot.send_message.side_effect = [SentMessage(11), SentMessage(12), SentMessage(13)]
|
||||||
|
|
||||||
|
message_ids = await demo.send_format_suite(bot, 1780243200, "BEDOLAGA123")
|
||||||
|
|
||||||
|
self.assertEqual(message_ids, [11, 12, 13])
|
||||||
|
calls = bot.send_message.await_args_list
|
||||||
|
self.assertNotIn("parse_mode", calls[0].kwargs)
|
||||||
|
self.assertEqual(calls[1].kwargs["parse_mode"], ParseMode.MARKDOWN)
|
||||||
|
self.assertEqual(calls[2].kwargs["parse_mode"], ParseMode.MARKDOWN_V2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -6,7 +6,6 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
@ -219,39 +218,19 @@ func (h *handler) editEphemeralMessage(w http.ResponseWriter, r *http.Request, b
|
||||||
input.Fields.SetReplyMarkup, input.Fields.ReplyMarkup = markupSet, markup
|
input.Fields.SetReplyMarkup, input.Fields.ReplyMarkup = markupSet, markup
|
||||||
switch mode {
|
switch mode {
|
||||||
case "text":
|
case "text":
|
||||||
if strings.TrimSpace(values["parse_mode"]) != "" {
|
text, entities, err := botAPIFormattedTextRaw(values["text"], values["parse_mode"], values["entities"], domain.MaxMessageTextLength, true)
|
||||||
writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if values["text"] == "" {
|
|
||||||
writeAPIError(w, http.StatusBadRequest, "MESSAGE_EMPTY")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !utf8.ValidString(values["text"]) || utf8.RuneCountInString(values["text"]) > domain.MaxMessageTextLength {
|
|
||||||
writeAPIError(w, http.StatusBadRequest, "MESSAGE_TOO_LONG")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
entities, err := botAPIMessageEntities(values["entities"])
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, values["text"], entities
|
input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, text, entities
|
||||||
case "caption":
|
case "caption":
|
||||||
if strings.TrimSpace(values["parse_mode"]) != "" {
|
caption, entities, err := botAPIFormattedTextRaw(values["caption"], values["parse_mode"], values["caption_entities"], domain.MaxEphemeralCaptionLength, false)
|
||||||
writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
entities, err := botAPIMessageEntities(values["caption_entities"])
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !utf8.ValidString(values["caption"]) || utf8.RuneCountInString(values["caption"]) > domain.MaxEphemeralCaptionLength {
|
input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, caption, entities
|
||||||
writeAPIError(w, http.StatusBadRequest, "MESSAGE_TOO_LONG")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, values["caption"], entities
|
|
||||||
case "reply_markup":
|
case "reply_markup":
|
||||||
input.Fields.SetReplyMarkup = true
|
input.Fields.SetReplyMarkup = true
|
||||||
case "media":
|
case "media":
|
||||||
|
|
@ -290,7 +269,7 @@ func parseEphemeralEditMedia(raw string, input *domain.BotAPIEphemeralEditInput)
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Performer string `json:"performer"`
|
Performer string `json:"performer"`
|
||||||
}
|
}
|
||||||
if input == nil || json.Unmarshal([]byte(raw), &media) != nil || media.Type == "" || strings.TrimSpace(media.ParseMode) != "" {
|
if input == nil || json.Unmarshal([]byte(raw), &media) != nil || media.Type == "" {
|
||||||
return errors.New("MEDIA_INVALID")
|
return errors.New("MEDIA_INVALID")
|
||||||
}
|
}
|
||||||
allowed := map[string]bool{"animation": true, "audio": true, "document": true, "live_photo": true, "photo": true, "video": true}
|
allowed := map[string]bool{"animation": true, "audio": true, "document": true, "live_photo": true, "photo": true, "video": true}
|
||||||
|
|
@ -316,14 +295,11 @@ func parseEphemeralEditMedia(raw string, input *domain.BotAPIEphemeralEditInput)
|
||||||
}
|
}
|
||||||
input.SecondaryFile = secondary
|
input.SecondaryFile = secondary
|
||||||
}
|
}
|
||||||
entities, err := botAPIMessageEntities(string(media.CaptionEntities))
|
caption, entities, err := botAPIFormattedTextRaw(media.Caption, media.ParseMode, string(media.CaptionEntities), domain.MaxEphemeralCaptionLength, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !utf8.ValidString(media.Caption) || utf8.RuneCountInString(media.Caption) > domain.MaxEphemeralCaptionLength {
|
input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, caption, entities
|
||||||
return errors.New("MESSAGE_TOO_LONG")
|
|
||||||
}
|
|
||||||
input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, media.Caption, entities
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
1029
internal/botapi/formatted_text.go
Normal file
1029
internal/botapi/formatted_text.go
Normal file
File diff suppressed because it is too large
Load diff
255
internal/botapi/formatted_text_test.go
Normal file
255
internal/botapi/formatted_text_test.go
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
package botapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseBotAPIHTMLNestedUTF16LinksAndDate(t *testing.T) {
|
||||||
|
plain, entities, err := parseBotAPIHTML(`<b>A <i>😀</i></b> <a href="tg://user?id=42">Alice</a> <tg-time unix="1700000000" format="wdT">now</tg-time>`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if plain != "A 😀 Alice now" {
|
||||||
|
t.Fatalf("plain = %q", plain)
|
||||||
|
}
|
||||||
|
want := []domain.MessageEntity{
|
||||||
|
{Type: domain.MessageEntityBold, Offset: 0, Length: 4},
|
||||||
|
{Type: domain.MessageEntityItalic, Offset: 2, Length: 2},
|
||||||
|
{Type: domain.MessageEntityMentionName, Offset: 5, Length: 5, UserID: 42},
|
||||||
|
{Type: domain.MessageEntityFormattedDate, Offset: 11, Length: 3, Date: 1700000000, DayOfWeek: true, ShortDate: true, LongTime: true},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(entities, want) {
|
||||||
|
t.Fatalf("entities = %#v, want %#v", entities, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBotAPIHTMLPreAndEscapes(t *testing.T) {
|
||||||
|
plain, entities, err := parseBotAPIHTML(`<pre><code class="language-go">if a < b && b > c</code></pre>`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if plain != "if a < b && b > c" {
|
||||||
|
t.Fatalf("plain = %q", plain)
|
||||||
|
}
|
||||||
|
want := []domain.MessageEntity{{Type: domain.MessageEntityPre, Offset: 0, Length: 17, Language: "go"}}
|
||||||
|
if !reflect.DeepEqual(entities, want) {
|
||||||
|
t.Fatalf("entities = %#v, want %#v", entities, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBotAPILegacyMarkdown(t *testing.T) {
|
||||||
|
plain, entities, err := parseBotAPIMarkdown(`*bold* _😀_ [site](https://example.com) \*raw\*`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if plain != "bold 😀 site *raw*" {
|
||||||
|
t.Fatalf("plain = %q", plain)
|
||||||
|
}
|
||||||
|
want := []domain.MessageEntity{
|
||||||
|
{Type: domain.MessageEntityBold, Offset: 0, Length: 4},
|
||||||
|
{Type: domain.MessageEntityItalic, Offset: 5, Length: 2},
|
||||||
|
{Type: domain.MessageEntityTextURL, Offset: 8, Length: 4, URL: "https://example.com"},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(entities, want) {
|
||||||
|
t.Fatalf("entities = %#v, want %#v", entities, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBotAPIMarkdownV2NestedLinksAndExpandableQuote(t *testing.T) {
|
||||||
|
plain, entities, err := parseBotAPIMarkdownV2(`*bold _😀_* [site](https://example.com/a\)b) ||secret||`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if plain != "bold 😀 site secret" {
|
||||||
|
t.Fatalf("plain = %q", plain)
|
||||||
|
}
|
||||||
|
want := []domain.MessageEntity{
|
||||||
|
{Type: domain.MessageEntityBold, Offset: 0, Length: 7},
|
||||||
|
{Type: domain.MessageEntityItalic, Offset: 5, Length: 2},
|
||||||
|
{Type: domain.MessageEntityTextURL, Offset: 8, Length: 4, URL: "https://example.com/a)b"},
|
||||||
|
{Type: domain.MessageEntitySpoiler, Offset: 13, Length: 6},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(entities, want) {
|
||||||
|
t.Fatalf("entities = %#v, want %#v", entities, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
plain, entities, err = parseBotAPIMarkdownV2(">visible\n>hidden||")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if plain != "visible\nhidden" || !reflect.DeepEqual(entities, []domain.MessageEntity{{Type: domain.MessageEntityBlockquote, Offset: 0, Length: 14, Collapsed: true}}) {
|
||||||
|
t.Fatalf("expandable quote plain=%q entities=%#v", plain, entities)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBotAPIMarkdownV2FormattedDate(t *testing.T) {
|
||||||
|
plain, entities, err := parseBotAPIMarkdownV2(``)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []domain.MessageEntity{{
|
||||||
|
Type: domain.MessageEntityFormattedDate, Offset: 0, Length: 4, Date: 1700000000,
|
||||||
|
DayOfWeek: true, ShortDate: true, LongTime: true,
|
||||||
|
}}
|
||||||
|
if plain != "when" || !reflect.DeepEqual(entities, want) {
|
||||||
|
t.Fatalf("plain=%q entities=%#v", plain, entities)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBotAPIFormattedTextPrecedenceAndEntityBounds(t *testing.T) {
|
||||||
|
plain, entities, err := botAPIFormattedTextRaw(`<b>ok</b>`, " HTML ", `{not json`, domain.MaxMessageTextLength, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if plain != "ok" || !reflect.DeepEqual(entities, []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 2}}) {
|
||||||
|
t.Fatalf("plain=%q entities=%#v", plain, entities)
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, raw := range map[string]string{
|
||||||
|
"unterminated HTML": `<b>broken`,
|
||||||
|
"reserved MarkdownV2": `plain-text`,
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
mode := "HTML"
|
||||||
|
if strings.Contains(name, "MarkdownV2") {
|
||||||
|
mode = "MarkdownV2"
|
||||||
|
}
|
||||||
|
if _, _, err := botAPIFormattedTextRaw(raw, mode, "", domain.MaxMessageTextLength, true); err == nil || !strings.Contains(err.Error(), "Can't parse entities") {
|
||||||
|
t.Fatalf("error = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err = botAPIFormattedText("😀x", "", []apiMessageEntity{{Type: "bold", Offset: 1, Length: 1}}, domain.MaxMessageTextLength, true)
|
||||||
|
if err == nil || err.Error() != "ENTITY_BOUNDS_INVALID" {
|
||||||
|
t.Fatalf("surrogate-split error = %v", err)
|
||||||
|
}
|
||||||
|
_, _, err = botAPIFormattedText("abcdef", "", []apiMessageEntity{
|
||||||
|
{Type: "bold", Offset: 0, Length: 4},
|
||||||
|
{Type: "italic", Offset: 2, Length: 4},
|
||||||
|
}, domain.MaxMessageTextLength, true)
|
||||||
|
if err == nil || err.Error() != "ENTITY_BOUNDS_INVALID" {
|
||||||
|
t.Fatalf("crossing error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBotAPIExplicitExtendedEntitiesRoundTrip(t *testing.T) {
|
||||||
|
input := []apiMessageEntity{
|
||||||
|
{Type: "expandable_blockquote", Offset: 0, Length: 4},
|
||||||
|
{Type: "date_time", Offset: 5, Length: 4, UnixTime: 1700000000, DateTimeFormat: "wdT"},
|
||||||
|
{Type: "bank_card_number", Offset: 10, Length: 4},
|
||||||
|
}
|
||||||
|
_, entities, err := botAPIFormattedText("text when 1234", "", input, domain.MaxMessageTextLength, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
projected := apiMessageEntities(entities, nil)
|
||||||
|
if projected[0]["type"] != "expandable_blockquote" || projected[1]["type"] != "date_time" || projected[1]["unix_time"] != 1700000000 || projected[1]["date_time_format"] != "wdT" || projected[2]["type"] != "bank_card_number" {
|
||||||
|
t.Fatalf("projected = %#v", projected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBotAPIInlineAndNestedMediaUseFormattedTextParser(t *testing.T) {
|
||||||
|
payload := apiInlineResult{InputMessageContent: json.RawMessage(`{
|
||||||
|
"message_text":"<b>inline</b>",
|
||||||
|
"parse_mode":"HTML",
|
||||||
|
"entities":[{"type":"bold","offset":999,"length":1}]
|
||||||
|
}`)}
|
||||||
|
message, entities, _, err := inputTextMessageContentFromAPI(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if message != "inline" || !reflect.DeepEqual(entities, []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 6}}) {
|
||||||
|
t.Fatalf("inline message=%q entities=%#v", message, entities)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileID := encodeBotAPIFileID("photo:7002:m")
|
||||||
|
raw, _ := json.Marshal(map[string]any{
|
||||||
|
"type": "photo", "media": fileID, "caption": "_media_", "parse_mode": "MarkdownV2",
|
||||||
|
})
|
||||||
|
var input domain.BotAPIEphemeralEditInput
|
||||||
|
if err := parseEphemeralEditMedia(string(raw), &input); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if input.Fields.Message != "media" || !reflect.DeepEqual(input.Fields.Entities, []domain.MessageEntity{{Type: domain.MessageEntityItalic, Offset: 0, Length: 5}}) {
|
||||||
|
t.Fatalf("media fields=%#v", input.Fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBotAPIFormattedTextIsUsedByAllMessageEntryPoints(t *testing.T) {
|
||||||
|
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||||
|
gateway := &fakeBotAPIGateway{
|
||||||
|
self: domain.User{ID: 1001, FirstName: "Bot", Bot: true},
|
||||||
|
sendMessage: domain.Message{ID: 1, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001}, Body: "hello"},
|
||||||
|
sendMediaMessage: domain.Message{ID: 2, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001}, Body: "caption"},
|
||||||
|
editMessage: domain.Message{ID: 3, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001}, Body: "edited"},
|
||||||
|
ephemeralMessage: domain.EphemeralMessage{ID: 4, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, SenderUserID: 1001, ReceiverUserID: 2001, Date: 1_900_000_000, Content: domain.EphemeralContent{Message: "ephemeral"}},
|
||||||
|
}
|
||||||
|
h := (&handler{bots: bots, gateway: gateway}).routes()
|
||||||
|
|
||||||
|
rec := performBotAPIRequest(t, h, bots.profile, "sendMessage", `{"chat_id":2001,"text":"<b>hello</b>","parse_mode":"HTML"}`)
|
||||||
|
if rec.Code != http.StatusOK || gateway.sendText != "hello" || len(gateway.sendEntities) != 1 || gateway.sendEntities[0].Type != domain.MessageEntityBold {
|
||||||
|
t.Fatalf("sendMessage status=%d body=%s text=%q entities=%#v", rec.Code, rec.Body.String(), gateway.sendText, gateway.sendEntities)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileID := encodeBotAPIFileID("doc:7001")
|
||||||
|
body, _ := json.Marshal(map[string]any{"chat_id": 2001, "document": fileID, "caption": "*caption*", "parse_mode": "MarkdownV2"})
|
||||||
|
rec = performBotAPIRequest(t, h, bots.profile, "sendDocument", string(body))
|
||||||
|
if rec.Code != http.StatusOK || gateway.sendMediaCaption != "caption" || len(gateway.sendMediaEntities) != 1 || gateway.sendMediaEntities[0].Type != domain.MessageEntityBold {
|
||||||
|
t.Fatalf("sendDocument status=%d body=%s caption=%q entities=%#v", rec.Code, rec.Body.String(), gateway.sendMediaCaption, gateway.sendMediaEntities)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = performBotAPIRequest(t, h, bots.profile, "editMessageText", `{"chat_id":2001,"message_id":3,"text":"_edited_","parse_mode":"Markdown"}`)
|
||||||
|
if rec.Code != http.StatusOK || gateway.editText != "edited" || len(gateway.editEntities) != 1 || gateway.editEntities[0].Type != domain.MessageEntityItalic {
|
||||||
|
t.Fatalf("edit status=%d body=%s text=%q entities=%#v", rec.Code, rec.Body.String(), gateway.editText, gateway.editEntities)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = performBotAPIRequest(t, h, bots.profile, "sendMessage", `{"chat_id":-1000000003001,"receiver_user_id":2001,"text":"<u>ephemeral</u>","parse_mode":"HTML"}`)
|
||||||
|
if rec.Code != http.StatusOK || len(gateway.ephemeralSends) == 0 {
|
||||||
|
t.Fatalf("ephemeral status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
lastSend := gateway.ephemeralSends[len(gateway.ephemeralSends)-1]
|
||||||
|
if lastSend.Text != "ephemeral" || len(lastSend.Entities) != 1 || lastSend.Entities[0].Type != domain.MessageEntityUnderline {
|
||||||
|
t.Fatalf("ephemeral status=%d body=%s input=%#v", rec.Code, rec.Body.String(), lastSend)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = performBotAPIRequest(t, h, bots.profile, "editEphemeralMessageCaption", `{"chat_id":-1000000003001,"receiver_user_id":2001,"ephemeral_message_id":4,"caption":"<s>caption</s>","parse_mode":"HTML"}`)
|
||||||
|
if rec.Code != http.StatusOK || len(gateway.ephemeralEdits) == 0 {
|
||||||
|
t.Fatalf("ephemeral edit status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
lastEdit := gateway.ephemeralEdits[len(gateway.ephemeralEdits)-1]
|
||||||
|
if lastEdit.Fields.Message != "caption" || len(lastEdit.Fields.Entities) != 1 || lastEdit.Fields.Entities[0].Type != domain.MessageEntityStrike {
|
||||||
|
t.Fatalf("ephemeral edit status=%d body=%s input=%#v", rec.Code, rec.Body.String(), lastEdit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func FuzzBotAPIFormattedTextParsersNeverPanic(f *testing.F) {
|
||||||
|
for _, seed := range []string{"", "plain", "<b>x</b>", "<", "&broken", "*x*", "_", ">quote\n>hidden||", "", "😀"} {
|
||||||
|
f.Add(seed)
|
||||||
|
}
|
||||||
|
f.Fuzz(func(t *testing.T, input string) {
|
||||||
|
if len(input) > 4096 || !utf8.ValidString(input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _, _ = parseBotAPIHTML(input)
|
||||||
|
_, _, _ = parseBotAPIMarkdown(input)
|
||||||
|
_, _, _ = parseBotAPIMarkdownV2(input)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBotAPIHTMLParseFailureIsAtomic(t *testing.T) {
|
||||||
|
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||||
|
gateway := &fakeBotAPIGateway{}
|
||||||
|
h := (&handler{bots: bots, gateway: gateway}).routes()
|
||||||
|
rec := performBotAPIRequest(t, h, bots.profile, "sendMessage", `{"chat_id":2001,"text":"<b>broken","parse_mode":"HTML"}`)
|
||||||
|
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "Can't parse entities") || gateway.sendCalled {
|
||||||
|
t.Fatalf("status=%d body=%s gatewayCalled=%v", rec.Code, rec.Body.String(), gateway.sendCalled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,6 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
"telesrv/internal/store"
|
"telesrv/internal/store"
|
||||||
|
|
@ -62,17 +61,7 @@ func inputTextMessageContentFromAPI(payload apiInlineResult) (string, []domain.M
|
||||||
} else if payload.MessageText != "" {
|
} else if payload.MessageText != "" {
|
||||||
content.MessageText = payload.MessageText
|
content.MessageText = payload.MessageText
|
||||||
}
|
}
|
||||||
if content.ParseMode != "" {
|
message, entities, err := botAPIFormattedText(content.MessageText, content.ParseMode, content.Entities, domain.MaxMessageTextLength, true)
|
||||||
return "", nil, false, errors.New("ENTITY_PARSE_UNSUPPORTED")
|
|
||||||
}
|
|
||||||
message := content.MessageText
|
|
||||||
if message == "" {
|
|
||||||
return "", nil, false, errors.New("MESSAGE_EMPTY")
|
|
||||||
}
|
|
||||||
if utf8.RuneCountInString(message) > domain.MaxMessageTextLength {
|
|
||||||
return "", nil, false, errors.New("MESSAGE_TOO_LONG")
|
|
||||||
}
|
|
||||||
entities, err := messageEntitiesFromAPI(content.Entities)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, false, err
|
return "", nil, false, err
|
||||||
}
|
}
|
||||||
|
|
@ -97,21 +86,41 @@ func messageEntitiesFromAPI(in []apiMessageEntity) ([]domain.MessageEntity, erro
|
||||||
return nil, errors.New("ENTITY_TYPE_UNSUPPORTED")
|
return nil, errors.New("ENTITY_TYPE_UNSUPPORTED")
|
||||||
}
|
}
|
||||||
item := domain.MessageEntity{
|
item := domain.MessageEntity{
|
||||||
Type: mapped,
|
Type: mapped,
|
||||||
Offset: entity.Offset,
|
Offset: entity.Offset,
|
||||||
Length: entity.Length,
|
Length: entity.Length,
|
||||||
URL: entity.URL,
|
|
||||||
Language: entity.Language,
|
|
||||||
}
|
}
|
||||||
if entity.User != nil {
|
switch mapped {
|
||||||
item.UserID = entity.User.ID
|
case domain.MessageEntityTextURL:
|
||||||
}
|
resolved, ok := botAPITextLinkEntity(entity.URL, entity.Offset, entity.Length)
|
||||||
if entity.CustomEmojiID != "" {
|
if !ok {
|
||||||
|
return nil, errors.New("ENTITY_TYPE_UNSUPPORTED")
|
||||||
|
}
|
||||||
|
item = resolved
|
||||||
|
case domain.MessageEntityMentionName:
|
||||||
|
if entity.User != nil {
|
||||||
|
item.UserID = entity.User.ID
|
||||||
|
}
|
||||||
|
if item.UserID <= 0 {
|
||||||
|
return nil, errors.New("ENTITY_TYPE_UNSUPPORTED")
|
||||||
|
}
|
||||||
|
case domain.MessageEntityPre:
|
||||||
|
item.Language = entity.Language
|
||||||
|
case domain.MessageEntityBlockquote:
|
||||||
|
item.Collapsed = entity.Type == "expandable_blockquote"
|
||||||
|
case domain.MessageEntityCustomEmoji:
|
||||||
id, err := strconv.ParseInt(entity.CustomEmojiID, 10, 64)
|
id, err := strconv.ParseInt(entity.CustomEmojiID, 10, 64)
|
||||||
if err != nil || id <= 0 {
|
if err != nil || id <= 0 {
|
||||||
return nil, errors.New("ENTITY_TYPE_UNSUPPORTED")
|
return nil, errors.New("ENTITY_TYPE_UNSUPPORTED")
|
||||||
}
|
}
|
||||||
item.DocumentID = id
|
item.DocumentID = id
|
||||||
|
case domain.MessageEntityFormattedDate:
|
||||||
|
formatted, err := botAPIFormattedDate(entity.UnixTime, entity.DateTimeFormat)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("ENTITY_TYPE_UNSUPPORTED")
|
||||||
|
}
|
||||||
|
formatted.Offset, formatted.Length = entity.Offset, entity.Length
|
||||||
|
item = formatted
|
||||||
}
|
}
|
||||||
out = append(out, item)
|
out = append(out, item)
|
||||||
}
|
}
|
||||||
|
|
@ -140,6 +149,8 @@ func apiEntityType(in string) (domain.MessageEntityType, bool) {
|
||||||
return domain.MessageEntitySpoiler, true
|
return domain.MessageEntitySpoiler, true
|
||||||
case "blockquote":
|
case "blockquote":
|
||||||
return domain.MessageEntityBlockquote, true
|
return domain.MessageEntityBlockquote, true
|
||||||
|
case "expandable_blockquote":
|
||||||
|
return domain.MessageEntityBlockquote, true
|
||||||
case "custom_emoji":
|
case "custom_emoji":
|
||||||
return domain.MessageEntityCustomEmoji, true
|
return domain.MessageEntityCustomEmoji, true
|
||||||
case "mention":
|
case "mention":
|
||||||
|
|
@ -156,6 +167,10 @@ func apiEntityType(in string) (domain.MessageEntityType, bool) {
|
||||||
return domain.MessageEntityEmail, true
|
return domain.MessageEntityEmail, true
|
||||||
case "phone_number":
|
case "phone_number":
|
||||||
return domain.MessageEntityPhone, true
|
return domain.MessageEntityPhone, true
|
||||||
|
case "bank_card_number":
|
||||||
|
return domain.MessageEntityBankCard, true
|
||||||
|
case "date_time":
|
||||||
|
return domain.MessageEntityFormattedDate, true
|
||||||
default:
|
default:
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
@ -450,8 +465,10 @@ type apiMessageEntity struct {
|
||||||
User *struct {
|
User *struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
} `json:"user"`
|
} `json:"user"`
|
||||||
Language string `json:"language"`
|
Language string `json:"language"`
|
||||||
CustomEmojiID string `json:"custom_emoji_id"`
|
CustomEmojiID string `json:"custom_emoji_id"`
|
||||||
|
UnixTime int `json:"unix_time"`
|
||||||
|
DateTimeFormat string `json:"date_time_format"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type apiInlineKeyboardMarkup struct {
|
type apiInlineKeyboardMarkup struct {
|
||||||
|
|
|
||||||
|
|
@ -402,6 +402,9 @@ func apiMessageEntities(in []domain.MessageEntity, users map[int64]domain.User)
|
||||||
"offset": entity.Offset,
|
"offset": entity.Offset,
|
||||||
"length": entity.Length,
|
"length": entity.Length,
|
||||||
}
|
}
|
||||||
|
if entity.Type == domain.MessageEntityBlockquote && entity.Collapsed {
|
||||||
|
item["type"] = "expandable_blockquote"
|
||||||
|
}
|
||||||
if entity.URL != "" {
|
if entity.URL != "" {
|
||||||
item["url"] = entity.URL
|
item["url"] = entity.URL
|
||||||
}
|
}
|
||||||
|
|
@ -418,6 +421,10 @@ func apiMessageEntities(in []domain.MessageEntity, users map[int64]domain.User)
|
||||||
if entity.DocumentID != 0 {
|
if entity.DocumentID != 0 {
|
||||||
item["custom_emoji_id"] = strconv.FormatInt(entity.DocumentID, 10)
|
item["custom_emoji_id"] = strconv.FormatInt(entity.DocumentID, 10)
|
||||||
}
|
}
|
||||||
|
if entity.Type == domain.MessageEntityFormattedDate {
|
||||||
|
item["unix_time"] = entity.Date
|
||||||
|
item["date_time_format"] = botAPIFormattedDateFormat(entity)
|
||||||
|
}
|
||||||
out = append(out, item)
|
out = append(out, item)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|
@ -461,6 +468,10 @@ func botAPIEntityType(in domain.MessageEntityType) (string, bool) {
|
||||||
return "email", true
|
return "email", true
|
||||||
case domain.MessageEntityPhone:
|
case domain.MessageEntityPhone:
|
||||||
return "phone_number", true
|
return "phone_number", true
|
||||||
|
case domain.MessageEntityBankCard:
|
||||||
|
return "bank_card_number", true
|
||||||
|
case domain.MessageEntityFormattedDate:
|
||||||
|
return "date_time", true
|
||||||
default:
|
default:
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
|
@ -525,16 +524,7 @@ func (h *handler) sendMessage(w http.ResponseWriter, r *http.Request, botID int6
|
||||||
writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID")
|
writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
text := values["text"]
|
text, entities, err := botAPIFormattedTextRaw(values["text"], values["parse_mode"], values["entities"], domain.MaxMessageTextLength, true)
|
||||||
if text == "" || !utf8.ValidString(text) || utf8.RuneCountInString(text) > domain.MaxMessageTextLength {
|
|
||||||
writeAPIError(w, http.StatusBadRequest, "MESSAGE_EMPTY")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(values["parse_mode"]) != "" {
|
|
||||||
writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
entities, err := botAPIMessageEntities(values["entities"])
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
|
|
@ -602,19 +592,11 @@ func (h *handler) sendMedia(w http.ResponseWriter, r *http.Request, botID int64,
|
||||||
writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID")
|
writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(values["parse_mode"]) != "" {
|
caption, entities, err := botAPIFormattedTextRaw(values["caption"], values["parse_mode"], values["caption_entities"], domain.MaxEphemeralCaptionLength, false)
|
||||||
writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
entities, err := botAPIMessageEntities(values["caption_entities"])
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !utf8.ValidString(values["caption"]) || utf8.RuneCountInString(values["caption"]) > domain.MaxEphemeralCaptionLength {
|
|
||||||
writeAPIError(w, http.StatusBadRequest, "MESSAGE_TOO_LONG")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var markup *domain.MessageReplyMarkup
|
var markup *domain.MessageReplyMarkup
|
||||||
if raw := strings.TrimSpace(values["reply_markup"]); raw != "" {
|
if raw := strings.TrimSpace(values["reply_markup"]); raw != "" {
|
||||||
markup, err = replyMarkupFromAPI(json.RawMessage(raw))
|
markup, err = replyMarkupFromAPI(json.RawMessage(raw))
|
||||||
|
|
@ -662,7 +644,7 @@ func (h *handler) sendMedia(w http.ResponseWriter, r *http.Request, botID int64,
|
||||||
message, err := gateway.BotAPISendEphemeral(r.Context(), domain.BotAPIEphemeralSendInput{
|
message, err := gateway.BotAPISendEphemeral(r.Context(), domain.BotAPIEphemeralSendInput{
|
||||||
BotUserID: botID, ChatID: chatID, ReceiverUserID: ephemeral.receiverUserID,
|
BotUserID: botID, ChatID: chatID, ReceiverUserID: ephemeral.receiverUserID,
|
||||||
CallbackQueryID: ephemeral.callbackQueryID, ReplyToEphemeralID: ephemeral.replyToEphemeralID,
|
CallbackQueryID: ephemeral.callbackQueryID, ReplyToEphemeralID: ephemeral.replyToEphemeralID,
|
||||||
TopMessageID: ephemeral.topMessageID, Kind: kind, Text: values["caption"], Entities: entities,
|
TopMessageID: ephemeral.topMessageID, Kind: kind, Text: caption, Entities: entities,
|
||||||
ReplyMarkup: markup, File: file, SecondaryFile: secondary,
|
ReplyMarkup: markup, File: file, SecondaryFile: secondary,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -673,7 +655,7 @@ func (h *handler) sendMedia(w http.ResponseWriter, r *http.Request, botID int64,
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
locationKey, remoteURL, fileName, mimeType, fileBytes := file.LocationKey, file.RemoteURL, file.FileName, file.MimeType, file.Bytes
|
locationKey, remoteURL, fileName, mimeType, fileBytes := file.LocationKey, file.RemoteURL, file.FileName, file.MimeType, file.Bytes
|
||||||
msg, err := h.gateway.BotAPISendMedia(r.Context(), botID, chatID, kind, locationKey, remoteURL, fileName, mimeType, fileBytes, values["caption"], entities, markup, apiBool(values["disable_notification"]), apiInt(values["reply_to_message_id"], 0))
|
msg, err := h.gateway.BotAPISendMedia(r.Context(), botID, chatID, kind, locationKey, remoteURL, fileName, mimeType, fileBytes, caption, entities, markup, apiBool(values["disable_notification"]), apiInt(values["reply_to_message_id"], 0))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
|
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
|
||||||
return
|
return
|
||||||
|
|
@ -713,11 +695,7 @@ func (h *handler) editMessageText(w http.ResponseWriter, r *http.Request, botID
|
||||||
writeAPIError(w, http.StatusBadRequest, "MESSAGE_IDENTIFIER_INVALID")
|
writeAPIError(w, http.StatusBadRequest, "MESSAGE_IDENTIFIER_INVALID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(values["parse_mode"]) != "" {
|
text, entities, err := botAPIFormattedTextRaw(values["text"], values["parse_mode"], values["entities"], domain.MaxMessageTextLength, true)
|
||||||
writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
entities, err := botAPIMessageEntities(values["entities"])
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
|
|
@ -737,7 +715,7 @@ func (h *handler) editMessageText(w http.ResponseWriter, r *http.Request, botID
|
||||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ok, err := h.gateway.BotAPIEditInlineMessageText(r.Context(), botID, inlineID, values["text"], entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"]))
|
ok, err := h.gateway.BotAPIEditInlineMessageText(r.Context(), botID, inlineID, text, entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"]))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
|
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
|
||||||
return
|
return
|
||||||
|
|
@ -745,7 +723,7 @@ func (h *handler) editMessageText(w http.ResponseWriter, r *http.Request, botID
|
||||||
writeAPIOK(w, ok)
|
writeAPIOK(w, ok)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
msg, err := h.gateway.BotAPIEditMessageText(r.Context(), botID, chatID, messageID, values["text"], entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"]))
|
msg, err := h.gateway.BotAPIEditMessageText(r.Context(), botID, chatID, messageID, text, entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"]))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
|
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
|
||||||
return
|
return
|
||||||
|
|
@ -1366,7 +1344,6 @@ func apiErrorDescription(err error) string {
|
||||||
"BOT_INVALID",
|
"BOT_INVALID",
|
||||||
"CHAT_ID_INVALID",
|
"CHAT_ID_INVALID",
|
||||||
"ENTITY_INVALID",
|
"ENTITY_INVALID",
|
||||||
"ENTITY_PARSE_UNSUPPORTED",
|
|
||||||
"ENTITIES_TOO_LONG",
|
"ENTITIES_TOO_LONG",
|
||||||
"ENTITY_BOUNDS_INVALID",
|
"ENTITY_BOUNDS_INVALID",
|
||||||
"ENTITY_TYPE_UNSUPPORTED",
|
"ENTITY_TYPE_UNSUPPORTED",
|
||||||
|
|
|
||||||
|
|
@ -1335,12 +1335,17 @@ type fakeBotAPIGateway struct {
|
||||||
sendMediaFileName string
|
sendMediaFileName string
|
||||||
sendMediaBytes []byte
|
sendMediaBytes []byte
|
||||||
sendMediaCaption string
|
sendMediaCaption string
|
||||||
|
sendMediaEntities []domain.MessageEntity
|
||||||
sendMediaMessage domain.Message
|
sendMediaMessage domain.Message
|
||||||
editCalled bool
|
editCalled bool
|
||||||
|
editText string
|
||||||
|
editEntities []domain.MessageEntity
|
||||||
editSetMarkup bool
|
editSetMarkup bool
|
||||||
editMessage domain.Message
|
editMessage domain.Message
|
||||||
editInlineCalled bool
|
editInlineCalled bool
|
||||||
editInlineID domain.BotInlineMessageID
|
editInlineID domain.BotInlineMessageID
|
||||||
|
editInlineText string
|
||||||
|
editInlineEntities []domain.MessageEntity
|
||||||
deleteCalled bool
|
deleteCalled bool
|
||||||
callbackCalled bool
|
callbackCalled bool
|
||||||
callbackID string
|
callbackID string
|
||||||
|
|
@ -1450,17 +1455,22 @@ func (f *fakeBotAPIGateway) BotAPISendMedia(_ context.Context, botID, chatID int
|
||||||
f.sendMediaFileName = fileName
|
f.sendMediaFileName = fileName
|
||||||
f.sendMediaBytes = append([]byte(nil), fileBytes...)
|
f.sendMediaBytes = append([]byte(nil), fileBytes...)
|
||||||
f.sendMediaCaption = caption
|
f.sendMediaCaption = caption
|
||||||
|
f.sendMediaEntities = append([]domain.MessageEntity(nil), entities...)
|
||||||
return f.sendMediaMessage, nil
|
return f.sendMediaMessage, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeBotAPIGateway) BotAPIEditMessageText(_ context.Context, botID, chatID int64, messageID int, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (domain.Message, error) {
|
func (f *fakeBotAPIGateway) BotAPIEditMessageText(_ context.Context, botID, chatID int64, messageID int, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (domain.Message, error) {
|
||||||
f.editCalled = true
|
f.editCalled = true
|
||||||
|
f.editText = text
|
||||||
|
f.editEntities = append([]domain.MessageEntity(nil), entities...)
|
||||||
f.editSetMarkup = setReplyMarkup
|
f.editSetMarkup = setReplyMarkup
|
||||||
return f.editMessage, nil
|
return f.editMessage, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeBotAPIGateway) BotAPIEditInlineMessageText(_ context.Context, _ int64, inlineMessageID domain.BotInlineMessageID, _ string, _ []domain.MessageEntity, _ bool, _ *domain.MessageReplyMarkup, _ bool) (bool, error) {
|
func (f *fakeBotAPIGateway) BotAPIEditInlineMessageText(_ context.Context, _ int64, inlineMessageID domain.BotInlineMessageID, text string, entities []domain.MessageEntity, _ bool, _ *domain.MessageReplyMarkup, _ bool) (bool, error) {
|
||||||
f.editInlineCalled, f.editInlineID = true, inlineMessageID
|
f.editInlineCalled, f.editInlineID = true, inlineMessageID
|
||||||
|
f.editInlineText = text
|
||||||
|
f.editInlineEntities = append([]domain.MessageEntity(nil), entities...)
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue