diff --git a/boss_cli/cli.py b/boss_cli/cli.py index 76bba64..983248c 100644 --- a/boss_cli/cli.py +++ b/boss_cli/cli.py @@ -5,6 +5,8 @@ boss search [--city C] [--salary S] [--exp E] [--degree D] boss recommend [--page N] boss me / applied / interviews / chat + boss messages [-n N] + boss chat-history [-n N] boss greet boss batch-greet [-n N] [--city C] [--dry-run] boss cities @@ -60,6 +62,14 @@ def cli(ctx, verbose: bool) -> None: cli.add_command(social.chat_list) cli.add_command(social.greet) cli.add_command(social.batch_greet) +cli.add_command(social.messages) +cli.add_command(social.chat_history) +cli.add_command(social.unread_messages) +cli.add_command(social.geek_reply) +cli.add_command(social.send_resume) +cli.add_command(social.request_phone) +cli.add_command(social.request_wechat) +cli.add_command(social.accept_exchange) # ─── Recruiter (Boss) commands ────────────────────────────────────── diff --git a/boss_cli/client.py b/boss_cli/client.py index ee801cb..c0bcbec 100644 --- a/boss_cli/client.py +++ b/boss_cli/client.py @@ -14,6 +14,9 @@ from .constants import ( BASE_URL, BOSS_CHAT_GEEK_INFO_URL, + GEEK_FRIEND_LIST_URL, + GEEK_HISTORY_MSG_URL, + GEEK_LAST_MSG_URL, BOSS_CHATTED_JOB_LIST_URL, BOSS_EXCHANGE_CONTENT_URL, BOSS_EXCHANGE_REQUEST_URL, @@ -196,7 +199,8 @@ def _headers_for_request(self, url: str, params: dict[str, Any] | None = None) - headers["Referer"] = WEB_GEEK_JOB_URL elif url == JOB_HISTORY_URL: headers["Referer"] = WEB_GEEK_HISTORY_URL - elif url in (FRIEND_LIST_URL, FRIEND_ADD_URL): + elif url in (FRIEND_LIST_URL, FRIEND_ADD_URL, GEEK_FRIEND_LIST_URL, + GEEK_LAST_MSG_URL, GEEK_HISTORY_MSG_URL): headers["Referer"] = WEB_GEEK_CHAT_URL # Recruiter (boss) endpoints elif url == BOSS_SEARCH_GEEK_URL: @@ -454,6 +458,86 @@ def get_geek_job(self, security_id: str) -> dict[str, Any]: """Get interacted job info.""" return self._get(GEEK_GET_JOB_URL, params={"securityId": security_id}, action="互动职位") + def get_ws_auth(self) -> tuple[str, str]: + """Return (page_token, wt2) for MQTT WebSocket authentication.""" + user_info = self._get(USER_INFO_URL, action="用户信息") + page_token = user_info.get("token", "") + wt_data = self._get("/wapi/zppassport/get/wt", action="WS Token") + wt2 = wt_data.get("wt2", "") + return page_token, wt2 + + def get_geek_friend_list(self, label_id: int = 0, page: int = 1) -> dict[str, Any]: + """Get geek chat friend list (bosses who have chatted with you).""" + data: dict[str, Any] = {"labelId": label_id, "page": page} + return self._post(GEEK_FRIEND_LIST_URL, data=data, action="沟通列表") + + def get_geek_last_messages(self, friend_ids: list[int]) -> list[dict[str, Any]]: + """Get last message for each boss friend (geek perspective).""" + ids_str = ",".join(str(fid) for fid in friend_ids) + result = self._get(GEEK_LAST_MSG_URL, params={"friendIds": ids_str}, action="最近消息") + return result if isinstance(result, list) else [] + + def get_geek_chat_history(self, boss_id: int, count: int = 20, max_msg_id: int = 0) -> dict[str, Any]: + """Get chat history with a specific boss (geek perspective).""" + params: dict[str, Any] = {"gid": boss_id, "c": count, "src": 0} + if max_msg_id: + params["maxMsgId"] = max_msg_id + return self._get(GEEK_HISTORY_MSG_URL, params=params, action="聊天记录") + + def get_geek_boss_data(self, boss_id: int) -> dict[str, Any]: + """Get boss chat context data (securityId, encryptJobId, mobile, weixin, etc.).""" + result = self._get("/wapi/zpchat/geek/getBossData", params={"bossId": boss_id}, action="Boss信息") + return result.get("data", result) + + def geek_exchange_request(self, boss_id: int, security_id: str, exchange_type: int) -> dict[str, Any]: + """Request exchange with a boss. + + exchange_type: 1=phone, 2=wechat, 3=resume + securityId from get_geek_boss_data(). + """ + return self._post( + "/wapi/zpchat/exchange/request", + data={"type": exchange_type, "bossId": boss_id, "securityId": security_id}, + action="交换请求", + ) + + def geek_send_resume(self, boss_id: int, security_id: str) -> dict[str, Any]: + """Send resume to a boss (exchange type=3).""" + return self.geek_exchange_request(boss_id, security_id, exchange_type=3) + + def geek_request_phone(self, boss_id: int, security_id: str) -> dict[str, Any]: + """Request phone number exchange with a boss (exchange type=1).""" + return self.geek_exchange_request(boss_id, security_id, exchange_type=1) + + def geek_request_wechat(self, boss_id: int, security_id: str) -> dict[str, Any]: + """Request WeChat exchange with a boss (exchange type=2).""" + return self.geek_exchange_request(boss_id, security_id, exchange_type=2) + + def geek_accept_exchange(self, boss_id: int, mid: int, security_id: str = "") -> dict[str, Any]: + """Accept an exchange request from a boss (phone/wechat/resume/contact). + + mid: msgId from the exchange request message (from userLastMsg). + securityId: from get_geek_boss_data() — optional but recommended. + """ + data: dict[str, Any] = {"bossId": boss_id, "mid": mid} + if security_id: + data["securityId"] = security_id + return self._post("/wapi/zpchat/geek/acceptItemContact", data=data, action="接受交换请求") + + def geek_reject_exchange(self, boss_id: int, mid: int, security_id: str = "") -> dict[str, Any]: + """Reject an exchange request from a boss.""" + data: dict[str, Any] = {"bossId": boss_id, "mid": mid} + if security_id: + data["securityId"] = security_id + return self._post("/wapi/zpchat/geek/rejectItemContact", data=data, action="拒绝交换请求") + + def geek_accept_wechat(self, boss_id: int, mid: int, security_id: str = "") -> dict[str, Any]: + """Accept a WeChat exchange request from a boss.""" + data: dict[str, Any] = {"bossId": boss_id, "mid": mid} + if security_id: + data["securityId"] = security_id + return self._post("/wapi/zpchat/geek/acceptItemWeiXinRequest", data=data, action="接受微信交换请求") + # ── Recruiter (Boss) Mode ──────────────────────────────────────── def _post(self, url: str, data: dict[str, Any] | None = None, action: str = "", json_body: bool = False) -> dict[str, Any]: diff --git a/boss_cli/commands/social.py b/boss_cli/commands/social.py index 31df529..ae3c8ea 100644 --- a/boss_cli/commands/social.py +++ b/boss_cli/commands/social.py @@ -5,6 +5,7 @@ import json import logging import time +from datetime import datetime import click from rich.table import Table @@ -172,3 +173,351 @@ def batch_greet(keyword: str, city: str, count: int, salary: str | None, exp: st except BossApiError as exc: console.print(f"[red]❌ 搜索失败: {exc}[/red]") raise SystemExit(1) from None + + +@click.command("messages") +@click.option("-n", "--count", default=20, type=int, help="显示最近 N 条会话 (默认: 20)") +@structured_output_options +def messages(count: int, as_json: bool, as_yaml: bool) -> None: + """查看收到的消息列表(所有沟通过的 Boss 及最近一条消息)""" + cred = require_auth() + + def _action(client): + friends_data = client.get_geek_friend_list() + friends = friends_data.get("friendList", [])[:count] + if not friends: + return {"friends": [], "messages": []} + + # Batch fetch last messages in chunks of 20 + BATCH = 20 + all_msgs: list[dict] = [] + for i in range(0, len(friends), BATCH): + batch_ids = [f["friendId"] for f in friends[i:i + BATCH]] + all_msgs.extend(client.get_geek_last_messages(batch_ids)) + + return {"friends": friends, "messages": all_msgs} + + def _render(data: dict) -> None: + friends = data.get("friends", []) + msgs = data.get("messages", []) + + if not friends: + console.print("[yellow]暂无沟通记录[/yellow]") + return + + # Build boss_id -> msg map; the peer is whichever of fromId/toId is NOT the user's uid + my_uid = None + for m in msgs: + info = m.get("lastMsgInfo", {}) + to_id = info.get("toId") + from_id = info.get("fromId") + # uid field is always the current user's uid + if m.get("uid"): + my_uid = m["uid"] + break + + msg_map: dict[int, dict] = {} + for m in msgs: + info = m.get("lastMsgInfo", {}) + from_id = info.get("fromId") + to_id = info.get("toId") + # peer is the non-self side + peer_id = to_id if from_id == my_uid else from_id + if peer_id: + msg_map[peer_id] = m + + table = Table(title=f"💬 消息列表 ({len(friends)} 个)", show_lines=True) + table.add_column("#", style="dim", width=3) + table.add_column("Boss", style="bold cyan", max_width=10) + table.add_column("公司", style="green", max_width=18) + table.add_column("职位", max_width=22) + table.add_column("时间", style="dim", width=8) + table.add_column("最近消息", max_width=40) + + for i, friend in enumerate(friends, 1): + fid = friend["friendId"] + msg = msg_map.get(fid, {}) + info = msg.get("lastMsgInfo", {}) + last_text = info.get("showText", "-") + last_time = msg.get("lastTime", "-") + table.add_row( + str(i), + friend.get("name", "-"), + friend.get("brandName", "-"), + friend.get("jobName", "-"), + last_time, + last_text[:60] + ("…" if len(last_text) > 60 else ""), + ) + + console.print(table) + console.print(f"\n[dim]提示: 使用 boss history 查看完整聊天记录[/dim]") + + handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) + + +@click.command("unread") +@click.option("-n", "--count", default=50, type=int, help="检查最近 N 个会话 (默认: 50)") +@structured_output_options +def unread_messages(count: int, as_json: bool, as_yaml: bool) -> None: + """查看未回复的消息(Boss 发来但你还没回复的会话)""" + cred = require_auth() + + def _action(client): + friends_data = client.get_geek_friend_list() + friends = friends_data.get("friendList", [])[:count] + if not friends: + return {"unread": [], "my_uid": None} + + my_info = client.get_user_info() + my_uid = my_info.get("userId") + + BATCH = 20 + all_msgs: list[dict] = [] + for i in range(0, len(friends), BATCH): + batch_ids = [f["friendId"] for f in friends[i:i + BATCH]] + all_msgs.extend(client.get_geek_last_messages(batch_ids)) + + # Build peer_id -> msg map + msg_map: dict[int, dict] = {} + for m in all_msgs: + info = m.get("lastMsgInfo", {}) + from_id = info.get("fromId") + to_id = info.get("toId") + peer = to_id if from_id == my_uid else from_id + if peer: + msg_map[peer] = m + + # Unread = last message is FROM boss (not from me) + unread = [] + for friend in friends: + fid = friend["friendId"] + msg = msg_map.get(fid, {}) + info = msg.get("lastMsgInfo", {}) + from_id = info.get("fromId") + if from_id and from_id != my_uid: + unread.append({"friend": friend, "lastMsg": msg}) + + return {"unread": unread, "my_uid": my_uid} + + def _render(data: dict) -> None: + unread = data.get("unread", []) + if not unread: + console.print("[green]✅ 没有未回复的消息[/green]") + return + + table = Table(title=f"📬 未回复消息 ({len(unread)} 个)", show_lines=True) + table.add_column("#", style="dim", width=3) + table.add_column("friendId", style="dim", width=10) + table.add_column("Boss", style="bold cyan", max_width=10) + table.add_column("公司", style="green", max_width=18) + table.add_column("时间", style="dim", width=8) + table.add_column("消息", max_width=45) + + for i, item in enumerate(unread, 1): + friend = item["friend"] + msg = item["lastMsg"] + info = msg.get("lastMsgInfo", {}) + text = info.get("showText", "-") + last_time = msg.get("lastTime", "-") + table.add_row( + str(i), + str(friend["friendId"]), + friend.get("name", "-"), + friend.get("brandName", "-"), + last_time, + text[:60] + ("…" if len(text) > 60 else ""), + ) + + console.print(table) + console.print(f"\n[dim]提示: 使用 boss reply \"回复内容\" 发送消息[/dim]") + + handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) + + +@click.command("reply") +@click.argument("friend_id", type=int) +@click.argument("message") +def geek_reply(friend_id: int, message: str) -> None: + """向 Boss 发送消息 (需要 friendId,可从 boss messages 获取) + + 例: boss reply 605029326 "您好,我对这个职位很感兴趣" + """ + cred = require_auth() + + try: + from ..mqtt_chat import BossMQTTChat + except ImportError as exc: + console.print(f"[red]❌ {exc}[/red]") + raise SystemExit(1) from None + + console.print(f"[dim]正在获取认证信息...[/dim]") + + # Step 1: Get all needed info via HTTP + try: + friends_data = run_client_action(cred, lambda c: c.get_geek_friend_list()) + friends = friends_data.get("friendList", []) + friend = next((f for f in friends if f["friendId"] == friend_id), None) + if not friend: + console.print(f"[red]❌ 找不到 friendId={friend_id},请用 boss messages 查看列表[/red]") + raise SystemExit(1) + + # Get my own info + my_info = run_client_action(cred, lambda c: c.get_user_info()) + my_uid = my_info.get("userId") + my_enc_uid = my_info.get("encryptUserId", "") + + # Get MQTT auth tokens + page_token, wt2 = run_client_action(cred, lambda c: c.get_ws_auth()) + + except BossApiError as exc: + console.print(f"[red]❌ 获取信息失败: {exc}[/red]") + raise SystemExit(1) from None + + boss_uid = friend["friendId"] + boss_enc_uid = friend.get("encryptFriendId", "") + boss_name = friend.get("name", str(boss_uid)) + cookies = dict(cred.cookies) + + console.print(f"[dim]连接 MQTT...[/dim]") + + try: + with BossMQTTChat(page_token, wt2, cookies=cookies, timeout=12) as chat: + chat.send( + from_uid=my_uid, + from_encrypt_uid=my_enc_uid, + to_uid=boss_uid, + to_encrypt_uid=boss_enc_uid, + text=message, + ) + console.print(f"[green]✅ 消息已发送给 {boss_name}[/green]") + console.print(f" [dim]{message}[/dim]") + except Exception as exc: + console.print(f"[red]❌ 发送失败: {exc}[/red]") + raise SystemExit(1) from None + + +@click.command("chat-history") +@click.argument("friend_id", type=int) +@click.option("-n", "--count", default=20, type=int, help="获取最近 N 条消息 (默认: 20)") +@structured_output_options +def chat_history(friend_id: int, count: int, as_json: bool, as_yaml: bool) -> None: + """查看与某个 Boss 的双向聊天记录 (需要 friendId,可从 boss messages 获取)""" + cred = require_auth() + + def _action(client): + return client.get_geek_chat_history(boss_id=friend_id, count=count) + + def _render(data: dict) -> None: + msg_list = data.get("messages", data.get("msgList", [])) + if not msg_list: + console.print("[yellow]暂无聊天记录(仅显示双向对话记录,如对方发消息但你未回复则为空)[/yellow]") + return + + my_uid = None + for m in msg_list: + if m.get("fromType") == 1: + my_uid = m.get("fromId") + break + + console.print(f"\n[bold]聊天记录 (friendId={friend_id})[/bold]\n") + for m in reversed(msg_list): + from_id = m.get("fromId") + content = m.get("body", m.get("content", m.get("showText", "-"))) + ts = m.get("msgTime", 0) + time_str = datetime.fromtimestamp(ts / 1000).strftime("%m-%d %H:%M") if ts else "-" + is_me = from_id == my_uid + prefix = "[bold blue]我[/bold blue]" if is_me else "[bold green]Boss[/bold green]" + console.print(f" {time_str} {prefix}: {content}") + + handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) + + +def _exchange_command(exchange_type: int, success_msg: str): + """Factory for exchange commands (send-resume, request-phone, request-wechat).""" + def _cmd(friend_id: int, as_json: bool, as_yaml: bool) -> None: + cred = require_auth() + + def _action(client): + boss_data = client.get_geek_boss_data(friend_id) + security_id = boss_data.get("securityId", "") + if not security_id: + raise BossApiError(f"无法获取 securityId (friendId={friend_id})") + return client.geek_exchange_request(friend_id, security_id, exchange_type) + + def _render(data: dict) -> None: + status = data.get("status", -1) + if status == 0: + console.print(f"[green]✅ {success_msg}[/green]") + else: + console.print(f"[yellow]已发送请求 (status={status})[/yellow]") + + handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) + + return _cmd + + +@click.command("send-resume") +@click.argument("friend_id", type=int) +@structured_output_options +def send_resume(friend_id: int, as_json: bool, as_yaml: bool) -> None: + """向 Boss 发送附件简历 (需要 friendId)""" + _exchange_command(3, "简历已发送")(friend_id, as_json, as_yaml) + + +@click.command("request-phone") +@click.argument("friend_id", type=int) +@structured_output_options +def request_phone(friend_id: int, as_json: bool, as_yaml: bool) -> None: + """向 Boss 请求交换手机号 (需要 friendId)""" + _exchange_command(1, "手机号交换请求已发送")(friend_id, as_json, as_yaml) + + +@click.command("request-wechat") +@click.argument("friend_id", type=int) +@structured_output_options +def request_wechat(friend_id: int, as_json: bool, as_yaml: bool) -> None: + """向 Boss 请求交换微信 (需要 friendId)""" + _exchange_command(2, "微信交换请求已发送")(friend_id, as_json, as_yaml) + + +@click.command("accept") +@click.argument("friend_id", type=int) +@click.option("--reject", is_flag=True, help="拒绝请求(默认同意)") +@structured_output_options +def accept_exchange(friend_id: int, reject: bool, as_json: bool, as_yaml: bool) -> None: + """同意(或拒绝)Boss 发来的交换请求(手机/微信/简历) + + 例: boss accept 629683122 + boss accept 629683122 --reject + """ + cred = require_auth() + + def _action(client): + # 从 userLastMsg 获取最新的 exchange 请求消息 + msgs = client.get_geek_last_messages([friend_id]) + if not msgs: + raise BossApiError(f"找不到与 friendId={friend_id} 的消息记录") + info = msgs[0].get("lastMsgInfo", {}) + mid = info.get("msgId") + text = info.get("showText", "") + if not mid: + raise BossApiError("无法获取消息 ID") + # 检查是否是 exchange 请求 + exchange_keywords = ["是否同意", "交换", "附件简历", "联系方式"] + if not any(kw in text for kw in exchange_keywords): + raise BossApiError(f"最新消息不是交换请求: {text[:40]!r}") + boss_data = client.get_geek_boss_data(friend_id) + sec = boss_data.get("securityId", "") + if reject: + return client.geek_reject_exchange(friend_id, mid, sec) + else: + # 微信请求用 acceptItemWeiXinRequest,其他用 acceptItemContact + if "微信" in text: + return client.geek_accept_wechat(friend_id, mid, sec) + return client.geek_accept_exchange(friend_id, mid, sec) + + def _render(data: dict) -> None: + action = "拒绝" if reject else "同意" + console.print(f"[green]✅ 已{action}交换请求[/green]") + + handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml) diff --git a/boss_cli/constants.py b/boss_cli/constants.py index 4100d5c..e59013c 100644 --- a/boss_cli/constants.py +++ b/boss_cli/constants.py @@ -40,6 +40,9 @@ FRIEND_LIST_URL = "/wapi/zprelation/friend/getGeekFriendList.json" FRIEND_ADD_URL = "/wapi/zpgeek/friend/add.json" GEEK_GET_JOB_URL = "/wapi/zprelation/interaction/geekGetJob" +GEEK_FRIEND_LIST_URL = "/wapi/zprelation/friend/geekFilterByLabel" +GEEK_LAST_MSG_URL = "/wapi/zpchat/geek/userLastMsg" +GEEK_HISTORY_MSG_URL = "/wapi/zpchat/geek/historyMsg" # ── Recruiter (Boss) API ────────────────────────────────────────── WEB_BOSS_CHAT_URL = f"{BASE_URL}/web/chat/index" diff --git a/boss_cli/mqtt_chat.py b/boss_cli/mqtt_chat.py new file mode 100644 index 0000000..8e6d2b1 --- /dev/null +++ b/boss_cli/mqtt_chat.py @@ -0,0 +1,245 @@ +"""MQTT-based chat for geek (job seeker) side. + +BOSS 直聘 uses MQTT over WSS for real-time messaging. +- Server: ws6.zhipin.com:443/chatws (or ws.zhipin.com / ws2.zhipin.com) +- Auth: userName = wt2_cookie + "|0", password = wt2_cookie +- Topic: "chat" (QoS 1, retain=True) +- Payload: Protobuf-encoded TechwolfChatProtocol + +Proto schema (reverse-engineered from JS bundle): + TechwolfChatProtocol { type=1, messages=[TechwolfMessage] } + TechwolfMessage { from=User, to=User, type=1, mid, cmid, body=Body } + TechwolfUser { uid, name, source } + TechwolfMessageBody { type=1, templateId=1, text } +""" + +from __future__ import annotations + +import logging +import struct +import threading +import time +from typing import Callable + +logger = logging.getLogger(__name__) + + +# ── Minimal Protobuf encoder ────────────────────────────────────────────────── +# Wire types: 0=varint, 1=64-bit, 2=length-delimited, 5=32-bit + +def _varint(value: int) -> bytes: + """Encode a non-negative integer as a protobuf varint.""" + bits = [] + value = int(value) + while True: + b = value & 0x7F + value >>= 7 + if value: + bits.append(b | 0x80) + else: + bits.append(b) + break + return bytes(bits) + + +def _field(field_num: int, wire_type: int, data: bytes) -> bytes: + tag = (field_num << 3) | wire_type + return _varint(tag) + data + + +def _field_varint(field_num: int, value: int) -> bytes: + return _field(field_num, 0, _varint(value)) + + +def _field_bytes(field_num: int, data: bytes) -> bytes: + return _field(field_num, 2, _varint(len(data)) + data) + + +def _field_string(field_num: int, s: str) -> bytes: + return _field_bytes(field_num, s.encode("utf-8")) + + +def encode_user(uid: int, encrypt_uid: str = "", source: int = 0) -> bytes: + """TechwolfUser { uid=1, name=2, source=7 }""" + buf = _field_varint(1, uid) + if encrypt_uid: + buf += _field_string(2, encrypt_uid) + if source: + buf += _field_varint(7, source) + return buf + + +def encode_body(text: str) -> bytes: + """TechwolfMessageBody { type=1, templateId=2, text=3 }""" + buf = _field_varint(1, 1) # type = 1 (text) + buf += _field_varint(2, 1) # templateId = 1 + buf += _field_string(3, text) + return buf + + +def encode_message( + from_uid: int, + from_encrypt_uid: str, + to_uid: int, + to_encrypt_uid: str, + text: str, + temp_id: int, +) -> bytes: + """TechwolfMessage { from=1, to=2, type=3, mid=4, cmid=11, body=6 }""" + from_bytes = encode_user(from_uid, from_encrypt_uid) + to_bytes = encode_user(to_uid, to_encrypt_uid) + body_bytes = encode_body(text) + + buf = _field_bytes(1, from_bytes) # from + buf += _field_bytes(2, to_bytes) # to + buf += _field_varint(3, 1) # type = 1 (text) + buf += _field_varint(4, temp_id) # mid + buf += _field_varint(11, temp_id) # cmid + buf += _field_bytes(6, body_bytes) # body + return buf + + +def encode_chat_protocol(message_bytes: bytes) -> bytes: + """TechwolfChatProtocol { type=1, messages=3 }""" + buf = _field_varint(1, 1) # type = 1 (message) + buf += _field_bytes(3, message_bytes) # messages[0] + return buf + + +def build_text_message( + from_uid: int, + from_encrypt_uid: str, + to_uid: int, + to_encrypt_uid: str, + text: str, +) -> bytes: + """Build a complete Protobuf-encoded chat message payload.""" + temp_id = int(time.time() * 1000) + msg = encode_message(from_uid, from_encrypt_uid, to_uid, to_encrypt_uid, text, temp_id) + return encode_chat_protocol(msg) + + +# ── MQTT client ─────────────────────────────────────────────────────────────── + +class BossMQTTChat: + """MQTT over WSS client for sending messages as a geek (job seeker). + + Auth: + userName = page_token + "|0" (from /wapi/zpuser/wap/getUserInfo.json → token) + password = wt2 (from /wapi/zppassport/get/wt → zpData.wt2) + Cookie header required for WS 101 upgrade (403 without it) + + Usage: + with BossMQTTChat(page_token, wt2, cookies) as chat: + chat.send(from_uid, from_enc, to_uid, to_enc, "Hello!") + """ + + WS_SERVERS = ["ws6.zhipin.com", "ws.zhipin.com", "ws2.zhipin.com"] + PORT = 443 + PATH = "/chatws" + TOPIC = "chat" + + def __init__(self, page_token: str, wt2: str, cookies: dict | None = None, timeout: float = 10.0): + self._page_token = page_token + self._wt2 = wt2 + self._cookies = cookies or {} + self._timeout = timeout + self._client = None + self._connected = threading.Event() + self._error: str | None = None + + def _make_client(self): + try: + import paho.mqtt.client as mqtt + except ImportError as exc: + raise RuntimeError( + "paho-mqtt is required for chat. Run: pip install paho-mqtt" + ) from exc + + import uuid + client_id = f"ws-{''.join(str(uuid.uuid4()).replace('-','').upper()[:16])}" + + # paho-mqtt 2.x requires CallbackAPIVersion + try: + client = mqtt.Client( + callback_api_version=mqtt.CallbackAPIVersion.VERSION1, + client_id=client_id, + transport="websockets", + ) + except AttributeError: + # paho-mqtt 1.x + client = mqtt.Client(client_id=client_id, transport="websockets") + + client.tls_set() + + # Build Cookie header — required for 101 upgrade (403 without it) + cookie_str = "; ".join(f"{k}={v}" for k, v in self._cookies.items()) + client.ws_set_options( + path=self.PATH, + headers={ + "Origin": "https://www.zhipin.com", + "Cookie": cookie_str, + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36" + ), + }, + ) + client.username_pw_set( + username=f"{self._page_token}|0", + password=self._wt2, + ) + client.on_connect = self._on_connect + client.on_disconnect = self._on_disconnect + client.on_message = self._on_message + return client + + def _on_connect(self, client, userdata, flags, rc): + if rc == 0: + logger.debug("MQTT connected") + self._connected.set() + else: + self._error = f"MQTT connect failed: rc={rc}" + self._connected.set() + + def _on_disconnect(self, client, userdata, rc): + logger.debug("MQTT disconnected: rc=%d", rc) + + def _on_message(self, client, userdata, msg): + logger.debug("MQTT message on %s: %d bytes", msg.topic, len(msg.payload)) + + def __enter__(self) -> "BossMQTTChat": + self._client = self._make_client() + server = self.WS_SERVERS[0] + logger.debug("Connecting to %s:%d%s", server, self.PORT, self.PATH) + self._client.connect(server, self.PORT, keepalive=25) + self._client.loop_start() + if not self._connected.wait(timeout=self._timeout): + self._client.loop_stop() + raise RuntimeError(f"MQTT connection timed out after {self._timeout}s") + if self._error: + self._client.loop_stop() + raise RuntimeError(self._error) + return self + + def __exit__(self, *args): + if self._client: + self._client.loop_stop() + self._client.disconnect() + self._client = None + + def send( + self, + from_uid: int, + from_encrypt_uid: str, + to_uid: int, + to_encrypt_uid: str, + text: str, + ) -> None: + """Send a text message via MQTT.""" + if not self._client: + raise RuntimeError("Not connected. Use as context manager.") + payload = build_text_message(from_uid, from_encrypt_uid, to_uid, to_encrypt_uid, text) + result = self._client.publish(self.TOPIC, payload, qos=1, retain=False) + result.wait_for_publish(timeout=self._timeout) + logger.debug("Message published: mid=%d", result.mid) diff --git a/pyproject.toml b/pyproject.toml index 270269c..d22d036 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "httpx>=0.27", "browser-cookie3>=0.19", "qrcode>=7.0", + "paho-mqtt>=1.6,<3.0", ] [project.optional-dependencies] diff --git a/tests/test_cli.py b/tests/test_cli.py index a9edd94..a9655c3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -36,6 +36,8 @@ def test_all_commands_registered(self): "search", "recommend", "cities", "detail", "show", "export", "history", "applied", "interviews", "chat", "greet", "batch-greet", + "messages", "unread", "reply", "chat-history", + "send-resume", "request-phone", "request-wechat", "accept", ] for cmd in expected: assert cmd in result.output, f"Command '{cmd}' not found in CLI help" @@ -49,6 +51,8 @@ class TestCommandHelp: "search", "recommend", "cities", "detail", "show", "export", "history", "applied", "interviews", "chat", "greet", "batch-greet", + "messages", "unread", "reply", "chat-history", "accept", + "send-resume", "request-phone", "request-wechat", ]) def test_help(self, cmd: str): result = runner.invoke(cli, [cmd, "--help"]) @@ -87,6 +91,11 @@ def test_history_has_options(self): assert "--page" in result.output or "-p" in result.output assert "--json" in result.output + def test_chat_history_has_options(self): + result = runner.invoke(cli, ["chat-history", "--help"]) + assert "-n" in result.output or "--count" in result.output + assert "--json" in result.output + # ── Auth commands (mocked) ────────────────────────────────────────── @@ -951,3 +960,289 @@ def test_batch_greet_refreshes_after_session_expiry(self): assert result.exit_code == 0 assert "1/1" in result.output clear_credential.assert_not_called() + + +# ── Geek Chat commands (mocked) ───────────────────────────────────── + + +def _mock_cred(): + cred = MagicMock() + cred.cookies = {"__zp_stoken__": "s", "wt2": "tok", "wbg": "2", "zp_at": "3"} + return cred + + +def _mock_client_ctx(client_mock): + """Return a context manager that yields the mock client.""" + ctx = MagicMock() + ctx.__enter__ = MagicMock(return_value=client_mock) + ctx.__exit__ = MagicMock(return_value=False) + return ctx + + +class TestMessagesCommand: + """Tests for `boss messages`.""" + + def test_help(self): + result = runner.invoke(cli, ["messages", "--help"]) + assert result.exit_code == 0 + assert "-n" in result.output or "--count" in result.output + + def test_messages_no_auth(self): + with patch("boss_cli.commands._common.get_credential", return_value=None): + result = runner.invoke(cli, ["messages", "--json"]) + assert result.exit_code != 0 or "not_authenticated" in result.output + + def test_messages_empty(self): + cred = _mock_cred() + client = MagicMock() + client.get_geek_friend_list.return_value = {"friendList": []} + client.get_user_info.return_value = {"userId": 123} + with patch("boss_cli.auth.get_credential", return_value=cred), \ + patch("boss_cli.commands._common.BossClient", return_value=_mock_client_ctx(client)): + result = runner.invoke(cli, ["messages", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["ok"] is True + assert data["data"]["friends"] == [] + + def test_messages_with_friends(self): + cred = _mock_cred() + client = MagicMock() + client.get_geek_friend_list.return_value = {"friendList": [ + {"friendId": 111, "name": "张老板", "brandName": "ACME", "jobName": "Python工程师", + "encryptFriendId": "enc111"}, + ]} + client.get_user_info.return_value = {"userId": 999} + client.get_geek_last_messages.return_value = [{ + "uid": 999, + "lastTime": "10:00", + "lastTS": 1000000, + "lastMsgInfo": {"msgId": 1, "showText": "你好", "fromId": 111, "toId": 999, + "status": 0, "msgTime": 1000000}, + }] + with patch("boss_cli.auth.get_credential", return_value=cred), \ + patch("boss_cli.commands._common.BossClient", return_value=_mock_client_ctx(client)): + result = runner.invoke(cli, ["messages", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["ok"] is True + assert len(data["data"]["friends"]) == 1 + assert len(data["data"]["messages"]) == 1 + + +class TestUnreadCommand: + """Tests for `boss unread`.""" + + def test_help(self): + result = runner.invoke(cli, ["unread", "--help"]) + assert result.exit_code == 0 + + def test_unread_no_auth(self): + with patch("boss_cli.commands._common.get_credential", return_value=None): + result = runner.invoke(cli, ["unread", "--json"]) + assert result.exit_code != 0 or "not_authenticated" in result.output + + def test_unread_filters_self_messages(self): + """Messages sent by me (fromId == my_uid) should not appear in unread.""" + cred = _mock_cred() + client = MagicMock() + my_uid = 999 + client.get_geek_friend_list.return_value = {"friendList": [ + {"friendId": 111, "name": "张老板", "brandName": "ACME", "jobName": "工程师", + "encryptFriendId": "enc111"}, + {"friendId": 222, "name": "李老板", "brandName": "XYZ", "jobName": "开发", + "encryptFriendId": "enc222"}, + ]} + client.get_user_info.return_value = {"userId": my_uid} + client.get_geek_last_messages.return_value = [ + # from boss → unread + {"uid": my_uid, "lastTime": "10:00", "lastTS": 2000, + "lastMsgInfo": {"msgId": 2, "showText": "感兴趣吗", "fromId": 111, "toId": my_uid, + "status": 0, "msgTime": 2000}}, + # from me → NOT unread + {"uid": my_uid, "lastTime": "09:00", "lastTS": 1000, + "lastMsgInfo": {"msgId": 1, "showText": "好的", "fromId": my_uid, "toId": 222, + "status": 2, "msgTime": 1000}}, + ] + with patch("boss_cli.auth.get_credential", return_value=cred), \ + patch("boss_cli.commands._common.BossClient", return_value=_mock_client_ctx(client)): + result = runner.invoke(cli, ["unread", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["ok"] is True + unread = data["data"]["unread"] + assert len(unread) == 1 + assert unread[0]["friend"]["friendId"] == 111 + + +class TestExchangeCommands: + """Tests for send-resume / request-phone / request-wechat / accept.""" + + def test_send_resume_help(self): + result = runner.invoke(cli, ["send-resume", "--help"]) + assert result.exit_code == 0 + assert "friendId" in result.output.lower() or "FRIEND_ID" in result.output + + def test_request_phone_help(self): + result = runner.invoke(cli, ["request-phone", "--help"]) + assert result.exit_code == 0 + + def test_request_wechat_help(self): + result = runner.invoke(cli, ["request-wechat", "--help"]) + assert result.exit_code == 0 + + def test_accept_help(self): + result = runner.invoke(cli, ["accept", "--help"]) + assert result.exit_code == 0 + assert "--reject" in result.output + + def test_send_resume_success(self): + cred = _mock_cred() + client = MagicMock() + client.get_geek_boss_data.return_value = {"securityId": "sec123"} + client.geek_exchange_request.return_value = {"type": 3, "status": 0} + with patch("boss_cli.auth.get_credential", return_value=cred), \ + patch("boss_cli.commands._common.BossClient", return_value=_mock_client_ctx(client)): + result = runner.invoke(cli, ["send-resume", "111", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["ok"] is True + client.geek_exchange_request.assert_called_once_with(111, "sec123", 3) + + def test_request_phone_success(self): + cred = _mock_cred() + client = MagicMock() + client.get_geek_boss_data.return_value = {"securityId": "sec123"} + client.geek_exchange_request.return_value = {"type": 1, "status": 0} + with patch("boss_cli.auth.get_credential", return_value=cred), \ + patch("boss_cli.commands._common.BossClient", return_value=_mock_client_ctx(client)): + result = runner.invoke(cli, ["request-phone", "111", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["ok"] is True + client.geek_exchange_request.assert_called_once_with(111, "sec123", 1) + + def test_request_wechat_success(self): + cred = _mock_cred() + client = MagicMock() + client.get_geek_boss_data.return_value = {"securityId": "sec123"} + client.geek_exchange_request.return_value = {"type": 2, "status": 0} + with patch("boss_cli.auth.get_credential", return_value=cred), \ + patch("boss_cli.commands._common.BossClient", return_value=_mock_client_ctx(client)): + result = runner.invoke(cli, ["request-wechat", "111", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["ok"] is True + client.geek_exchange_request.assert_called_once_with(111, "sec123", 2) + + def test_accept_resume_request(self): + cred = _mock_cred() + client = MagicMock() + client.get_geek_last_messages.return_value = [{ + "uid": 999, + "lastMsgInfo": {"msgId": 12345, "showText": "我想要一份您的附件简历,您是否同意", + "fromId": 111, "toId": 999, "status": 0, "msgTime": 1000}, + }] + client.get_geek_boss_data.return_value = {"securityId": "sec123"} + client.geek_accept_exchange.return_value = {} + with patch("boss_cli.auth.get_credential", return_value=cred), \ + patch("boss_cli.commands._common.BossClient", return_value=_mock_client_ctx(client)): + result = runner.invoke(cli, ["accept", "111", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["ok"] is True + client.geek_accept_exchange.assert_called_once_with(111, 12345, "sec123") + + def test_accept_wechat_request(self): + cred = _mock_cred() + client = MagicMock() + client.get_geek_last_messages.return_value = [{ + "uid": 999, + "lastMsgInfo": {"msgId": 99999, "showText": "我想要和您交换微信,您是否同意", + "fromId": 111, "toId": 999, "status": 0, "msgTime": 1000}, + }] + client.get_geek_boss_data.return_value = {"securityId": "sec123"} + client.geek_accept_wechat.return_value = {} + with patch("boss_cli.auth.get_credential", return_value=cred), \ + patch("boss_cli.commands._common.BossClient", return_value=_mock_client_ctx(client)): + result = runner.invoke(cli, ["accept", "111", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["ok"] is True + client.geek_accept_wechat.assert_called_once_with(111, 99999, "sec123") + + def test_reject_request(self): + cred = _mock_cred() + client = MagicMock() + client.get_geek_last_messages.return_value = [{ + "uid": 999, + "lastMsgInfo": {"msgId": 12345, "showText": "我想要一份您的附件简历,您是否同意", + "fromId": 111, "toId": 999, "status": 0, "msgTime": 1000}, + }] + client.get_geek_boss_data.return_value = {"securityId": "sec123"} + client.geek_reject_exchange.return_value = {} + with patch("boss_cli.auth.get_credential", return_value=cred), \ + patch("boss_cli.commands._common.BossClient", return_value=_mock_client_ctx(client)): + result = runner.invoke(cli, ["accept", "111", "--reject", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["ok"] is True + client.geek_reject_exchange.assert_called_once_with(111, 12345, "sec123") + + def test_accept_no_exchange_request(self): + """Should fail if latest message is not an exchange request.""" + cred = _mock_cred() + client = MagicMock() + client.get_geek_last_messages.return_value = [{ + "uid": 999, + "lastMsgInfo": {"msgId": 1, "showText": "你好,请问有时间聊聊吗", + "fromId": 111, "toId": 999, "status": 0, "msgTime": 1000}, + }] + client.get_geek_boss_data.return_value = {"securityId": "sec123"} + with patch("boss_cli.auth.get_credential", return_value=cred), \ + patch("boss_cli.commands._common.BossClient", return_value=_mock_client_ctx(client)): + result = runner.invoke(cli, ["accept", "111", "--json"]) + assert result.exit_code != 0 or ( + json.loads(result.output)["ok"] is False + ) + + +class TestProtobufEncoding: + """Tests for the hand-written Protobuf encoder in mqtt_chat.py.""" + + def test_varint_single_byte(self): + from boss_cli.mqtt_chat import _varint + assert _varint(0) == b'\x00' + assert _varint(1) == b'\x01' + assert _varint(127) == b'\x7f' + + def test_varint_multibyte(self): + from boss_cli.mqtt_chat import _varint + assert _varint(128) == b'\x80\x01' + assert _varint(300) == b'\xac\x02' + assert _varint(16383) == b'\xff\x7f' + assert _varint(16384) == b'\x80\x80\x01' + + def test_build_text_message_is_bytes(self): + from boss_cli.mqtt_chat import build_text_message + payload = build_text_message(111, "encA", 222, "encB", "hello") + assert isinstance(payload, bytes) + assert len(payload) > 0 + + def test_build_text_message_starts_with_type1(self): + from boss_cli.mqtt_chat import build_text_message, _varint + payload = build_text_message(111, "encA", 222, "encB", "hi") + # First field: f1 (type), varint, value=1 → tag=0x08, value=0x01 + assert payload[0] == 0x08 + assert payload[1] == 0x01 + + def test_build_text_message_contains_text(self): + from boss_cli.mqtt_chat import build_text_message + payload = build_text_message(111, "encA", 222, "encB", "测试消息") + assert "测试消息".encode("utf-8") in payload + + def test_build_text_message_different_uids(self): + from boss_cli.mqtt_chat import build_text_message + p1 = build_text_message(111, "encA", 222, "encB", "hi") + p2 = build_text_message(333, "encC", 444, "encD", "hi") + assert p1 != p2 diff --git a/uv.lock b/uv.lock index 437a938..b0cf61b 100644 --- a/uv.lock +++ b/uv.lock @@ -223,7 +223,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -362,6 +362,7 @@ dependencies = [ { name = "browser-cookie3" }, { name = "click" }, { name = "httpx" }, + { name = "paho-mqtt" }, { name = "qrcode" }, { name = "rich" }, ] @@ -384,6 +385,7 @@ requires-dist = [ { name = "camoufox", marker = "extra == 'browser'", specifier = ">=0.4" }, { name = "click", specifier = ">=8.0" }, { name = "httpx", specifier = ">=0.27" }, + { name = "paho-mqtt", specifier = ">=1.6,<3.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pyyaml", marker = "extra == 'yaml'", specifier = ">=6.0" }, { name = "qrcode", specifier = ">=7.0" }, @@ -839,6 +841,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "paho-mqtt" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/15/0a6214e76d4d32e7f663b109cf71fb22561c2be0f701d67f93950cd40542/paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834", size = 148848, upload-time = "2024-04-29T19:52:55.591Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" }, +] + [[package]] name = "platformdirs" version = "4.9.4"