teacher_directory.py 48 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371
  1. from __future__ import annotations
  2. from datetime import UTC, datetime, timedelta
  3. from html import escape
  4. from secrets import token_urlsafe
  5. from typing import Any
  6. from pyrogram import StopPropagation, filters
  7. from pyrogram.enums import ChatMembersFilter, ChatMemberStatus, ParseMode
  8. from pyrogram.types import (
  9. CallbackQuery,
  10. ChatMemberUpdated,
  11. InlineKeyboardButton,
  12. InlineKeyboardMarkup,
  13. KeyboardButton,
  14. Message,
  15. ReplyKeyboardMarkup,
  16. )
  17. from wbb import BOT_PROFILE_ID, BOT_USERNAME, app, log
  18. from wbb.services.directory import (
  19. DirectoryServiceError,
  20. admin_change_teacher_state,
  21. admin_decide_teacher,
  22. admin_source_chat_ids,
  23. apply_as_teacher,
  24. change_own_teacher_state,
  25. clear_user_location,
  26. directory_for_user,
  27. format_distance,
  28. observe_directory_user,
  29. observe_group_member,
  30. profile_summary,
  31. public_teacher,
  32. require_platform_membership,
  33. require_teacher_admin,
  34. start_presence_sweeper,
  35. status_value,
  36. update_user_location,
  37. verify_platform_membership,
  38. )
  39. from wbb.services.message_cleanup import schedule_group_cleanup
  40. from wbb.utils.dbdirectory import (
  41. APPLICATION_APPROVED,
  42. APPLICATION_PENDING,
  43. APPLICATION_REJECTED,
  44. APPLICATION_REVOKED,
  45. get_directory_location,
  46. get_directory_profile,
  47. list_directory_profiles,
  48. list_teacher_applications,
  49. )
  50. __MODULE__ = "技师与附近"
  51. __HELP__ = """/directory - 打开附近技师菜单。
  52. /nearby - 选择范围并查找附近技师。
  53. /teacher_rank - 按距离查看全部上榜技师。
  54. /teacher_apply - 申请成为技师。
  55. /teacher_online - 技师上线。
  56. /teacher_offline - 技师下线。
  57. /teacher_list - 技师上榜。
  58. /teacher_unlist - 技师下榜。"""
  59. APPLICATION_LABELS = {
  60. "none": "未申请",
  61. APPLICATION_PENDING: "技师申请待审批",
  62. APPLICATION_APPROVED: "技师",
  63. APPLICATION_REJECTED: "技师申请已拒绝",
  64. APPLICATION_REVOKED: "技师资格已撤销",
  65. }
  66. TEACHER_ACTION_LABELS = {
  67. "list": "上榜",
  68. "unlist": "下榜",
  69. "online": "上线",
  70. "offline": "下线",
  71. }
  72. BUTTON_NEARBY = "附近技师"
  73. BUTTON_RANK = "技师榜单"
  74. BUTTON_APPLY = "申请成为技师"
  75. BUTTON_APPROVAL = "技师审批"
  76. BUTTON_STATUS = "我的状态"
  77. BUTTON_LIST = "上榜"
  78. BUTTON_UNLIST = "下榜"
  79. BUTTON_ONLINE = "🟢 上线"
  80. BUTTON_OFFLINE = "🔴 下线"
  81. BUTTON_TELEGRAM_LOCATION = "Telegram 定位"
  82. BUTTON_MANUAL_LOCATION = "手动选择位置"
  83. BUTTON_CLEAR_LOCATION = "清除位置"
  84. _admin_flows: dict[int, dict[str, Any]] = {}
  85. _admin_confirmations: dict[str, dict[str, Any]] = {}
  86. _location_flows: dict[int, datetime] = {}
  87. _source_chat_contexts: dict[int, dict[str, Any]] = {}
  88. def _display_name(profile: dict[str, Any]) -> str:
  89. return str(profile.get("display_name") or f"用户 {profile['user_id']}")
  90. def _directory_keyboard(
  91. profile: dict[str, Any] | None,
  92. *,
  93. is_admin: bool,
  94. ) -> ReplyKeyboardMarkup:
  95. status = str((profile or {}).get("application_status") or "none")
  96. rows: list[list[KeyboardButton]] = [
  97. [KeyboardButton(BUTTON_TELEGRAM_LOCATION, request_location=True)],
  98. [KeyboardButton(BUTTON_MANUAL_LOCATION)],
  99. [KeyboardButton(BUTTON_NEARBY), KeyboardButton(BUTTON_RANK)],
  100. ]
  101. if status == APPLICATION_APPROVED:
  102. rows.extend(
  103. [
  104. [
  105. KeyboardButton(
  106. BUTTON_UNLIST if profile and profile.get("listed") else BUTTON_LIST
  107. ),
  108. KeyboardButton(
  109. BUTTON_OFFLINE
  110. if profile and profile.get("online")
  111. else BUTTON_ONLINE
  112. ),
  113. ],
  114. [KeyboardButton(BUTTON_STATUS)],
  115. ]
  116. )
  117. else:
  118. rows.append(
  119. [
  120. KeyboardButton(
  121. "技师申请审核中"
  122. if status == APPLICATION_PENDING
  123. else BUTTON_APPLY
  124. )
  125. ]
  126. )
  127. rows.append([KeyboardButton(BUTTON_CLEAR_LOCATION)])
  128. if is_admin:
  129. rows.append([KeyboardButton(BUTTON_APPROVAL)])
  130. return ReplyKeyboardMarkup(
  131. rows,
  132. resize_keyboard=True,
  133. is_persistent=True,
  134. selective=True,
  135. placeholder="选择附近技师服务或输入消息",
  136. )
  137. def _group_directory_keyboard(
  138. profile: dict[str, Any] | None,
  139. *,
  140. chat_id: int,
  141. is_admin: bool,
  142. ) -> InlineKeyboardMarkup:
  143. status = str((profile or {}).get("application_status") or "none")
  144. rows: list[list[InlineKeyboardButton]] = [
  145. [
  146. InlineKeyboardButton(
  147. "更新位置",
  148. url=f"https://t.me/{BOT_USERNAME}?start=directory_{chat_id}",
  149. ),
  150. InlineKeyboardButton(BUTTON_NEARBY, callback_data="dir:radius"),
  151. InlineKeyboardButton(BUTTON_RANK, callback_data="dir:list:all:1"),
  152. ]
  153. ]
  154. if status == APPLICATION_APPROVED:
  155. rows.append(
  156. [
  157. InlineKeyboardButton(
  158. BUTTON_UNLIST if profile and profile.get("listed") else BUTTON_LIST,
  159. callback_data=(
  160. "dir:self:unlist"
  161. if profile and profile.get("listed")
  162. else "dir:self:list"
  163. ),
  164. ),
  165. InlineKeyboardButton(
  166. BUTTON_OFFLINE if profile and profile.get("online") else BUTTON_ONLINE,
  167. callback_data=(
  168. "dir:self:offline"
  169. if profile and profile.get("online")
  170. else "dir:self:online"
  171. ),
  172. ),
  173. InlineKeyboardButton(BUTTON_STATUS, callback_data="dir:status"),
  174. ]
  175. )
  176. elif status == APPLICATION_PENDING:
  177. rows.append(
  178. [InlineKeyboardButton("技师申请审核中", callback_data="dir:status")]
  179. )
  180. else:
  181. rows.append(
  182. [InlineKeyboardButton(BUTTON_APPLY, callback_data="dir:apply")]
  183. )
  184. if is_admin:
  185. rows.append(
  186. [InlineKeyboardButton(BUTTON_APPROVAL, callback_data="dir:admin:pending:1")]
  187. )
  188. return InlineKeyboardMarkup(rows)
  189. def _presence_label(online: bool) -> str:
  190. return "🟢 在线" if online else "🔴 离线"
  191. def _menu_text(summary: dict[str, Any]) -> str:
  192. lines = [
  193. "<b>附近技师</b>",
  194. f"位置:{'已更新' if summary['has_location'] else '尚未更新'}",
  195. ]
  196. status = summary["application_status"]
  197. if status == APPLICATION_APPROVED:
  198. lines.extend(
  199. [
  200. "身份:技师",
  201. f"榜单:{'已上榜' if summary['listed'] else '未上榜'}",
  202. f"状态:{_presence_label(summary['online'])}",
  203. ]
  204. )
  205. elif status == APPLICATION_PENDING:
  206. lines.append("技师申请:待审批")
  207. elif status == APPLICATION_REJECTED:
  208. lines.append("技师申请:未通过")
  209. elif status == APPLICATION_REVOKED:
  210. lines.append("技师资格:已撤销")
  211. return "\n".join(lines)
  212. async def send_directory_menu(
  213. message: Message,
  214. *,
  215. source_chat_id: int | None = None,
  216. ) -> None:
  217. if not message.from_user:
  218. return
  219. if source_chat_id is not None:
  220. try:
  221. member = await app.get_chat_member(source_chat_id, message.from_user.id)
  222. if getattr(member, "status", None) not in {
  223. ChatMemberStatus.OWNER,
  224. ChatMemberStatus.ADMINISTRATOR,
  225. ChatMemberStatus.MEMBER,
  226. ChatMemberStatus.RESTRICTED,
  227. }:
  228. return await message.reply_text("你已不是该群成员,无法打开菜单。")
  229. source_chat_title = ""
  230. try:
  231. source_chat = await app.get_chat(source_chat_id)
  232. source_chat_title = str(getattr(source_chat, "title", "") or "")
  233. except Exception:
  234. pass
  235. await observe_group_member(
  236. chat_id=source_chat_id,
  237. chat_title=source_chat_title,
  238. user=message.from_user,
  239. status=status_value(member.status),
  240. active=True,
  241. verified=True,
  242. )
  243. _source_chat_contexts[int(message.from_user.id)] = {
  244. "chat_id": int(source_chat_id),
  245. "chat_title": source_chat_title,
  246. "expires_at": datetime.now(UTC) + timedelta(minutes=30),
  247. }
  248. except Exception:
  249. return await message.reply_text("暂时无法核验来源群身份,请稍后重试。")
  250. try:
  251. await require_platform_membership(int(message.from_user.id))
  252. except DirectoryServiceError as exc:
  253. await message.reply_text(str(exc))
  254. return
  255. profile = await observe_directory_user(message.from_user)
  256. location = await get_directory_location(int(message.from_user.id))
  257. admin_result = await verify_platform_membership(
  258. user_id=int(message.from_user.id),
  259. require_admin=True,
  260. )
  261. summary = profile_summary(profile, location)
  262. await message.reply_text(
  263. _menu_text(summary),
  264. parse_mode=ParseMode.HTML,
  265. reply_markup=_directory_keyboard(
  266. {**profile, "online": summary["online"]},
  267. is_admin=bool(admin_result.get("allowed")),
  268. ),
  269. )
  270. async def _verify_group_member(message: Message) -> bool:
  271. if not message.from_user:
  272. return False
  273. try:
  274. member = await app.get_chat_member(message.chat.id, message.from_user.id)
  275. except Exception:
  276. await message.reply_text("暂时无法验证你的群成员身份,请稍后重试。")
  277. return False
  278. active = member.status in {
  279. ChatMemberStatus.OWNER,
  280. ChatMemberStatus.ADMINISTRATOR,
  281. ChatMemberStatus.MEMBER,
  282. ChatMemberStatus.RESTRICTED,
  283. }
  284. await observe_group_member(
  285. chat_id=message.chat.id,
  286. chat_title=message.chat.title or "",
  287. user=message.from_user,
  288. status=status_value(member.status),
  289. active=active,
  290. verified=True,
  291. )
  292. if not active:
  293. await message.reply_text("该功能仅对当前群成员开放。")
  294. return active
  295. @app.on_message(filters.command("directory"))
  296. async def directory_command(_, message: Message):
  297. if message.chat.type.value == "private":
  298. return await send_directory_menu(message)
  299. if not await _verify_group_member(message):
  300. return
  301. profile = await observe_directory_user(message.from_user)
  302. summary = profile_summary(
  303. profile,
  304. await get_directory_location(message.from_user.id),
  305. )
  306. member = await app.get_chat_member(message.chat.id, message.from_user.id)
  307. response = await message.reply_text(
  308. _menu_text(summary),
  309. parse_mode=ParseMode.HTML,
  310. reply_markup=_group_directory_keyboard(
  311. {**profile, "online": summary["online"]},
  312. chat_id=message.chat.id,
  313. is_admin=member.status in {
  314. ChatMemberStatus.OWNER,
  315. ChatMemberStatus.ADMINISTRATOR,
  316. },
  317. ),
  318. )
  319. schedule_group_cleanup(message, response)
  320. @app.on_message(filters.command("nearby"))
  321. async def nearby_command(_, message: Message):
  322. if message.chat.type.value != "private" and not await _verify_group_member(message):
  323. return
  324. await _show_radius_picker(message)
  325. @app.on_message(filters.command("teacher_rank"))
  326. async def teacher_rank_command(_, message: Message):
  327. if message.chat.type.value != "private" and not await _verify_group_member(message):
  328. return
  329. await _show_teacher_results(message, radius_km=None, page=1)
  330. @app.on_message(filters.command("teacher_apply"))
  331. async def teacher_apply_command(_, message: Message):
  332. if message.chat.type.value != "private" and not await _verify_group_member(message):
  333. return
  334. await _apply_teacher(message)
  335. async def _self_state_command(
  336. target: Message | CallbackQuery,
  337. action: str,
  338. ) -> None:
  339. if not target.from_user:
  340. return
  341. message = target.message if isinstance(target, CallbackQuery) else target
  342. is_group = int(message.chat.id) < 0
  343. if is_group:
  344. try:
  345. member = await app.get_chat_member(message.chat.id, target.from_user.id)
  346. except Exception:
  347. if isinstance(target, CallbackQuery):
  348. return await target.answer("暂时无法核验群成员身份。", show_alert=True)
  349. return await target.reply_text("暂时无法核验群成员身份。")
  350. if member.status not in {
  351. ChatMemberStatus.OWNER,
  352. ChatMemberStatus.ADMINISTRATOR,
  353. ChatMemberStatus.MEMBER,
  354. ChatMemberStatus.RESTRICTED,
  355. }:
  356. if isinstance(target, CallbackQuery):
  357. return await target.answer("该功能仅对当前群成员开放。", show_alert=True)
  358. return await target.reply_text("该功能仅对当前群成员开放。")
  359. await observe_group_member(
  360. chat_id=message.chat.id,
  361. chat_title=message.chat.title or "",
  362. user=target.from_user,
  363. status=status_value(member.status),
  364. active=True,
  365. verified=True,
  366. )
  367. try:
  368. profile, _ = await change_own_teacher_state(
  369. user=target.from_user,
  370. action=action,
  371. source="telegram_group" if is_group else "telegram_private",
  372. idempotency_key=(
  373. f"teacher-self:{message.chat.id}:{message.id}:"
  374. f"{target.from_user.id}:{action}"
  375. if is_group
  376. else None
  377. ),
  378. chat_id=message.chat.id if is_group else None,
  379. )
  380. response = await message.reply_text(
  381. f"已{TEACHER_ACTION_LABELS[action]}。"
  382. + (
  383. "本次上线状态将在 24 小时后自动失效。"
  384. if action == "online"
  385. else ""
  386. )
  387. )
  388. if isinstance(target, CallbackQuery):
  389. await target.answer("状态已更新。")
  390. if not is_group and isinstance(target, Message):
  391. await _refresh_keyboard(target, profile)
  392. else:
  393. schedule_group_cleanup(message, response)
  394. except DirectoryServiceError as exc:
  395. if isinstance(target, CallbackQuery):
  396. await target.answer(str(exc), show_alert=True)
  397. else:
  398. response = await target.reply_text(str(exc))
  399. schedule_group_cleanup(target, response)
  400. @app.on_message(filters.command("teacher_online"))
  401. async def teacher_online_command(_, message: Message):
  402. if message.chat.type.value != "private" and not await _verify_group_member(message):
  403. return
  404. await _self_state_command(message, "online")
  405. @app.on_message(filters.command("teacher_offline"))
  406. async def teacher_offline_command(_, message: Message):
  407. if message.chat.type.value != "private" and not await _verify_group_member(message):
  408. return
  409. await _self_state_command(message, "offline")
  410. @app.on_message(filters.command("teacher_list"))
  411. async def teacher_list_command(_, message: Message):
  412. if message.chat.type.value != "private" and not await _verify_group_member(message):
  413. return
  414. await _self_state_command(message, "list")
  415. @app.on_message(filters.command("teacher_unlist"))
  416. async def teacher_unlist_command(_, message: Message):
  417. if message.chat.type.value != "private" and not await _verify_group_member(message):
  418. return
  419. await _self_state_command(message, "unlist")
  420. @app.on_message(filters.private & filters.location, group=12)
  421. async def directory_location_message(_, message: Message):
  422. if not message.from_user or not message.location:
  423. return
  424. try:
  425. await update_user_location(
  426. user=message.from_user,
  427. longitude=message.location.longitude,
  428. latitude=message.location.latitude,
  429. source="telegram_location",
  430. )
  431. except DirectoryServiceError as exc:
  432. return await message.reply_text(str(exc))
  433. _location_flows.pop(message.from_user.id, None)
  434. await message.reply_text("Telegram 位置已更新,可立即查找附近技师。")
  435. await _refresh_keyboard(message, await get_directory_profile(message.from_user.id))
  436. async def _start_manual_location(message: Message) -> None:
  437. if not message.from_user:
  438. return
  439. _location_flows[message.from_user.id] = datetime.now(UTC) + timedelta(minutes=10)
  440. await message.reply_text(
  441. "请直接发送 Telegram 位置并在地图上选择地点;如果 Telegram 无法定位,"
  442. "也可以输入“纬度,经度”,例如:39.9042,116.4074。\n"
  443. "发送 /cancel 可取消。"
  444. )
  445. async def _refresh_keyboard(
  446. message: Message,
  447. profile: dict[str, Any] | None,
  448. ) -> None:
  449. if not message.from_user:
  450. return
  451. admin_result = await verify_platform_membership(
  452. user_id=int(message.from_user.id),
  453. require_admin=True,
  454. )
  455. summary = profile_summary(
  456. profile or {"user_id": message.from_user.id},
  457. await get_directory_location(message.from_user.id),
  458. )
  459. await message.reply_text(
  460. "菜单已更新。",
  461. reply_markup=_directory_keyboard(
  462. {**(profile or {}), "online": summary["online"]},
  463. is_admin=bool(admin_result.get("allowed")),
  464. ),
  465. )
  466. def _admin_mention(user: Any) -> str:
  467. name = " ".join(
  468. value
  469. for value in (
  470. str(getattr(user, "first_name", "") or "").strip(),
  471. str(getattr(user, "last_name", "") or "").strip(),
  472. )
  473. if value
  474. ) or str(getattr(user, "username", "") or "管理员")
  475. return f'<a href="tg://user?id={int(user.id)}">{escape(name)}</a>'
  476. async def _notify_application_admins(profile: dict[str, Any]) -> None:
  477. chat_id = profile.get("application_source_chat_id")
  478. if not chat_id:
  479. return
  480. admins: list[Any] = []
  481. try:
  482. async for member in app.get_chat_members(
  483. int(chat_id),
  484. filter=ChatMembersFilter.ADMINISTRATORS,
  485. ):
  486. user = getattr(member, "user", None)
  487. if user and not getattr(user, "is_bot", False):
  488. admins.append(user)
  489. except Exception as exc:
  490. log.error(f"获取技师申请来源群管理员失败:{exc}")
  491. mentions = " ".join(_admin_mention(user) for user in admins[:20])
  492. applicant = escape(_display_name(profile))
  493. text = (
  494. f"{mentions}\n" if mentions else ""
  495. ) + (
  496. f"有新的技师申请待审批:<b>{applicant}</b>\n"
  497. f"用户 ID:<code>{profile['user_id']}</code>"
  498. )
  499. markup = InlineKeyboardMarkup(
  500. [
  501. [
  502. InlineKeyboardButton(
  503. "查看并审批",
  504. callback_data=f"dir:admin:detail:{profile['user_id']}",
  505. )
  506. ]
  507. ]
  508. )
  509. try:
  510. await app.send_message(
  511. int(chat_id),
  512. text,
  513. parse_mode=ParseMode.HTML,
  514. reply_markup=markup,
  515. )
  516. except Exception as exc:
  517. log.error(f"发送技师申请群提醒失败:{exc}")
  518. private_text = (
  519. f"群“{escape(profile.get('application_source_chat_title') or str(chat_id))}”"
  520. f"有新的技师申请:<b>{applicant}</b>。"
  521. )
  522. for admin in admins:
  523. if int(admin.id) == int(profile["user_id"]):
  524. continue
  525. try:
  526. await app.send_message(
  527. int(admin.id),
  528. private_text,
  529. parse_mode=ParseMode.HTML,
  530. reply_markup=markup,
  531. )
  532. except Exception:
  533. pass
  534. async def _apply_teacher(target: Message | CallbackQuery) -> None:
  535. if not target.from_user:
  536. return
  537. message = target.message if isinstance(target, CallbackQuery) else target
  538. is_group = int(message.chat.id) < 0
  539. source_context = None
  540. if not is_group:
  541. source_context = _source_chat_contexts.get(int(target.from_user.id))
  542. if (
  543. source_context
  544. and source_context["expires_at"] < datetime.now(UTC)
  545. ):
  546. _source_chat_contexts.pop(int(target.from_user.id), None)
  547. source_context = None
  548. if is_group:
  549. try:
  550. member = await app.get_chat_member(message.chat.id, target.from_user.id)
  551. except Exception:
  552. if isinstance(target, CallbackQuery):
  553. return await target.answer("暂时无法核验群成员身份。", show_alert=True)
  554. return await target.reply_text("暂时无法核验群成员身份。")
  555. if member.status not in {
  556. ChatMemberStatus.OWNER,
  557. ChatMemberStatus.ADMINISTRATOR,
  558. ChatMemberStatus.MEMBER,
  559. ChatMemberStatus.RESTRICTED,
  560. }:
  561. if isinstance(target, CallbackQuery):
  562. return await target.answer("该功能仅对当前群成员开放。", show_alert=True)
  563. return await target.reply_text("该功能仅对当前群成员开放。")
  564. await observe_group_member(
  565. chat_id=message.chat.id,
  566. chat_title=message.chat.title or "",
  567. user=target.from_user,
  568. status=status_value(member.status),
  569. active=True,
  570. verified=True,
  571. )
  572. try:
  573. profile, created = await apply_as_teacher(
  574. user=target.from_user,
  575. chat_id=(
  576. int(message.chat.id)
  577. if is_group
  578. else int(source_context["chat_id"])
  579. if source_context
  580. else None
  581. ),
  582. chat_title=(
  583. str(message.chat.title or "")
  584. if is_group
  585. else str(source_context.get("chat_title") or "")
  586. if source_context
  587. else ""
  588. ),
  589. source="telegram_group" if is_group else "telegram_private",
  590. )
  591. except DirectoryServiceError as exc:
  592. if isinstance(target, CallbackQuery):
  593. return await target.answer(str(exc), show_alert=True)
  594. return await target.reply_text(str(exc))
  595. response_text = (
  596. "技师申请已提交,来源群管理员已收到审批提醒。"
  597. if created
  598. else "技师申请正在等待管理员审批。"
  599. )
  600. response = await message.reply_text(response_text)
  601. if isinstance(target, CallbackQuery):
  602. await target.answer("申请已提交。" if created else "申请正在审批中。")
  603. if created:
  604. _source_chat_contexts.pop(int(target.from_user.id), None)
  605. await _notify_application_admins(profile)
  606. if is_group:
  607. schedule_group_cleanup(message, response)
  608. elif isinstance(target, Message):
  609. await _refresh_keyboard(target, profile)
  610. async def _show_status(target: Message | CallbackQuery) -> None:
  611. if not target.from_user:
  612. return
  613. message = target.message if isinstance(target, CallbackQuery) else target
  614. profile = await observe_directory_user(target.from_user)
  615. summary = profile_summary(
  616. profile,
  617. await get_directory_location(target.from_user.id),
  618. )
  619. if summary["application_status"] != APPLICATION_APPROVED:
  620. status_text = {
  621. APPLICATION_PENDING: "技师申请正在等待管理员审批。",
  622. APPLICATION_REJECTED: "技师申请未通过。",
  623. APPLICATION_REVOKED: "技师资格已被撤销。",
  624. }.get(summary["application_status"], "你尚未申请成为技师。")
  625. response = await message.reply_text(status_text)
  626. if isinstance(target, CallbackQuery):
  627. await target.answer()
  628. schedule_group_cleanup(message, response)
  629. return
  630. lines = [
  631. "<b>我的技师状态</b>",
  632. f"位置:{'已更新' if summary['has_location'] else '尚未更新'}",
  633. f"榜单:{'已上榜' if summary['listed'] else '未上榜'}",
  634. f"上下线:{_presence_label(summary['online'])}",
  635. (
  636. "自动下线:"
  637. + escape(
  638. summary["online_until"].astimezone(UTC).strftime(
  639. "%Y-%m-%d %H:%M UTC"
  640. )
  641. )
  642. if summary["online"] and summary["online_until"]
  643. else ""
  644. ),
  645. ]
  646. response = await message.reply_text(
  647. "\n".join(line for line in lines if line),
  648. parse_mode=ParseMode.HTML,
  649. )
  650. if isinstance(target, CallbackQuery):
  651. await target.answer()
  652. schedule_group_cleanup(message, response)
  653. async def _show_radius_picker(target: Message | CallbackQuery) -> None:
  654. markup = InlineKeyboardMarkup(
  655. [
  656. [
  657. InlineKeyboardButton(
  658. f"{radius} 公里",
  659. callback_data=f"dir:list:{radius}:1",
  660. )
  661. for radius in (5, 10)
  662. ],
  663. [
  664. InlineKeyboardButton(
  665. f"{radius} 公里",
  666. callback_data=f"dir:list:{radius}:1",
  667. )
  668. for radius in (20, 50)
  669. ],
  670. ]
  671. )
  672. if isinstance(target, CallbackQuery):
  673. await target.message.edit_text("请选择附近技师的搜索范围:", reply_markup=markup)
  674. else:
  675. response = await target.reply_text(
  676. "请选择附近技师的搜索范围:",
  677. reply_markup=markup,
  678. )
  679. schedule_group_cleanup(target, response)
  680. def _teacher_result_markup(
  681. teachers: list[dict[str, Any]],
  682. *,
  683. radius_key: str,
  684. page: int,
  685. total: int,
  686. ) -> InlineKeyboardMarkup:
  687. rows: list[list[InlineKeyboardButton]] = []
  688. teacher_buttons: list[InlineKeyboardButton] = []
  689. for item in teachers:
  690. username = str(item.get("username") or "")
  691. status = "🟢" if item["online"] else "🔴"
  692. teacher_buttons.append(
  693. InlineKeyboardButton(
  694. f"{status} {item['display_name']}"[:18],
  695. url=f"https://t.me/{username}",
  696. )
  697. )
  698. rows.extend(
  699. teacher_buttons[index : index + 3]
  700. for index in range(0, len(teacher_buttons), 3)
  701. )
  702. navigation: list[InlineKeyboardButton] = []
  703. if page > 1:
  704. navigation.append(
  705. InlineKeyboardButton(
  706. "上一页",
  707. callback_data=f"dir:list:{radius_key}:{page - 1}",
  708. )
  709. )
  710. if page * 10 < total:
  711. navigation.append(
  712. InlineKeyboardButton(
  713. "下一页",
  714. callback_data=f"dir:list:{radius_key}:{page + 1}",
  715. )
  716. )
  717. if navigation:
  718. rows.append(navigation)
  719. rows.append([InlineKeyboardButton("切换附近范围", callback_data="dir:radius")])
  720. return InlineKeyboardMarkup(rows)
  721. async def _show_teacher_results(
  722. target: Message | CallbackQuery,
  723. *,
  724. radius_km: int | None,
  725. page: int,
  726. ) -> None:
  727. user = target.from_user
  728. try:
  729. raw_items, total = await directory_for_user(
  730. user=user,
  731. radius_km=radius_km,
  732. page=page,
  733. page_size=10,
  734. )
  735. except DirectoryServiceError as exc:
  736. if isinstance(target, CallbackQuery):
  737. return await target.answer(str(exc), show_alert=True)
  738. return await target.reply_text(str(exc))
  739. teachers = [public_teacher(item) for item in raw_items]
  740. title = (
  741. f"<b>{radius_km} 公里内的技师</b>"
  742. if radius_km is not None
  743. else "<b>技师榜单</b>"
  744. )
  745. lines = [title, f"共 {total} 位已上榜技师,按距离排序。"]
  746. for index, item in enumerate(teachers, start=(page - 1) * 10 + 1):
  747. lines.append(
  748. f"\n{index}. <b>{escape(item['display_name'])}</b> "
  749. f"(@{escape(item['username'])})\n"
  750. f"状态:{_presence_label(item['online'])} | "
  751. f"距离:{format_distance(item['distance_meters'])}"
  752. )
  753. if not teachers:
  754. lines.append("\n当前范围内没有已上榜技师。")
  755. markup = _teacher_result_markup(
  756. teachers,
  757. radius_key=str(radius_km) if radius_km is not None else "all",
  758. page=page,
  759. total=total,
  760. )
  761. if isinstance(target, CallbackQuery):
  762. await target.message.edit_text(
  763. "\n".join(lines),
  764. parse_mode=ParseMode.HTML,
  765. disable_web_page_preview=True,
  766. reply_markup=markup,
  767. )
  768. await target.answer()
  769. else:
  770. response = await target.reply_text(
  771. "\n".join(lines),
  772. parse_mode=ParseMode.HTML,
  773. disable_web_page_preview=True,
  774. reply_markup=markup,
  775. )
  776. schedule_group_cleanup(target, response)
  777. async def _show_admin_applications(
  778. target: Message | CallbackQuery,
  779. *,
  780. page: int = 1,
  781. ) -> None:
  782. try:
  783. source_chat_ids = await admin_source_chat_ids(target.from_user)
  784. except DirectoryServiceError as exc:
  785. if isinstance(target, CallbackQuery):
  786. return await target.answer(str(exc), show_alert=True)
  787. return await target.reply_text(str(exc))
  788. items, total = await list_teacher_applications(
  789. status=APPLICATION_PENDING,
  790. source_bot_id=str(BOT_PROFILE_ID)
  791. if source_chat_ids is not None
  792. else "",
  793. source_chat_ids=source_chat_ids,
  794. page=page,
  795. page_size=5,
  796. )
  797. lines = ["<b>技师申请审批</b>", f"待审批:{total}"]
  798. rows: list[list[InlineKeyboardButton]] = []
  799. for item in items:
  800. lines.append(
  801. f"\n{escape(_display_name(item))} (@{escape(item.get('username') or '')})"
  802. f"\n用户 ID:<code>{item['user_id']}</code>"
  803. )
  804. rows.append(
  805. [
  806. InlineKeyboardButton(
  807. f"查看 {_display_name(item)[:18]}",
  808. callback_data=f"dir:admin:detail:{item['user_id']}",
  809. )
  810. ]
  811. )
  812. if page > 1:
  813. rows.append(
  814. [
  815. InlineKeyboardButton(
  816. "上一页",
  817. callback_data=f"dir:admin:pending:{page - 1}",
  818. )
  819. ]
  820. )
  821. if page * 5 < total:
  822. rows.append(
  823. [
  824. InlineKeyboardButton(
  825. "下一页",
  826. callback_data=f"dir:admin:pending:{page + 1}",
  827. )
  828. ]
  829. )
  830. rows.append(
  831. [InlineKeyboardButton("已批准技师", callback_data="dir:admin:teachers:1")]
  832. )
  833. markup = InlineKeyboardMarkup(rows)
  834. if isinstance(target, CallbackQuery):
  835. await target.message.edit_text(
  836. "\n".join(lines),
  837. parse_mode=ParseMode.HTML,
  838. reply_markup=markup,
  839. )
  840. await target.answer()
  841. else:
  842. await target.reply_text(
  843. "\n".join(lines),
  844. parse_mode=ParseMode.HTML,
  845. reply_markup=markup,
  846. )
  847. async def _show_admin_teachers(target: CallbackQuery, page: int) -> None:
  848. try:
  849. source_chat_ids = await admin_source_chat_ids(target.from_user)
  850. except DirectoryServiceError as exc:
  851. return await target.answer(str(exc), show_alert=True)
  852. items, total = await list_directory_profiles(
  853. application_status=APPLICATION_APPROVED,
  854. source_bot_id=str(BOT_PROFILE_ID)
  855. if source_chat_ids is not None
  856. else "",
  857. source_chat_ids=source_chat_ids,
  858. page=page,
  859. page_size=8,
  860. )
  861. rows = [
  862. [
  863. InlineKeyboardButton(
  864. _display_name(item)[:28],
  865. callback_data=f"dir:admin:detail:{item['user_id']}",
  866. )
  867. ]
  868. for item in items
  869. ]
  870. navigation: list[InlineKeyboardButton] = []
  871. if page > 1:
  872. navigation.append(
  873. InlineKeyboardButton(
  874. "上一页",
  875. callback_data=f"dir:admin:teachers:{page - 1}",
  876. )
  877. )
  878. if page * 8 < total:
  879. navigation.append(
  880. InlineKeyboardButton(
  881. "下一页",
  882. callback_data=f"dir:admin:teachers:{page + 1}",
  883. )
  884. )
  885. if navigation:
  886. rows.append(navigation)
  887. rows.append([InlineKeyboardButton("待审批", callback_data="dir:admin:pending:1")])
  888. await target.message.edit_text(
  889. f"<b>已批准技师</b>\n共 {total} 人",
  890. parse_mode=ParseMode.HTML,
  891. reply_markup=InlineKeyboardMarkup(rows),
  892. )
  893. await target.answer()
  894. async def _show_admin_teacher_detail(target: CallbackQuery, user_id: int) -> None:
  895. try:
  896. await require_teacher_admin(target.from_user, user_id)
  897. except DirectoryServiceError as exc:
  898. return await target.answer(str(exc), show_alert=True)
  899. profile = await get_directory_profile(user_id)
  900. if not profile:
  901. return await target.answer("未找到该成员资料。", show_alert=True)
  902. summary = profile_summary(profile, await get_directory_location(user_id))
  903. lines = [
  904. f"<b>{escape(summary['display_name'])}</b>",
  905. f"用户名:@{escape(summary.get('username') or '未设置')}",
  906. f"用户 ID:<code>{user_id}</code>",
  907. f"申请:{escape(APPLICATION_LABELS.get(summary['application_status'], '未申请'))}",
  908. f"位置:{'已更新' if summary['has_location'] else '尚未更新'}",
  909. f"榜单:{'已上榜' if summary['listed'] else '未上榜'}",
  910. f"状态:{_presence_label(summary['online'])}",
  911. ]
  912. rows: list[list[InlineKeyboardButton]] = []
  913. if summary["application_status"] == APPLICATION_PENDING:
  914. rows.append(
  915. [
  916. InlineKeyboardButton(
  917. "批准",
  918. callback_data=f"dir:admin:approveask:{user_id}",
  919. ),
  920. InlineKeyboardButton(
  921. "拒绝",
  922. callback_data=f"dir:admin:flow:reject:{user_id}",
  923. ),
  924. ]
  925. )
  926. if summary["application_status"] == APPLICATION_APPROVED:
  927. rows.extend(
  928. [
  929. [
  930. InlineKeyboardButton(
  931. "强制下榜" if summary["listed"] else "强制上榜",
  932. callback_data=(
  933. f"dir:admin:flow:"
  934. f"{'unlist' if summary['listed'] else 'list'}:{user_id}"
  935. ),
  936. ),
  937. InlineKeyboardButton(
  938. "强制下线" if summary["online"] else "强制上线",
  939. callback_data=(
  940. f"dir:admin:flow:"
  941. f"{'offline' if summary['online'] else 'online'}:{user_id}"
  942. ),
  943. ),
  944. ],
  945. [
  946. InlineKeyboardButton(
  947. "撤销技师资格",
  948. callback_data=f"dir:admin:flow:revoke:{user_id}",
  949. )
  950. ],
  951. ]
  952. )
  953. rows.append([InlineKeyboardButton("返回审批", callback_data="dir:admin:pending:1")])
  954. await target.message.edit_text(
  955. "\n".join(lines),
  956. parse_mode=ParseMode.HTML,
  957. reply_markup=InlineKeyboardMarkup(rows),
  958. )
  959. try:
  960. await target.answer()
  961. except Exception:
  962. pass
  963. async def _start_admin_reason_flow(
  964. target: CallbackQuery,
  965. *,
  966. action: str,
  967. user_id: int,
  968. ) -> None:
  969. try:
  970. await require_teacher_admin(target.from_user, user_id)
  971. except DirectoryServiceError as exc:
  972. return await target.answer(str(exc), show_alert=True)
  973. _admin_flows[int(target.from_user.id)] = {
  974. "action": action,
  975. "user_id": int(user_id),
  976. "expires_at": datetime.now(UTC) + timedelta(minutes=10),
  977. }
  978. await target.message.reply_text(
  979. f"请输入“{TEACHER_ACTION_LABELS.get(action, action)}”的原因,"
  980. "发送 /cancel 可取消。"
  981. )
  982. await target.answer()
  983. async def _confirm_admin_action(target: CallbackQuery, token: str) -> None:
  984. confirmation = _admin_confirmations.get(token)
  985. if not confirmation or confirmation["actor_id"] != target.from_user.id:
  986. return await target.answer("确认已失效,请重新操作。", show_alert=True)
  987. if confirmation["expires_at"] < datetime.now(UTC):
  988. _admin_confirmations.pop(token, None)
  989. return await target.answer("确认已超时,请重新操作。", show_alert=True)
  990. action = confirmation["action"]
  991. user_id = int(confirmation["user_id"])
  992. reason = str(confirmation.get("reason") or "")
  993. try:
  994. if action in {"reject", "revoke"}:
  995. profile = await admin_decide_teacher(
  996. actor=target.from_user,
  997. user_id=user_id,
  998. action=action,
  999. reason=reason,
  1000. )
  1001. else:
  1002. profile = await admin_change_teacher_state(
  1003. actor=target.from_user,
  1004. user_id=user_id,
  1005. action=action,
  1006. reason=reason,
  1007. )
  1008. with_reason = f"\n原因:{reason}" if reason else ""
  1009. with_suppressed_notification = (
  1010. "技师申请已被拒绝" if action == "reject" else
  1011. "技师资格已被撤销" if action == "revoke" else
  1012. f"技师状态已被管理员修改为“{TEACHER_ACTION_LABELS[action]}”"
  1013. )
  1014. try:
  1015. await app.send_message(
  1016. user_id,
  1017. f"{with_suppressed_notification}。{with_reason}",
  1018. )
  1019. except Exception:
  1020. pass
  1021. _admin_confirmations.pop(token, None)
  1022. await target.answer("操作成功。")
  1023. await _show_admin_teacher_detail(target, int(profile["user_id"]))
  1024. except DirectoryServiceError as exc:
  1025. await target.answer(str(exc), show_alert=True)
  1026. @app.on_callback_query(filters.regex(r"^dir:"))
  1027. async def directory_callback(_, query: CallbackQuery):
  1028. parts = str(query.data).split(":")
  1029. try:
  1030. if parts[1] == "apply":
  1031. return await _apply_teacher(query)
  1032. if parts[1] == "status":
  1033. return await _show_status(query)
  1034. if parts[1] == "self":
  1035. return await _self_state_command(query, parts[2])
  1036. if parts[1] == "radius":
  1037. return await _show_radius_picker(query)
  1038. if parts[1] == "list":
  1039. radius = None if parts[2] == "all" else int(parts[2])
  1040. return await _show_teacher_results(
  1041. query,
  1042. radius_km=radius,
  1043. page=int(parts[3]),
  1044. )
  1045. if parts[1] == "clearask":
  1046. return await query.message.edit_text(
  1047. "清除位置后会自动下榜并下线,确认继续吗?",
  1048. reply_markup=InlineKeyboardMarkup(
  1049. [
  1050. [
  1051. InlineKeyboardButton(
  1052. "确认清除",
  1053. callback_data="dir:clear",
  1054. ),
  1055. InlineKeyboardButton("取消", callback_data="dir:cancel"),
  1056. ]
  1057. ]
  1058. ),
  1059. )
  1060. if parts[1] == "clear":
  1061. await clear_user_location(user=query.from_user)
  1062. await query.message.edit_text("位置已清除,技师状态已同步下线并下榜。")
  1063. return await query.answer("已清除。")
  1064. if parts[1] == "cancel":
  1065. await query.message.edit_text("已取消。")
  1066. return await query.answer()
  1067. if parts[1] == "admin":
  1068. if parts[2] == "pending":
  1069. return await _show_admin_applications(query, page=int(parts[3]))
  1070. if parts[2] == "teachers":
  1071. return await _show_admin_teachers(query, int(parts[3]))
  1072. if parts[2] == "detail":
  1073. return await _show_admin_teacher_detail(query, int(parts[3]))
  1074. if parts[2] == "approveask":
  1075. user_id = int(parts[3])
  1076. return await query.message.edit_text(
  1077. "批准后技师身份将在全部群和 Bot 中生效,确认继续吗?",
  1078. reply_markup=InlineKeyboardMarkup(
  1079. [
  1080. [
  1081. InlineKeyboardButton(
  1082. "确认批准",
  1083. callback_data=f"dir:admin:approve:{user_id}",
  1084. ),
  1085. InlineKeyboardButton(
  1086. "取消",
  1087. callback_data=f"dir:admin:detail:{user_id}",
  1088. ),
  1089. ]
  1090. ]
  1091. ),
  1092. )
  1093. if parts[2] == "approve":
  1094. profile = await admin_decide_teacher(
  1095. actor=query.from_user,
  1096. user_id=int(parts[3]),
  1097. action="approve",
  1098. )
  1099. try:
  1100. await app.send_message(
  1101. int(profile["user_id"]),
  1102. "你的技师申请已通过,可以在菜单中自助上榜。",
  1103. )
  1104. except Exception:
  1105. pass
  1106. await query.answer("已批准。")
  1107. return await _show_admin_teacher_detail(
  1108. query,
  1109. int(profile["user_id"]),
  1110. )
  1111. if parts[2] == "flow":
  1112. return await _start_admin_reason_flow(
  1113. query,
  1114. action=parts[3],
  1115. user_id=int(parts[4]),
  1116. )
  1117. if parts[2] == "confirm":
  1118. return await _confirm_admin_action(query, parts[3])
  1119. except (DirectoryServiceError, ValueError) as exc:
  1120. return await query.answer(str(exc), show_alert=True)
  1121. except Exception as exc:
  1122. log.error(f"技师目录回调失败:{exc}")
  1123. return await query.answer("操作失败,请稍后重试。", show_alert=True)
  1124. @app.on_message(filters.command("cancel") & filters.private, group=-20)
  1125. async def cancel_directory_flow(_, message: Message):
  1126. if not message.from_user:
  1127. return
  1128. admin_flow = _admin_flows.pop(message.from_user.id, None)
  1129. location_flow = _location_flows.pop(message.from_user.id, None)
  1130. if not admin_flow and not location_flow:
  1131. return
  1132. await message.reply_text("已取消当前操作。")
  1133. raise StopPropagation
  1134. @app.on_message(filters.private & filters.text, group=13)
  1135. async def directory_private_text(_, message: Message):
  1136. if not message.from_user:
  1137. return
  1138. flow = _admin_flows.get(message.from_user.id)
  1139. if flow:
  1140. if flow["expires_at"] < datetime.now(UTC):
  1141. _admin_flows.pop(message.from_user.id, None)
  1142. return await message.reply_text("操作已超时,请重新打开技师审批。")
  1143. reason = str(message.text or "").strip()
  1144. if not reason:
  1145. return await message.reply_text("原因不能为空,请重新输入。")
  1146. token = token_urlsafe(8)
  1147. _admin_confirmations[token] = {
  1148. **flow,
  1149. "actor_id": message.from_user.id,
  1150. "reason": reason,
  1151. "expires_at": datetime.now(UTC) + timedelta(minutes=10),
  1152. }
  1153. _admin_flows.pop(message.from_user.id, None)
  1154. return await message.reply_text(
  1155. f"确认执行“{TEACHER_ACTION_LABELS.get(flow['action'], flow['action'])}”吗?"
  1156. f"\n用户 ID:{flow['user_id']}\n原因:{escape(reason)}",
  1157. reply_markup=InlineKeyboardMarkup(
  1158. [
  1159. [
  1160. InlineKeyboardButton(
  1161. "确认执行",
  1162. callback_data=f"dir:admin:confirm:{token}",
  1163. ),
  1164. InlineKeyboardButton("取消", callback_data="dir:cancel"),
  1165. ]
  1166. ]
  1167. ),
  1168. )
  1169. location_expires_at = _location_flows.get(message.from_user.id)
  1170. if location_expires_at:
  1171. if location_expires_at < datetime.now(UTC):
  1172. _location_flows.pop(message.from_user.id, None)
  1173. return await message.reply_text("位置输入已超时,请重新选择手动位置。")
  1174. raw = str(message.text or "").strip().replace(",", ",")
  1175. values = [value.strip() for value in raw.split(",")]
  1176. if len(values) != 2:
  1177. return await message.reply_text(
  1178. "格式不正确,请输入“纬度,经度”,例如:39.9042,116.4074。"
  1179. )
  1180. try:
  1181. latitude, longitude = (float(value) for value in values)
  1182. await update_user_location(
  1183. user=message.from_user,
  1184. longitude=longitude,
  1185. latitude=latitude,
  1186. source="manual_coordinates",
  1187. )
  1188. except (DirectoryServiceError, ValueError) as exc:
  1189. return await message.reply_text(f"位置保存失败:{exc}")
  1190. _location_flows.pop(message.from_user.id, None)
  1191. await message.reply_text("手动位置已更新,可立即查找附近技师。")
  1192. return await _refresh_keyboard(
  1193. message,
  1194. await get_directory_profile(message.from_user.id),
  1195. )
  1196. text = str(message.text or "").strip()
  1197. if text == BUTTON_MANUAL_LOCATION:
  1198. return await _start_manual_location(message)
  1199. if text == BUTTON_NEARBY:
  1200. return await _show_radius_picker(message)
  1201. if text == BUTTON_RANK:
  1202. return await _show_teacher_results(message, radius_km=None, page=1)
  1203. if text == BUTTON_APPLY:
  1204. return await _apply_teacher(message)
  1205. if text == BUTTON_STATUS:
  1206. return await _show_status(message)
  1207. if text == "技师申请审核中":
  1208. return await _show_status(message)
  1209. if text == BUTTON_CLEAR_LOCATION:
  1210. return await message.reply_text(
  1211. "清除位置后会自动下榜并下线,确认继续吗?",
  1212. reply_markup=InlineKeyboardMarkup(
  1213. [
  1214. [
  1215. InlineKeyboardButton("确认清除", callback_data="dir:clear"),
  1216. InlineKeyboardButton("取消", callback_data="dir:cancel"),
  1217. ]
  1218. ]
  1219. ),
  1220. )
  1221. if text == BUTTON_APPROVAL:
  1222. return await _show_admin_applications(message)
  1223. action = {
  1224. BUTTON_LIST: "list",
  1225. BUTTON_UNLIST: "unlist",
  1226. BUTTON_ONLINE: "online",
  1227. BUTTON_OFFLINE: "offline",
  1228. "上线": "online",
  1229. "下线": "offline",
  1230. }.get(text)
  1231. if action:
  1232. return await _self_state_command(message, action)
  1233. @app.on_message(filters.group & filters.text, group=13)
  1234. async def teacher_presence_group_text(_, message: Message):
  1235. if not message.from_user:
  1236. return
  1237. text = str(message.text or "").strip()
  1238. action = {
  1239. "上线": "online",
  1240. "下线": "offline",
  1241. BUTTON_ONLINE: "online",
  1242. BUTTON_OFFLINE: "offline",
  1243. }.get(text)
  1244. if not action:
  1245. return
  1246. profile = await get_directory_profile(message.from_user.id)
  1247. if not profile or profile.get("application_status") != APPLICATION_APPROVED:
  1248. return
  1249. if not await _verify_group_member(message):
  1250. return
  1251. try:
  1252. _, applied = await change_own_teacher_state(
  1253. user=message.from_user,
  1254. action=action,
  1255. source="telegram_group",
  1256. idempotency_key=(
  1257. f"teacher-presence:{message.chat.id}:{message.id}:"
  1258. f"{message.from_user.id}"
  1259. ),
  1260. chat_id=message.chat.id,
  1261. )
  1262. if applied:
  1263. response = await message.reply_text(
  1264. f"全平台状态已切换为{_presence_label(action == 'online')}。"
  1265. + ("24 小时后将自动下线。" if action == "online" else "")
  1266. )
  1267. schedule_group_cleanup(message, response)
  1268. except DirectoryServiceError as exc:
  1269. response = await message.reply_text(str(exc))
  1270. schedule_group_cleanup(message, response)
  1271. @app.on_chat_member_updated(group=13)
  1272. async def directory_chat_member_updated(_, update: ChatMemberUpdated):
  1273. member = update.new_chat_member or update.old_chat_member
  1274. if not member or not member.user or member.user.is_bot:
  1275. return
  1276. status = update.new_chat_member.status if update.new_chat_member else ChatMemberStatus.LEFT
  1277. active = status in {
  1278. ChatMemberStatus.OWNER,
  1279. ChatMemberStatus.ADMINISTRATOR,
  1280. ChatMemberStatus.MEMBER,
  1281. ChatMemberStatus.RESTRICTED,
  1282. }
  1283. await observe_group_member(
  1284. chat_id=update.chat.id,
  1285. chat_title=update.chat.title or "",
  1286. user=member.user,
  1287. status=status_value(status),
  1288. active=active,
  1289. verified=True,
  1290. )
  1291. start_presence_sweeper()