teacher_directory.py 46 KB

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