feat: sync Bot API gateway support
This commit is contained in:
parent
9a501f900a
commit
4c0cc2b7a7
44 changed files with 4609 additions and 49 deletions
49
cmd/bots/ptbecho/README.md
Normal file
49
cmd/bots/ptbecho/README.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# python-telegram-bot echo demo
|
||||
|
||||
This demo uses the normal `python-telegram-bot` API and only swaps the Bot API
|
||||
base URLs to telesrv.
|
||||
|
||||
```powershell
|
||||
python -m pip install python-telegram-bot
|
||||
$env:TELESRV_BOT_TOKEN = "<bot_id>:<secret>"
|
||||
python .\cmd\bots\ptbecho\echo.py `
|
||||
--base-url http://127.0.0.1:8081/bot `
|
||||
--base-file-url http://127.0.0.1:8081/file/bot
|
||||
```
|
||||
|
||||
In a group with BotFather privacy enabled, send a command such as:
|
||||
|
||||
```text
|
||||
/ping hello from group
|
||||
```
|
||||
|
||||
主动发送一条消息并退出:
|
||||
|
||||
```powershell
|
||||
python .\cmd\bots\ptbecho\echo.py `
|
||||
--send-only `
|
||||
--send-chat-id -1000000000002 `
|
||||
--send-text "hello from python-telegram-bot"
|
||||
```
|
||||
|
||||
长轮询 echo 启动后立即主动发送一条消息:
|
||||
|
||||
```powershell
|
||||
python .\cmd\bots\ptbecho\echo.py `
|
||||
--send-chat-id -1000000000002 `
|
||||
--send-text "ptbecho is online"
|
||||
```
|
||||
|
||||
可选参数:
|
||||
|
||||
- `--send-count N`:连续主动发送 N 条。
|
||||
- `--send-interval SEC`:连续发送之间的间隔。
|
||||
- `TELESRV_BOT_DEMO_CHAT_ID` / `TELESRV_BOT_DEMO_SEND_TEXT`:主动发送参数的环境变量形式。
|
||||
|
||||
本地超级群 chat id 使用 Bot API 形式 `-100<channel_id>`;例如 channel id 为
|
||||
`2` 时是 `-1000000000002`。
|
||||
|
||||
Implemented telesrv Bot API surface for this demo: `getMe`, `getUpdates`,
|
||||
`deleteWebhook`, `sendMessage`, and file URL configuration. The wider gateway
|
||||
also has basic `sendPhoto`, `sendDocument`, `editMessageText`, `deleteMessage`,
|
||||
`answerCallbackQuery`, `getFile`, and `/file/bot...` support.
|
||||
227
cmd/bots/ptbecho/echo.py
Normal file
227
cmd/bots/ptbecho/echo.py
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
#!/usr/bin/env python3
|
||||
"""python-telegram-bot echo demo for telesrv Bot API.
|
||||
|
||||
This is a normal python-telegram-bot program. The only telesrv-specific part is
|
||||
the custom base_url/base_file_url pair.
|
||||
|
||||
Example:
|
||||
|
||||
python cmd/bots/ptbecho/echo.py \
|
||||
--token "1780243224:..." \
|
||||
--base-url http://127.0.0.1:8081/bot \
|
||||
--base-file-url http://127.0.0.1:8081/file/bot
|
||||
|
||||
With BotFather privacy enabled, supergroup bots only receive commands, replies
|
||||
to the bot, mentions, or messages otherwise visible to bots. In a group, send:
|
||||
|
||||
/ping hello
|
||||
|
||||
The same program can also send proactive messages:
|
||||
|
||||
python cmd/bots/ptbecho/echo.py \
|
||||
--token "1780243224:..." \
|
||||
--send-only \
|
||||
--send-chat-id -1000000000002 \
|
||||
--send-text "hello from python-telegram-bot"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
from typing import Iterable
|
||||
|
||||
from telegram import Bot, Update
|
||||
from telegram.constants import ChatAction
|
||||
from telegram.ext import (
|
||||
Application,
|
||||
ApplicationBuilder,
|
||||
CommandHandler,
|
||||
ContextTypes,
|
||||
MessageHandler,
|
||||
filters,
|
||||
)
|
||||
|
||||
|
||||
LOG = logging.getLogger("ptbecho")
|
||||
|
||||
|
||||
def env_int(name: str) -> int | None:
|
||||
raw = os.getenv(name)
|
||||
if raw is None or raw == "":
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError as exc:
|
||||
raise SystemExit(f"{name} must be an integer, got {raw!r}") from exc
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Echo bot demo using python-telegram-bot against telesrv.")
|
||||
parser.add_argument("--token", default=os.getenv("TELESRV_BOT_TOKEN"), help="Bot token, defaults to TELESRV_BOT_TOKEN")
|
||||
parser.add_argument("--base-url", default=os.getenv("TELESRV_BOT_API_BASE_URL", "http://127.0.0.1:8081/bot"))
|
||||
parser.add_argument("--base-file-url", default=os.getenv("TELESRV_BOT_API_BASE_FILE_URL", "http://127.0.0.1:8081/file/bot"))
|
||||
parser.add_argument("--prefix", default="echo: ")
|
||||
parser.add_argument("--drop-pending", action="store_true", help="Drop pending updates before polling")
|
||||
parser.add_argument("--timeout", type=int, default=30, help="getUpdates long-poll timeout seconds")
|
||||
parser.add_argument(
|
||||
"--send-chat-id",
|
||||
type=int,
|
||||
default=env_int("TELESRV_BOT_DEMO_CHAT_ID"),
|
||||
help="Chat id for proactive sendMessage, defaults to TELESRV_BOT_DEMO_CHAT_ID",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--send-text",
|
||||
default=os.getenv("TELESRV_BOT_DEMO_SEND_TEXT", ""),
|
||||
help="Text for proactive sendMessage, defaults to TELESRV_BOT_DEMO_SEND_TEXT",
|
||||
)
|
||||
parser.add_argument("--send-count", type=int, default=1, help="Number of proactive messages to send")
|
||||
parser.add_argument("--send-interval", type=float, default=1.0, help="Seconds between proactive sends")
|
||||
parser.add_argument("--send-only", action="store_true", help="Send proactive messages and exit without polling")
|
||||
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_count < 1:
|
||||
parser.error("--send-count must be >= 1")
|
||||
if args.send_interval < 0:
|
||||
parser.error("--send-interval must be >= 0")
|
||||
wants_send = args.send_only or bool(args.send_text)
|
||||
if wants_send and args.send_chat_id is None:
|
||||
parser.error("--send-chat-id is required when --send-text or --send-only is used")
|
||||
if args.send_only and not args.send_text:
|
||||
parser.error("--send-only requires --send-text")
|
||||
return args
|
||||
|
||||
|
||||
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
if update.effective_message is None:
|
||||
return
|
||||
await update.effective_message.reply_text("send /ping <text> in a group, or any text in private chat")
|
||||
|
||||
|
||||
async def ping(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
await echo(update, context)
|
||||
|
||||
|
||||
async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
if update.effective_message is None or update.effective_chat is None:
|
||||
return
|
||||
text = update.effective_message.text or update.effective_message.caption or ""
|
||||
if not text:
|
||||
return
|
||||
prefix = context.application.bot_data.get("prefix", "echo: ")
|
||||
await context.bot.send_chat_action(chat_id=update.effective_chat.id, action=ChatAction.TYPING)
|
||||
sent = await update.effective_message.reply_text(prefix + text)
|
||||
LOG.info(
|
||||
"echoed update_id=%s chat_id=%s message_id=%s sent_message_id=%s text=%r",
|
||||
update.update_id,
|
||||
update.effective_chat.id,
|
||||
update.effective_message.message_id,
|
||||
sent.message_id,
|
||||
text,
|
||||
)
|
||||
|
||||
|
||||
async def send_active_messages(bot: Bot, chat_id: int, text: str, count: int, interval: float) -> None:
|
||||
for index in range(count):
|
||||
sent = await bot.send_message(chat_id=chat_id, text=text)
|
||||
LOG.info(
|
||||
"sent proactive index=%s/%s chat_id=%s message_id=%s text=%r",
|
||||
index + 1,
|
||||
count,
|
||||
chat_id,
|
||||
sent.message_id,
|
||||
text,
|
||||
)
|
||||
if index + 1 < count:
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
|
||||
async def send_on_startup(app: Application) -> None:
|
||||
chat_id = app.bot_data.get("send_chat_id")
|
||||
text = app.bot_data.get("send_text")
|
||||
if chat_id is None or not text:
|
||||
return
|
||||
await send_active_messages(
|
||||
app.bot,
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
count=int(app.bot_data.get("send_count", 1)),
|
||||
interval=float(app.bot_data.get("send_interval", 1.0)),
|
||||
)
|
||||
|
||||
|
||||
async def post_init(app: Application) -> None:
|
||||
me = await app.bot.get_me()
|
||||
LOG.info("listening as @%s (%s), bot_api=%s", me.username or me.id, me.id, app.bot_data["base_url"])
|
||||
if app.bot_data.get("send_chat_id") is not None and app.bot_data.get("send_text"):
|
||||
app.create_task(send_on_startup(app), name="ptbecho-proactive-send")
|
||||
|
||||
|
||||
def build_app(args: argparse.Namespace) -> Application:
|
||||
app = (
|
||||
ApplicationBuilder()
|
||||
.token(args.token)
|
||||
.base_url(args.base_url)
|
||||
.base_file_url(args.base_file_url)
|
||||
.post_init(post_init)
|
||||
.build()
|
||||
)
|
||||
app.bot_data["prefix"] = args.prefix
|
||||
app.bot_data["base_url"] = args.base_url
|
||||
app.bot_data["send_chat_id"] = args.send_chat_id
|
||||
app.bot_data["send_text"] = args.send_text
|
||||
app.bot_data["send_count"] = args.send_count
|
||||
app.bot_data["send_interval"] = args.send_interval
|
||||
app.add_handler(CommandHandler("start", start))
|
||||
app.add_handler(CommandHandler("ping", ping))
|
||||
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, echo))
|
||||
return app
|
||||
|
||||
|
||||
async def run_send_only(args: argparse.Namespace) -> None:
|
||||
bot = Bot(token=args.token, base_url=args.base_url, base_file_url=args.base_file_url)
|
||||
me = await bot.get_me()
|
||||
LOG.info("authenticated as @%s (%s), bot_api=%s", me.username or me.id, me.id, args.base_url)
|
||||
await send_active_messages(
|
||||
bot,
|
||||
chat_id=args.send_chat_id,
|
||||
text=args.send_text,
|
||||
count=args.send_count,
|
||||
interval=args.send_interval,
|
||||
)
|
||||
|
||||
|
||||
def stop_signals() -> Iterable[int]:
|
||||
if os.name == "nt":
|
||||
return (signal.SIGINT, signal.SIGTERM)
|
||||
return (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)
|
||||
|
||||
|
||||
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",
|
||||
)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
if args.send_only:
|
||||
asyncio.run(run_send_only(args))
|
||||
return 0
|
||||
|
||||
app = build_app(args)
|
||||
app.run_polling(
|
||||
allowed_updates=["message", "edited_message"],
|
||||
drop_pending_updates=args.drop_pending,
|
||||
poll_interval=0.0,
|
||||
timeout=args.timeout,
|
||||
stop_signals=stop_signals(),
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -318,6 +318,7 @@ func run(logger *zap.Logger) error {
|
|||
readModelVersionStore := storepkg.NewCachedReadModelVersionStore(postgres.NewReadModelVersionStore(pool), 0, 0)
|
||||
dispatchOutboxStore := postgres.NewDispatchOutboxStore(pool, postgres.WithLeaseTimeout(cfg.OutboxLeaseTimeout))
|
||||
bootstrapUpdateStore := postgres.NewBootstrapUpdateJobStore(pool)
|
||||
botAPIUpdateStore := postgres.NewBotAPIUpdateStore(pool)
|
||||
boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool))
|
||||
channelIDAllocator := redisstore.NewChannelIDAllocator(rdb, postgres.NewChannelIDCounterSource(pool))
|
||||
channelMessageIDAllocator := redisstore.NewChannelMessageIDAllocator(rdb, postgres.NewChannelMessageIDCounterSource(pool))
|
||||
|
|
@ -432,7 +433,7 @@ func run(logger *zap.Logger) error {
|
|||
cfg.UpdateEventRetention,
|
||||
cfg.RetentionInterval,
|
||||
cfg.RetentionBatch,
|
||||
).Run(ctx)
|
||||
).WithBotAPIUpdateRetention(botAPIUpdateStore, cfg.BotAPIUpdateRetention).Run(ctx)
|
||||
go filesapp.NewUploadPartGCWorker(filesService, logger.Named("files").Named("upload_gc"),
|
||||
cfg.UploadPartTTL,
|
||||
cfg.UploadPartGCInterval,
|
||||
|
|
@ -672,6 +673,7 @@ func run(logger *zap.Logger) error {
|
|||
Users: usersService,
|
||||
Updates: updatesService,
|
||||
BootstrapUpdates: bootstrapUpdateStore,
|
||||
BotAPIUpdates: botAPIUpdateStore,
|
||||
Contacts: contactsService,
|
||||
Dialogs: dialogsService,
|
||||
Chatlists: chatlistsService,
|
||||
|
|
@ -708,6 +710,7 @@ func run(logger *zap.Logger) error {
|
|||
ProfilePhotos: cachedPhotos,
|
||||
Stories: router,
|
||||
ChannelFullBots: router,
|
||||
ChannelBotMembers: channelsService,
|
||||
ChannelMediaCounts: channelsService,
|
||||
PrivateMediaCounts: messagesService,
|
||||
RPCProjections: router,
|
||||
|
|
@ -744,11 +747,12 @@ func run(logger *zap.Logger) error {
|
|||
go rpc.NewPhoneExpiryDispatcher(router, logger.Named("rpc").Named("phone-expiry"), cfg.CallExpiryInterval).Run(ctx)
|
||||
go rpc.NewGroupCallSweepDispatcher(router, logger.Named("rpc").Named("groupcall-sweep"), cfg.GroupCallSweepInterval, cfg.GroupCallCheckTTL).Run(ctx)
|
||||
go router.RunChannelFanout(ctx)
|
||||
go router.RunBotAPIEnqueue(ctx)
|
||||
go router.RunPresenceSweeper(ctx, time.Minute)
|
||||
go activeSessions.RunPendingSweeper(ctx, time.Minute)
|
||||
go router.RunPremiumSweeper(ctx, cfg.PremiumSweepInterval, cfg.PremiumSweepBatch)
|
||||
go router.RunInlineBotPushSubscriber(ctx)
|
||||
if _, err := botapi.Start(ctx, cfg.BotAPIAddr, botsService, usersService, router, logger.Named("botapi")); err != nil {
|
||||
if _, err := botapi.Start(ctx, cfg.BotAPIAddr, botsService, usersService, router, router, logger.Named("botapi")); err != nil {
|
||||
return fmt.Errorf("start bot api: %w", err)
|
||||
}
|
||||
if _, err := adminapi.Start(ctx, adminapi.Config{Addr: cfg.AdminAPIAddr, Token: cfg.AdminAPIToken}, adminService, logger.Named("adminapi")); err != nil {
|
||||
|
|
@ -757,6 +761,7 @@ func run(logger *zap.Logger) error {
|
|||
if _, err := stickerlinks.Start(ctx, stickerlinks.Config{
|
||||
Addr: cfg.PublicLinkWebAddr,
|
||||
PublicBaseURL: cfg.PublicBaseURL,
|
||||
Users: userStore,
|
||||
}, filesService, logger.Named("stickerlinks")); err != nil {
|
||||
return fmt.Errorf("start sticker links: %w", err)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue