diff --git a/bot.py b/bot.py
new file mode 100644
index 0000000..9e058e0
--- /dev/null
+++ b/bot.py
@@ -0,0 +1,172 @@
+import re
+import tomllib
+import urllib.parse
+from pathlib import Path
+
+import requests
+from fastapi import FastAPI
+from hypy_utils import ensure_dir
+from hypy_utils.logging_utils import setup_logger
+from starlette.responses import HTMLResponse
+from starlette.staticfiles import StaticFiles
+from telegram import Update
+from telegram.ext import Application, CommandHandler, ContextTypes
+
+import db
+import utils
+from utils import gen_sha, CONFIG
+
+
+BOT_TOKEN = CONFIG["token"]
+BOT_NAME = CONFIG["name"]
+
+app = FastAPI()
+logger = setup_logger()
+bot = Application.builder().token(BOT_TOKEN).build()
+
+data_dir = Path(__file__).parent / "data"
+channels_dir = ensure_dir(data_dir / "channels")
+
+validating = set()
+
+
+def user_info(update: Update):
+ return (f"{update.message.from_user.id} {update.message.from_user.username or ''} "
+ f"{update.message.from_user.first_name or ''} {update.message.from_user.last_name or ''}")
+
+
+async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
+ logger.info(f"/start from {user_info(update)}")
+ await update.message.reply_text("🌳🌳🌳")
+
+
+def channel_html(channel: str):
+ if (channels_dir / f"{channel}.html").exists():
+ return (channels_dir / f"{channel}.html").read_text()
+ return requests.get(f"https://t.me/{channel}").text
+
+
+async def plant(update: Update, context: ContextTypes.DEFAULT_TYPE):
+ if update.message.chat.type != "private":
+ return
+
+ logger.info(f"/leaf from {user_info(update)}: {update.message.text}")
+
+ args, uid = context.args, update.message.from_user.id
+ if len(args) != 2:
+ logger.info(f"> Invalid args.")
+ return await update.message.reply_text("用法是 /leaf <上级频道名> <你的频道名> 哦~")
+
+ parent, channel = args
+ parent, channel = parent.strip("<>{} @"), channel.strip("<>{} @")
+ sha = gen_sha(channel, uid, parent)
+
+ # Check if channel only contains 0-9, a-z, A-Z and _
+ if not re.match(r"^[0-9a-zA-Z_]+$", channel):
+ logger.info(f"> Invalid channel name.")
+ return await update.message.reply_text("没有找到这个频道... 只有公开的频道可以参与,以及需要输入频道的 @用户名,不是显示名哦~")
+
+ if not db.channel_info(parent):
+ logger.info(f"> Parent channel not found.")
+ return await update.message.reply_text("上级频道还不在树上... 是不是打错了 qwq")
+
+ # 检查在不在树上
+ if db.channel_info(channel):
+ logger.info(f"> Channel already exists.")
+ return await update.message.reply_text(f"这个频道已经在树上了哦~ https://tree.aza.moe/c/{channel}")
+
+ # 检查验证码
+ text = channel_html(channel)
+ if 'noindex, nofollow' in text:
+ logger.info(f"> Channel noindex, nofollow.")
+ return await update.message.reply_text("没有找到这个频道... 只有公开的频道可以参与,以及需要输入频道的 @用户名,不是显示名哦~")
+
+ info = utils.extract_meta_tags(text)
+ if sha in text:
+ logger.info(f"> 🌿 Registering channel {channel} with parent {parent}.")
+ height = db.register(channel, info.title, parent)
+ await update.message.reply_text(f"""频道 {channel} 上树成功!把下面这条转发到频道里吧~""".strip())
+ url_enc = urllib.parse.quote_plus(f"https://tree.aza.moe/c/{channel}")
+ return await update.message.reply_html(f"""
+今天是植树节,想试试和大家一起种一颗 tgcn 频道树 🌳 qwq
+
+这里是 {info.title},是 @{parent} 的树枝 🌿 在频道树的第 {height + 1} 层哦~
+
+(如果你也有公开频道,想成为这个频道的树叶的话,就去给 @tgtreebot 发送 /leaf {channel} {{你的频道名}} 吧! > <) \u200e
+""".strip())
+
+ if sha not in validating:
+ logger.info(f"> Channel not validated, asking for validation.")
+ await update.message.reply_text(f"""
+好耶!
+
+不过上树之前,为了防止被滥用,需要先验证一下你是 {channel} 的管理员...
+
+请编辑频道简介加入验证码 {sha} 再重新执行这条指令吧~(加在哪里都可以的 > < 验证完就可以删掉)
+""".strip())
+ validating.add(sha)
+ else:
+ logger.info(f"> Channel not validated, asking again for validation.")
+ await update.message.reply_text("(看了一下好像频道信息还没有更新的样子... 确定加上了吗?再试试吧)")
+
+
+# Add handlers
+bot.add_handler(CommandHandler("start", start))
+bot.add_handler(CommandHandler("leaf", plant))
+
+
+@app.on_event("startup")
+async def startup_event():
+ """Starts the bot."""
+ logger.info("Starting bot...")
+ await bot.initialize()
+ await bot.start()
+ await bot.updater.start_polling()
+
+
+@app.on_event("shutdown")
+async def shutdown_event():
+ """Stops the bot on shutdown."""
+ logger.info("Stopping bot...")
+ await bot.updater.stop()
+ await bot.stop()
+ await bot.shutdown()
+
+
+layout_html = (Path(__file__).parent / "public" / "layout.html").read_text()
+fmt_html = lambda x: layout_html.replace("{{CONTENT}}", x).replace("\n", "")
+
+
+@app.get("/c/{channel}", response_class=HTMLResponse)
+def channel_info(channel: str):
+ info = db.channel_info(channel)
+
+ if not info:
+ return fmt_html(f"""
+
频道 @{channel} 不在树上哦~
+ """) + + leaf_txt = '树枝' if info.children else '树叶' + + return fmt_html(f""" +这里是频道 @{channel} - {info.name},{ + f'在频道树的第 {info.height + 1} 层,是 @{info.parent} (🔗) 的{leaf_txt}哦~' + if info.parent else "是树根哦~" + }
+ {f"""下面这些是这个频道的树枝:
+(如果你也有公开频道,想成为这个频道的树叶的话,就去给 @tgtreebot 发送 /leaf {channel} {{你的频道名}} 吧! > <)