teacher_directory.py 47 KB

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