teacher_directory.py 47 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346
  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(
  299. filters.command("directory")
  300. | (filters.regex(r"^技师$") & filters.group)
  301. )
  302. async def directory_command(_, message: Message):
  303. if message.chat.type.value == "private":
  304. return await send_directory_menu(message)
  305. if not await _verify_group_member(message):
  306. return
  307. profile = await observe_directory_user(message.from_user)
  308. summary = profile_summary(profile, None)
  309. member = await app.get_chat_member(message.chat.id, message.from_user.id)
  310. response = await message.reply_text(
  311. _menu_text(summary),
  312. parse_mode=ParseMode.HTML,
  313. reply_markup=_group_directory_keyboard(
  314. {**profile, "online": summary["online"]},
  315. chat_id=message.chat.id,
  316. is_admin=member.status in {
  317. ChatMemberStatus.OWNER,
  318. ChatMemberStatus.ADMINISTRATOR,
  319. },
  320. ),
  321. )
  322. schedule_group_cleanup(message, response)
  323. @app.on_message(
  324. filters.command("nearby")
  325. | (filters.regex(r"^附近技师$") & filters.group)
  326. )
  327. async def nearby_command(_, message: Message):
  328. if message.chat.type.value != "private" and not await _verify_group_member(message):
  329. return
  330. await _open_service_directory(message)
  331. @app.on_message(
  332. filters.command("teacher_rank")
  333. | (filters.regex(r"^技师榜单$") & filters.group)
  334. )
  335. async def teacher_rank_command(_, message: Message):
  336. if message.chat.type.value != "private" and not await _verify_group_member(message):
  337. return
  338. await _open_service_directory(message)
  339. @app.on_message(filters.command("teacher_apply"))
  340. async def teacher_apply_command(_, message: Message):
  341. if message.chat.type.value != "private" and not await _verify_group_member(message):
  342. return
  343. await _apply_teacher(message)
  344. async def _self_state_command(
  345. target: Message | CallbackQuery,
  346. action: str,
  347. ) -> None:
  348. if not target.from_user:
  349. return
  350. message = target.message if isinstance(target, CallbackQuery) else target
  351. is_group = int(message.chat.id) < 0
  352. if is_group:
  353. try:
  354. member = await app.get_chat_member(message.chat.id, target.from_user.id)
  355. except Exception:
  356. if isinstance(target, CallbackQuery):
  357. return await target.answer("暂时无法核验群成员身份。", show_alert=True)
  358. return await target.reply_text("暂时无法核验群成员身份。")
  359. if member.status not in {
  360. ChatMemberStatus.OWNER,
  361. ChatMemberStatus.ADMINISTRATOR,
  362. ChatMemberStatus.MEMBER,
  363. ChatMemberStatus.RESTRICTED,
  364. }:
  365. if isinstance(target, CallbackQuery):
  366. return await target.answer("该功能仅对当前群成员开放。", show_alert=True)
  367. return await target.reply_text("该功能仅对当前群成员开放。")
  368. await observe_group_member(
  369. chat_id=message.chat.id,
  370. chat_title=message.chat.title or "",
  371. user=target.from_user,
  372. status=status_value(member.status),
  373. active=True,
  374. verified=True,
  375. )
  376. try:
  377. profile, _ = await change_own_teacher_state(
  378. user=target.from_user,
  379. action=action,
  380. source="telegram_group" if is_group else "telegram_private",
  381. idempotency_key=(
  382. f"teacher-self:{message.chat.id}:{message.id}:"
  383. f"{target.from_user.id}:{action}"
  384. if is_group
  385. else None
  386. ),
  387. chat_id=message.chat.id if is_group else None,
  388. )
  389. response = await message.reply_text(
  390. f"已{TEACHER_ACTION_LABELS[action]}。"
  391. + (
  392. "本次上线状态将在 24 小时后自动失效。"
  393. if action == "online"
  394. else ""
  395. )
  396. )
  397. if isinstance(target, CallbackQuery):
  398. await target.answer("状态已更新。")
  399. if not is_group and isinstance(target, Message):
  400. await _refresh_keyboard(target, profile)
  401. else:
  402. schedule_group_cleanup(message, response)
  403. except DirectoryServiceError as exc:
  404. if isinstance(target, CallbackQuery):
  405. await target.answer(str(exc), show_alert=True)
  406. else:
  407. response = await target.reply_text(str(exc))
  408. schedule_group_cleanup(target, response)
  409. @app.on_message(filters.command("teacher_online"))
  410. async def teacher_online_command(_, message: Message):
  411. if message.chat.type.value != "private" and not await _verify_group_member(message):
  412. return
  413. await _self_state_command(message, "online")
  414. @app.on_message(filters.command("teacher_offline"))
  415. async def teacher_offline_command(_, message: Message):
  416. if message.chat.type.value != "private" and not await _verify_group_member(message):
  417. return
  418. await _self_state_command(message, "offline")
  419. @app.on_message(filters.command("teacher_list"))
  420. async def teacher_list_command(_, message: Message):
  421. if message.chat.type.value != "private" and not await _verify_group_member(message):
  422. return
  423. await _self_state_command(message, "list")
  424. @app.on_message(filters.command("teacher_unlist"))
  425. async def teacher_unlist_command(_, message: Message):
  426. if message.chat.type.value != "private" and not await _verify_group_member(message):
  427. return
  428. await _self_state_command(message, "unlist")
  429. async def _refresh_keyboard(
  430. message: Message,
  431. profile: dict[str, Any] | None,
  432. ) -> None:
  433. if not message.from_user:
  434. return
  435. await remove_reply_keyboard(message)
  436. admin_result = await verify_platform_membership(
  437. user_id=int(message.from_user.id),
  438. require_admin=True,
  439. )
  440. summary = profile_summary(
  441. profile or {"user_id": message.from_user.id},
  442. None,
  443. )
  444. await message.reply_text(
  445. "菜单已更新。",
  446. reply_markup=_directory_keyboard(
  447. {**(profile or {}), "online": summary["online"]},
  448. is_admin=bool(admin_result.get("allowed")),
  449. ),
  450. )
  451. def _admin_mention(user: Any) -> str:
  452. name = " ".join(
  453. value
  454. for value in (
  455. str(getattr(user, "first_name", "") or "").strip(),
  456. str(getattr(user, "last_name", "") or "").strip(),
  457. )
  458. if value
  459. ) or str(getattr(user, "username", "") or "管理员")
  460. return f'<a href="tg://user?id={int(user.id)}">{escape(name)}</a>'
  461. async def _notify_application_admins(profile: dict[str, Any]) -> None:
  462. chat_id = profile.get("application_source_chat_id")
  463. if not chat_id:
  464. return
  465. admins: list[Any] = []
  466. try:
  467. async for member in app.get_chat_members(
  468. int(chat_id),
  469. filter=ChatMembersFilter.ADMINISTRATORS,
  470. ):
  471. user = getattr(member, "user", None)
  472. if user and not getattr(user, "is_bot", False):
  473. admins.append(user)
  474. except Exception as exc:
  475. log.error(f"获取技师申请来源群管理员失败:{exc}")
  476. mentions = " ".join(_admin_mention(user) for user in admins[:20])
  477. applicant = escape(_display_name(profile))
  478. text = (
  479. f"{mentions}\n" if mentions else ""
  480. ) + (
  481. f"有新的技师申请待审批:<b>{applicant}</b>\n"
  482. f"用户 ID:<code>{profile['user_id']}</code>"
  483. )
  484. markup = InlineKeyboardMarkup(
  485. [
  486. [
  487. InlineKeyboardButton(
  488. "查看并审批",
  489. callback_data=f"dir:admin:detail:{profile['user_id']}",
  490. )
  491. ]
  492. ]
  493. )
  494. try:
  495. await app.send_message(
  496. int(chat_id),
  497. text,
  498. parse_mode=ParseMode.HTML,
  499. reply_markup=markup,
  500. )
  501. except Exception as exc:
  502. log.error(f"发送技师申请群提醒失败:{exc}")
  503. private_text = (
  504. f"群“{escape(profile.get('application_source_chat_title') or str(chat_id))}”"
  505. f"有新的技师申请:<b>{applicant}</b>。"
  506. )
  507. for admin in admins:
  508. if int(admin.id) == int(profile["user_id"]):
  509. continue
  510. try:
  511. await app.send_message(
  512. int(admin.id),
  513. private_text,
  514. parse_mode=ParseMode.HTML,
  515. reply_markup=markup,
  516. )
  517. except Exception:
  518. pass
  519. async def _apply_teacher(target: Message | CallbackQuery) -> None:
  520. if not target.from_user:
  521. return
  522. message = target.message if isinstance(target, CallbackQuery) else target
  523. is_group = int(message.chat.id) < 0
  524. source_context = None
  525. if not is_group:
  526. source_context = _source_chat_contexts.get(int(target.from_user.id))
  527. if (
  528. source_context
  529. and source_context["expires_at"] < datetime.now(UTC)
  530. ):
  531. _source_chat_contexts.pop(int(target.from_user.id), None)
  532. source_context = None
  533. if is_group:
  534. try:
  535. member = await app.get_chat_member(message.chat.id, target.from_user.id)
  536. except Exception:
  537. if isinstance(target, CallbackQuery):
  538. return await target.answer("暂时无法核验群成员身份。", show_alert=True)
  539. return await target.reply_text("暂时无法核验群成员身份。")
  540. if member.status not in {
  541. ChatMemberStatus.OWNER,
  542. ChatMemberStatus.ADMINISTRATOR,
  543. ChatMemberStatus.MEMBER,
  544. ChatMemberStatus.RESTRICTED,
  545. }:
  546. if isinstance(target, CallbackQuery):
  547. return await target.answer("该功能仅对当前群成员开放。", show_alert=True)
  548. return await target.reply_text("该功能仅对当前群成员开放。")
  549. await observe_group_member(
  550. chat_id=message.chat.id,
  551. chat_title=message.chat.title or "",
  552. user=target.from_user,
  553. status=status_value(member.status),
  554. active=True,
  555. verified=True,
  556. )
  557. try:
  558. profile, created = await apply_as_teacher(
  559. user=target.from_user,
  560. chat_id=(
  561. int(message.chat.id)
  562. if is_group
  563. else int(source_context["chat_id"])
  564. if source_context
  565. else None
  566. ),
  567. chat_title=(
  568. str(message.chat.title or "")
  569. if is_group
  570. else str(source_context.get("chat_title") or "")
  571. if source_context
  572. else ""
  573. ),
  574. source="telegram_group" if is_group else "telegram_private",
  575. )
  576. except DirectoryServiceError as exc:
  577. if isinstance(target, CallbackQuery):
  578. return await target.answer(str(exc), show_alert=True)
  579. return await target.reply_text(str(exc))
  580. response_text = (
  581. "技师申请已提交,来源群管理员已收到审批提醒。"
  582. if created
  583. else "技师申请正在等待管理员审批。"
  584. )
  585. response = await message.reply_text(response_text)
  586. if isinstance(target, CallbackQuery):
  587. await target.answer("申请已提交。" if created else "申请正在审批中。")
  588. if created:
  589. _source_chat_contexts.pop(int(target.from_user.id), None)
  590. await _notify_application_admins(profile)
  591. if is_group:
  592. schedule_group_cleanup(message, response)
  593. elif isinstance(target, Message):
  594. await _refresh_keyboard(target, profile)
  595. async def _show_status(target: Message | CallbackQuery) -> None:
  596. if not target.from_user:
  597. return
  598. message = target.message if isinstance(target, CallbackQuery) else target
  599. profile = await observe_directory_user(target.from_user)
  600. summary = profile_summary(
  601. profile,
  602. None,
  603. )
  604. if summary["application_status"] != APPLICATION_APPROVED:
  605. status_text = {
  606. APPLICATION_PENDING: "技师申请正在等待管理员审批。",
  607. APPLICATION_REJECTED: "技师申请未通过。",
  608. APPLICATION_REVOKED: "技师资格已被撤销。",
  609. }.get(summary["application_status"], "你尚未申请成为技师。")
  610. response = await message.reply_text(status_text)
  611. if isinstance(target, CallbackQuery):
  612. await target.answer()
  613. schedule_group_cleanup(message, response)
  614. return
  615. lines = [
  616. "<b>我的技师状态</b>",
  617. "服务区域:请在 /technician_profile 快速页面填写大概区域",
  618. f"榜单:{'已上榜' if summary['listed'] else '未上榜'}",
  619. f"上下线:{_presence_label(summary['online'])}",
  620. (
  621. "自动下线:"
  622. + escape(
  623. summary["online_until"].astimezone(UTC).strftime(
  624. "%Y-%m-%d %H:%M UTC"
  625. )
  626. )
  627. if summary["online"] and summary["online_until"]
  628. else ""
  629. ),
  630. ]
  631. response = await message.reply_text(
  632. "\n".join(line for line in lines if line),
  633. parse_mode=ParseMode.HTML,
  634. )
  635. if isinstance(target, CallbackQuery):
  636. await target.answer()
  637. schedule_group_cleanup(message, response)
  638. async def _open_service_directory(target: Message | CallbackQuery) -> None:
  639. message = target.message if isinstance(target, CallbackQuery) else target
  640. if message.chat.type.value != "private":
  641. response = await message.reply_text(
  642. "请在 Bot 私聊中浏览技师、咨询和评价。",
  643. reply_markup=InlineKeyboardMarkup(
  644. [
  645. [
  646. InlineKeyboardButton(
  647. BUTTON_BROWSE,
  648. url=f"https://t.me/{BOT_USERNAME}?start=services",
  649. )
  650. ]
  651. ]
  652. ),
  653. )
  654. schedule_group_cleanup(message, response)
  655. if isinstance(target, CallbackQuery):
  656. await target.answer()
  657. return
  658. from wbb.modules.technician_services import _ask_search_location
  659. await _ask_search_location(target)
  660. async def _show_radius_picker(target: Message | CallbackQuery) -> None:
  661. markup = InlineKeyboardMarkup(
  662. [
  663. [
  664. InlineKeyboardButton(
  665. f"{radius} 公里",
  666. callback_data=f"dir:list:{radius}:1",
  667. )
  668. for radius in (5, 10)
  669. ],
  670. [
  671. InlineKeyboardButton(
  672. f"{radius} 公里",
  673. callback_data=f"dir:list:{radius}:1",
  674. )
  675. for radius in (20, 50)
  676. ],
  677. ]
  678. )
  679. if isinstance(target, CallbackQuery):
  680. await target.message.edit_text("请选择附近技师的搜索范围:", reply_markup=markup)
  681. else:
  682. response = await target.reply_text(
  683. "请选择附近技师的搜索范围:",
  684. reply_markup=markup,
  685. )
  686. schedule_group_cleanup(target, response)
  687. def _teacher_result_markup(
  688. teachers: list[dict[str, Any]],
  689. *,
  690. radius_key: str,
  691. page: int,
  692. total: int,
  693. ) -> InlineKeyboardMarkup:
  694. rows: list[list[InlineKeyboardButton]] = []
  695. teacher_buttons: list[InlineKeyboardButton] = []
  696. for item in teachers:
  697. username = str(item.get("username") or "")
  698. status = "🟢" if item["online"] else "🔴"
  699. teacher_buttons.append(
  700. InlineKeyboardButton(
  701. f"{status} {item['display_name']}"[:18],
  702. url=f"https://t.me/{username}",
  703. )
  704. )
  705. rows.extend(
  706. teacher_buttons[index : index + 3]
  707. for index in range(0, len(teacher_buttons), 3)
  708. )
  709. navigation: list[InlineKeyboardButton] = []
  710. if page > 1:
  711. navigation.append(
  712. InlineKeyboardButton(
  713. "上一页",
  714. callback_data=f"dir:list:{radius_key}:{page - 1}",
  715. )
  716. )
  717. if page * 10 < total:
  718. navigation.append(
  719. InlineKeyboardButton(
  720. "下一页",
  721. callback_data=f"dir:list:{radius_key}:{page + 1}",
  722. )
  723. )
  724. if navigation:
  725. rows.append(navigation)
  726. rows.append([InlineKeyboardButton("切换附近范围", callback_data="dir:radius")])
  727. return InlineKeyboardMarkup(rows)
  728. async def _show_teacher_results(
  729. target: Message | CallbackQuery,
  730. *,
  731. radius_km: int | None,
  732. page: int,
  733. ) -> None:
  734. user = target.from_user
  735. try:
  736. raw_items, total = await directory_for_user(
  737. user=user,
  738. radius_km=radius_km,
  739. page=page,
  740. page_size=10,
  741. )
  742. except DirectoryServiceError as exc:
  743. if isinstance(target, CallbackQuery):
  744. return await target.answer(str(exc), show_alert=True)
  745. return await target.reply_text(str(exc))
  746. teachers = [public_teacher(item) for item in raw_items]
  747. title = (
  748. f"<b>{radius_km} 公里内的技师</b>"
  749. if radius_km is not None
  750. else "<b>技师榜单</b>"
  751. )
  752. lines = [title, f"共 {total} 位已上榜技师,按距离排序。"]
  753. for index, item in enumerate(teachers, start=(page - 1) * 10 + 1):
  754. lines.append(
  755. f"\n{index}. <b>{escape(item['display_name'])}</b> "
  756. f"(@{escape(item['username'])})\n"
  757. f"状态:{_presence_label(item['online'])} | "
  758. f"距离:{format_distance(item['distance_meters'])}"
  759. )
  760. if not teachers:
  761. lines.append("\n当前范围内没有已上榜技师。")
  762. markup = _teacher_result_markup(
  763. teachers,
  764. radius_key=str(radius_km) if radius_km is not None else "all",
  765. page=page,
  766. total=total,
  767. )
  768. if isinstance(target, CallbackQuery):
  769. await target.message.edit_text(
  770. "\n".join(lines),
  771. parse_mode=ParseMode.HTML,
  772. disable_web_page_preview=True,
  773. reply_markup=markup,
  774. )
  775. await target.answer()
  776. else:
  777. response = await target.reply_text(
  778. "\n".join(lines),
  779. parse_mode=ParseMode.HTML,
  780. disable_web_page_preview=True,
  781. reply_markup=markup,
  782. )
  783. schedule_group_cleanup(target, response)
  784. async def _show_admin_applications(
  785. target: Message | CallbackQuery,
  786. *,
  787. page: int = 1,
  788. ) -> None:
  789. try:
  790. source_chat_ids = await admin_source_chat_ids(target.from_user)
  791. except DirectoryServiceError as exc:
  792. if isinstance(target, CallbackQuery):
  793. return await target.answer(str(exc), show_alert=True)
  794. return await target.reply_text(str(exc))
  795. items, total = await list_teacher_applications(
  796. status=APPLICATION_PENDING,
  797. source_bot_id=str(BOT_PROFILE_ID)
  798. if source_chat_ids is not None
  799. else "",
  800. source_chat_ids=source_chat_ids,
  801. page=page,
  802. page_size=5,
  803. )
  804. lines = ["<b>技师申请审批</b>", f"待审批:{total}"]
  805. rows: list[list[InlineKeyboardButton]] = []
  806. for item in items:
  807. lines.append(
  808. f"\n{escape(_display_name(item))} (@{escape(item.get('username') or '')})"
  809. f"\n用户 ID:<code>{item['user_id']}</code>"
  810. )
  811. rows.append(
  812. [
  813. InlineKeyboardButton(
  814. f"查看 {_display_name(item)[:18]}",
  815. callback_data=f"dir:admin:detail:{item['user_id']}",
  816. )
  817. ]
  818. )
  819. if page > 1:
  820. rows.append(
  821. [
  822. InlineKeyboardButton(
  823. "上一页",
  824. callback_data=f"dir:admin:pending:{page - 1}",
  825. )
  826. ]
  827. )
  828. if page * 5 < total:
  829. rows.append(
  830. [
  831. InlineKeyboardButton(
  832. "下一页",
  833. callback_data=f"dir:admin:pending:{page + 1}",
  834. )
  835. ]
  836. )
  837. rows.append(
  838. [InlineKeyboardButton("已批准技师", callback_data="dir:admin:teachers:1")]
  839. )
  840. markup = InlineKeyboardMarkup(rows)
  841. if isinstance(target, CallbackQuery):
  842. await target.message.edit_text(
  843. "\n".join(lines),
  844. parse_mode=ParseMode.HTML,
  845. reply_markup=markup,
  846. )
  847. await target.answer()
  848. else:
  849. await target.reply_text(
  850. "\n".join(lines),
  851. parse_mode=ParseMode.HTML,
  852. reply_markup=markup,
  853. )
  854. async def _show_admin_teachers(target: CallbackQuery, page: int) -> None:
  855. try:
  856. source_chat_ids = await admin_source_chat_ids(target.from_user)
  857. except DirectoryServiceError as exc:
  858. return await target.answer(str(exc), show_alert=True)
  859. items, total = await list_directory_profiles(
  860. application_status=APPLICATION_APPROVED,
  861. source_bot_id=str(BOT_PROFILE_ID)
  862. if source_chat_ids is not None
  863. else "",
  864. source_chat_ids=source_chat_ids,
  865. page=page,
  866. page_size=8,
  867. )
  868. rows = [
  869. [
  870. InlineKeyboardButton(
  871. _display_name(item)[:28],
  872. callback_data=f"dir:admin:detail:{item['user_id']}",
  873. )
  874. ]
  875. for item in items
  876. ]
  877. navigation: list[InlineKeyboardButton] = []
  878. if page > 1:
  879. navigation.append(
  880. InlineKeyboardButton(
  881. "上一页",
  882. callback_data=f"dir:admin:teachers:{page - 1}",
  883. )
  884. )
  885. if page * 8 < total:
  886. navigation.append(
  887. InlineKeyboardButton(
  888. "下一页",
  889. callback_data=f"dir:admin:teachers:{page + 1}",
  890. )
  891. )
  892. if navigation:
  893. rows.append(navigation)
  894. rows.append([InlineKeyboardButton("待审批", callback_data="dir:admin:pending:1")])
  895. await target.message.edit_text(
  896. f"<b>已批准技师</b>\n共 {total} 人",
  897. parse_mode=ParseMode.HTML,
  898. reply_markup=InlineKeyboardMarkup(rows),
  899. )
  900. await target.answer()
  901. async def _show_admin_teacher_detail(target: CallbackQuery, user_id: int) -> None:
  902. try:
  903. await require_teacher_admin(target.from_user, user_id)
  904. except DirectoryServiceError as exc:
  905. return await target.answer(str(exc), show_alert=True)
  906. profile = await get_directory_profile(user_id)
  907. if not profile:
  908. return await target.answer("未找到该成员资料。", show_alert=True)
  909. summary = profile_summary(profile, None)
  910. service_area = str(
  911. (profile.get("service_profile") or {}).get("public_area_text") or "尚未填写"
  912. )
  913. lines = [
  914. f"<b>{escape(summary['display_name'])}</b>",
  915. f"用户名:@{escape(summary.get('username') or '未设置')}",
  916. f"用户 ID:<code>{user_id}</code>",
  917. f"申请:{escape(APPLICATION_LABELS.get(summary['application_status'], '未申请'))}",
  918. f"大概服务区域:{escape(service_area)}",
  919. f"榜单:{'已上榜' if summary['listed'] else '未上榜'}",
  920. f"状态:{_presence_label(summary['online'])}",
  921. ]
  922. rows: list[list[InlineKeyboardButton]] = []
  923. if summary["application_status"] == APPLICATION_PENDING:
  924. rows.append(
  925. [
  926. InlineKeyboardButton(
  927. "批准",
  928. callback_data=f"dir:admin:approveask:{user_id}",
  929. ),
  930. InlineKeyboardButton(
  931. "拒绝",
  932. callback_data=f"dir:admin:flow:reject:{user_id}",
  933. ),
  934. ]
  935. )
  936. if summary["application_status"] == APPLICATION_APPROVED:
  937. rows.extend(
  938. [
  939. [
  940. InlineKeyboardButton(
  941. "强制下榜" if summary["listed"] else "强制上榜",
  942. callback_data=(
  943. f"dir:admin:flow:"
  944. f"{'unlist' if summary['listed'] else 'list'}:{user_id}"
  945. ),
  946. ),
  947. InlineKeyboardButton(
  948. "强制下线" if summary["online"] else "强制上线",
  949. callback_data=(
  950. f"dir:admin:flow:"
  951. f"{'offline' if summary['online'] else 'online'}:{user_id}"
  952. ),
  953. ),
  954. ],
  955. [
  956. InlineKeyboardButton(
  957. "撤销技师资格",
  958. callback_data=f"dir:admin:flow:revoke:{user_id}",
  959. )
  960. ],
  961. ]
  962. )
  963. rows.append([InlineKeyboardButton("返回审批", callback_data="dir:admin:pending:1")])
  964. await target.message.edit_text(
  965. "\n".join(lines),
  966. parse_mode=ParseMode.HTML,
  967. reply_markup=InlineKeyboardMarkup(rows),
  968. )
  969. try:
  970. await target.answer()
  971. except Exception:
  972. pass
  973. async def _start_admin_reason_flow(
  974. target: CallbackQuery,
  975. *,
  976. action: str,
  977. user_id: int,
  978. ) -> None:
  979. try:
  980. await require_teacher_admin(target.from_user, user_id)
  981. except DirectoryServiceError as exc:
  982. return await target.answer(str(exc), show_alert=True)
  983. _admin_flows[int(target.from_user.id)] = {
  984. "action": action,
  985. "user_id": int(user_id),
  986. "expires_at": datetime.now(UTC) + timedelta(minutes=10),
  987. }
  988. await target.message.reply_text(
  989. f"请输入“{TEACHER_ACTION_LABELS.get(action, action)}”的原因,"
  990. "发送 /cancel 可取消。"
  991. )
  992. await target.answer()
  993. async def _confirm_admin_action(target: CallbackQuery, token: str) -> None:
  994. confirmation = _admin_confirmations.get(token)
  995. if not confirmation or confirmation["actor_id"] != target.from_user.id:
  996. return await target.answer("确认已失效,请重新操作。", show_alert=True)
  997. if confirmation["expires_at"] < datetime.now(UTC):
  998. _admin_confirmations.pop(token, None)
  999. return await target.answer("确认已超时,请重新操作。", show_alert=True)
  1000. action = confirmation["action"]
  1001. user_id = int(confirmation["user_id"])
  1002. reason = str(confirmation.get("reason") or "")
  1003. try:
  1004. if action in {"reject", "revoke"}:
  1005. profile = await admin_decide_teacher(
  1006. actor=target.from_user,
  1007. user_id=user_id,
  1008. action=action,
  1009. reason=reason,
  1010. )
  1011. else:
  1012. profile = await admin_change_teacher_state(
  1013. actor=target.from_user,
  1014. user_id=user_id,
  1015. action=action,
  1016. reason=reason,
  1017. )
  1018. with_reason = f"\n原因:{reason}" if reason else ""
  1019. with_suppressed_notification = (
  1020. "技师申请已被拒绝" if action == "reject" else
  1021. "技师资格已被撤销" if action == "revoke" else
  1022. f"技师状态已被管理员修改为“{TEACHER_ACTION_LABELS[action]}”"
  1023. )
  1024. try:
  1025. await app.send_message(
  1026. user_id,
  1027. f"{with_suppressed_notification}。{with_reason}",
  1028. )
  1029. except Exception:
  1030. pass
  1031. _admin_confirmations.pop(token, None)
  1032. await target.answer("操作成功。")
  1033. await _show_admin_teacher_detail(target, int(profile["user_id"]))
  1034. except DirectoryServiceError as exc:
  1035. await target.answer(str(exc), show_alert=True)
  1036. @app.on_callback_query(filters.regex(r"^dir:"))
  1037. async def directory_callback(_, query: CallbackQuery):
  1038. parts = str(query.data).split(":")
  1039. try:
  1040. if parts[1] == "browse":
  1041. return await _open_service_directory(query)
  1042. if parts[1] == "apply":
  1043. return await _apply_teacher(query)
  1044. if parts[1] == "status":
  1045. return await _show_status(query)
  1046. if parts[1] == "self":
  1047. return await _self_state_command(query, parts[2])
  1048. if parts[1] == "radius":
  1049. return await _open_service_directory(query)
  1050. if parts[1] == "list":
  1051. return await _open_service_directory(query)
  1052. if parts[1] == "clearask":
  1053. return await query.message.edit_text(
  1054. "确认清除历史精确坐标吗?公开状态和大概服务区域不会受影响。",
  1055. reply_markup=InlineKeyboardMarkup(
  1056. [
  1057. [
  1058. InlineKeyboardButton(
  1059. "确认清除",
  1060. callback_data="dir:clear",
  1061. ),
  1062. InlineKeyboardButton("取消", callback_data="dir:cancel"),
  1063. ]
  1064. ]
  1065. ),
  1066. )
  1067. if parts[1] == "clear":
  1068. await clear_user_location(user=query.from_user)
  1069. await query.message.edit_text("历史精确坐标已清除,公开状态保持不变。")
  1070. return await query.answer("已清除。")
  1071. if parts[1] == "cancel":
  1072. await query.message.edit_text("已取消。")
  1073. return await query.answer()
  1074. if parts[1] == "admin":
  1075. if parts[2] == "pending":
  1076. return await _show_admin_applications(query, page=int(parts[3]))
  1077. if parts[2] == "teachers":
  1078. return await _show_admin_teachers(query, int(parts[3]))
  1079. if parts[2] == "detail":
  1080. return await _show_admin_teacher_detail(query, int(parts[3]))
  1081. if parts[2] == "approveask":
  1082. user_id = int(parts[3])
  1083. return await query.message.edit_text(
  1084. "批准后技师身份将在全部群和 Bot 中生效,确认继续吗?",
  1085. reply_markup=InlineKeyboardMarkup(
  1086. [
  1087. [
  1088. InlineKeyboardButton(
  1089. "确认批准",
  1090. callback_data=f"dir:admin:approve:{user_id}",
  1091. ),
  1092. InlineKeyboardButton(
  1093. "取消",
  1094. callback_data=f"dir:admin:detail:{user_id}",
  1095. ),
  1096. ]
  1097. ]
  1098. ),
  1099. )
  1100. if parts[2] == "approve":
  1101. profile = await admin_decide_teacher(
  1102. actor=query.from_user,
  1103. user_id=int(parts[3]),
  1104. action="approve",
  1105. )
  1106. try:
  1107. await app.send_message(
  1108. int(profile["user_id"]),
  1109. "你的技师申请已通过,可以在菜单中自助上榜。",
  1110. )
  1111. except Exception:
  1112. pass
  1113. await query.answer("已批准。")
  1114. return await _show_admin_teacher_detail(
  1115. query,
  1116. int(profile["user_id"]),
  1117. )
  1118. if parts[2] == "flow":
  1119. return await _start_admin_reason_flow(
  1120. query,
  1121. action=parts[3],
  1122. user_id=int(parts[4]),
  1123. )
  1124. if parts[2] == "confirm":
  1125. return await _confirm_admin_action(query, parts[3])
  1126. except (DirectoryServiceError, ValueError) as exc:
  1127. return await query.answer(str(exc), show_alert=True)
  1128. except Exception as exc:
  1129. log.error(f"技师目录回调失败:{exc}")
  1130. return await query.answer("操作失败,请稍后重试。", show_alert=True)
  1131. @app.on_message(filters.command("cancel") & filters.private, group=-20)
  1132. async def cancel_directory_flow(_, message: Message):
  1133. if not message.from_user:
  1134. return
  1135. admin_flow = _admin_flows.pop(message.from_user.id, None)
  1136. if not admin_flow:
  1137. return
  1138. await remove_reply_keyboard(message)
  1139. await message.reply_text("已取消当前操作。")
  1140. raise StopPropagation
  1141. @app.on_message(filters.private & filters.text, group=13)
  1142. async def directory_private_text(_, message: Message):
  1143. if not message.from_user:
  1144. return
  1145. flow = _admin_flows.get(message.from_user.id)
  1146. if flow:
  1147. if flow["expires_at"] < datetime.now(UTC):
  1148. _admin_flows.pop(message.from_user.id, None)
  1149. return await message.reply_text("操作已超时,请重新打开技师审批。")
  1150. reason = str(message.text or "").strip()
  1151. if not reason:
  1152. return await message.reply_text("原因不能为空,请重新输入。")
  1153. token = token_urlsafe(8)
  1154. _admin_confirmations[token] = {
  1155. **flow,
  1156. "actor_id": message.from_user.id,
  1157. "reason": reason,
  1158. "expires_at": datetime.now(UTC) + timedelta(minutes=10),
  1159. }
  1160. _admin_flows.pop(message.from_user.id, None)
  1161. return await message.reply_text(
  1162. f"确认执行“{TEACHER_ACTION_LABELS.get(flow['action'], flow['action'])}”吗?"
  1163. f"\n用户 ID:{flow['user_id']}\n原因:{escape(reason)}",
  1164. reply_markup=InlineKeyboardMarkup(
  1165. [
  1166. [
  1167. InlineKeyboardButton(
  1168. "确认执行",
  1169. callback_data=f"dir:admin:confirm:{token}",
  1170. ),
  1171. InlineKeyboardButton("取消", callback_data="dir:cancel"),
  1172. ]
  1173. ]
  1174. ),
  1175. )
  1176. text = str(message.text or "").strip()
  1177. if text == BUTTON_BROWSE:
  1178. return await _open_service_directory(message)
  1179. if text == BUTTON_APPLY:
  1180. return await _apply_teacher(message)
  1181. if text == BUTTON_STATUS:
  1182. return await _show_status(message)
  1183. if text == "技师申请审核中":
  1184. return await _show_status(message)
  1185. if text == BUTTON_CLEAR_LOCATION:
  1186. return await message.reply_text(
  1187. "确认清除历史精确坐标吗?公开状态和大概服务区域不会受影响。",
  1188. reply_markup=InlineKeyboardMarkup(
  1189. [
  1190. [
  1191. InlineKeyboardButton("确认清除", callback_data="dir:clear"),
  1192. InlineKeyboardButton("取消", callback_data="dir:cancel"),
  1193. ]
  1194. ]
  1195. ),
  1196. )
  1197. if text == BUTTON_APPROVAL:
  1198. return await _show_admin_applications(message)
  1199. action = {
  1200. BUTTON_LIST: "list",
  1201. BUTTON_UNLIST: "unlist",
  1202. BUTTON_ONLINE: "online",
  1203. BUTTON_OFFLINE: "offline",
  1204. "上线": "online",
  1205. "下线": "offline",
  1206. }.get(text)
  1207. if action:
  1208. return await _self_state_command(message, action)
  1209. @app.on_message(filters.group & filters.text, group=13)
  1210. async def teacher_presence_group_text(_, message: Message):
  1211. if not message.from_user:
  1212. return
  1213. text = str(message.text or "").strip()
  1214. action = {
  1215. "上线": "online",
  1216. "下线": "offline",
  1217. BUTTON_ONLINE: "online",
  1218. BUTTON_OFFLINE: "offline",
  1219. }.get(text)
  1220. if not action:
  1221. return
  1222. profile = await get_directory_profile(message.from_user.id)
  1223. if not profile or profile.get("application_status") != APPLICATION_APPROVED:
  1224. return
  1225. if not await _verify_group_member(message):
  1226. return
  1227. try:
  1228. _, applied = await change_own_teacher_state(
  1229. user=message.from_user,
  1230. action=action,
  1231. source="telegram_group",
  1232. idempotency_key=(
  1233. f"teacher-presence:{message.chat.id}:{message.id}:"
  1234. f"{message.from_user.id}"
  1235. ),
  1236. chat_id=message.chat.id,
  1237. )
  1238. if applied:
  1239. response = await message.reply_text(
  1240. f"全平台状态已切换为{_presence_label(action == 'online')}。"
  1241. + ("24 小时后将自动下线。" if action == "online" else "")
  1242. )
  1243. schedule_group_cleanup(message, response)
  1244. except DirectoryServiceError as exc:
  1245. response = await message.reply_text(str(exc))
  1246. schedule_group_cleanup(message, response)
  1247. @app.on_chat_member_updated(group=13)
  1248. async def directory_chat_member_updated(_, update: ChatMemberUpdated):
  1249. member = update.new_chat_member or update.old_chat_member
  1250. if not member or not member.user or member.user.is_bot:
  1251. return
  1252. status = update.new_chat_member.status if update.new_chat_member else ChatMemberStatus.LEFT
  1253. active = status in {
  1254. ChatMemberStatus.OWNER,
  1255. ChatMemberStatus.ADMINISTRATOR,
  1256. ChatMemberStatus.MEMBER,
  1257. ChatMemberStatus.RESTRICTED,
  1258. }
  1259. await observe_group_member(
  1260. chat_id=update.chat.id,
  1261. chat_title=update.chat.title or "",
  1262. user=member.user,
  1263. status=status_value(status),
  1264. active=active,
  1265. verified=True,
  1266. )
  1267. start_presence_sweeper()