#!/usr/bin/env python3 """Run a PII-safe live audit of every read-only Chat Shortcut. The audit exercises the real Shortcut entry point and, when DWS_DUMP_RAW is enabled by the locally built binary, captures the lower MCP text response from the same invocation. Raw business responses are kept in memory only. The written JSON/Markdown reports contain shapes, counts, tool names, and redacted error facts, but no message text, user names, IDs, or invite/download URLs. This is deliberately a live-account audit, not a mock/Help contract check. """ from __future__ import annotations import argparse import json import os import re import signal import subprocess import sys import time from dataclasses import dataclass from pathlib import Path from typing import Any SCRIPT_DIR = Path(__file__).resolve().parent sys.path.insert(0, str(SCRIPT_DIR)) from shortcut_real_result import count_projection_items, payload_indicates_error # noqa: E402 READ_SHORTCUTS = ( "+at-me", "+bot-find", "+bot-search", "+category-list", "+category-list-conversations", "+chat-bots", "+chat-get-by-id", "+chat-invite-url", "+chat-list-all", "+chat-list-join-requests", "+chat-list-mine", "+chat-members-list", "+chat-members-get", "+chat-messages", "+chat-role-list", "+chat-role-query-user", "+chat-search", "+conversation-info", "+conversation-list", "+conversation-list-top", "+group-members", "+messages-list", "+messages-list-direct", "+messages-list-pin", "+messages-list-unread-conversations", "+messages-mget", "+messages-query-send-status", "+messages-read-status", "+messages-resource-download", "+messages-resource-url", "+my-groups", "+search-msg", "+thread-replies", "+unread-chats", ) LIST_PATHS_BY_TOOL = { "search_at_me_message": ( ("result", "conversationMessagesList", "*", "messages"), ), "search_bots": (("result", "bots"), ("result", "list")), "search_my_robots": (("result", "robots"), ("result", "list")), "list_user_define_conv_categories": ( ("result", "categories"), ("result", "categoryList"), ), "list_conversations_by_category": ( ("result", "conversations"), ("result", "conversationList"), ), "list_group_bots": (("result", "bots"), ("result", "robots")), "list_my_groups_pagination": (("result", "groups"),), "list_owned_or_admin_groups": (("result", "groups"),), "list_group_member_by_ids": ( ("result", "members"), ("result", "list"), ), "list_custom_group_roles": (("result", "roles"), ("result", "list")), "list_all_conversations": ( ("result", "conversations"), ("result", "conversationList"), ), "list_top_conversations": ( ("result", "conversations"), ("result", "conversationList"), ), "get_group_members": (("result", "list"), ("result", "members")), "list_conversation_message_v2": ( ("result", "messages"), ("result", "messageList"), ), "list_individual_chat_message": ( ("result", "messages"), ("result", "messageList"), ), "list_pin_messages": ( ("result", "messages"), ("result", "pinMessages"), ("result", "list"), ), "unread_message_conversation_list": ( ("result", "conversations"), ("result", "conversationList"), ), "list_messages_by_ids": (("result", "messages"), ("result", "list")), "search_messages_by_keyword": ( ("result", "messages"), ("result", "messageList"), ("result", "list"), ), "list_topic_replies": ( ("result", "messages"), ("result", "replies"), ("result", "list"), ), } @dataclass class Capture: command: str argv: list[str] exit_code: int | None stdout: str stderr: str raw_calls: list[tuple[str, str, Any]] duration_ms: int timed_out: bool = False def upper(self) -> Any: try: return json.loads(self.stdout) except (json.JSONDecodeError, TypeError): return None def parse_raw_calls(stderr: str) -> tuple[list[tuple[str, str, Any]], str]: raw_calls: list[tuple[str, str, Any]] = [] clean_lines: list[str] = [] for line in (stderr or "").splitlines(): if not line.startswith("DWSRAW\t"): clean_lines.append(line) continue fields = line.split("\t", 3) if len(fields) != 4: continue try: raw_calls.append((fields[1], fields[2], json.loads(fields[3]))) except json.JSONDecodeError: raw_calls.append((fields[1], fields[2], fields[3])) return raw_calls, "\n".join(clean_lines) def run_capture(binary: Path, command: str, args: list[str], timeout: int) -> Capture: argv = [str(binary), "chat", command, *args, "--format", "json"] env = os.environ.copy() env["DWS_DUMP_RAW"] = "1" started = time.monotonic() proc = subprocess.Popen( argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env, start_new_session=True, ) try: stdout, stderr = proc.communicate(timeout=timeout) raw_calls, clean_stderr = parse_raw_calls(stderr) return Capture( command=command, argv=argv, exit_code=proc.returncode, stdout=stdout, stderr=clean_stderr, raw_calls=raw_calls, duration_ms=round((time.monotonic() - started) * 1000), ) except subprocess.TimeoutExpired as exc: # dws may have transport children which inherit its output pipes. # Killing only the immediate process leaves those descendants alive and # communicate() can block forever despite the advertised timeout. os.killpg(proc.pid, signal.SIGKILL) stdout, stderr = proc.communicate() raw_calls, clean_stderr = parse_raw_calls(stderr) return Capture( command=command, argv=argv, exit_code=None, stdout=stdout, stderr=clean_stderr, raw_calls=raw_calls, duration_ms=round((time.monotonic() - started) * 1000), timed_out=True, ) def at_path(value: Any, path: tuple[str, ...]) -> list[Any] | None: current = value for index, part in enumerate(path): if part == "*": if not isinstance(current, list): return None remainder = path[index + 1 :] combined: list[Any] = [] for item in current: found = at_path(item, remainder) if isinstance(found, list): combined.extend(found) return combined if not isinstance(current, dict) or part not in current: return None current = current[part] return current if isinstance(current, list) else None def lower_count(tool: str, raw: Any) -> int | None: for path in LIST_PATHS_BY_TOOL.get(tool, ()): found = at_path(raw, path) if found is not None: return len(found) return None def distinct_lower_message_count( raw_calls: list[tuple[str, str, Any]], tool: str ) -> int | None: """Count unique messages across all pages without retaining their IDs. Time-boundary pagination may repeat the boundary message on the following page. Summing page lengths would therefore report a false projection mismatch for an otherwise correct de-duplicating Shortcut. """ metrics = distinct_lower_message_metrics(raw_calls, tool) return metrics[0] if metrics is not None else None def distinct_lower_message_metrics( raw_calls: list[tuple[str, str, Any]], tool: str ) -> tuple[int, int, int] | None: """Aggregate count/reaction/thread facts over one stable message-ID set.""" seen: set[str] = set() reaction_messages: set[str] = set() thread_messages: set[str] = set() anonymous = 0 found_collection = False identity_keys = ("openMessageId", "openMsgId", "messageId", "msgId", "id") for _, call_tool, raw in raw_calls: if call_tool != tool: continue rows: list[Any] | None = None for path in LIST_PATHS_BY_TOOL.get(tool, ()): rows = at_path(raw, path) if rows is not None: break if rows is None: continue found_collection = True for row in rows: identity = "" if isinstance(row, dict): for key in identity_keys: value = row.get(key) if value not in (None, ""): identity = f"id:{value}" break if not identity: identity = f"anonymous:{anonymous}" anonymous += 1 seen.add(identity) if reaction_message_count(row, upper=False) > 0: reaction_messages.add(identity) if thread_message_count(row, upper=False) > 0: thread_messages.add(identity) if not found_collection: return None return len(seen), len(reaction_messages), len(thread_messages) def distinct_lower_collection_count( raw_calls: list[tuple[str, str, Any]], tool: str, identity_keys: tuple[str, ...], ) -> int | None: """Count unique rows across every lower page for one tool.""" seen: set[str] = set() anonymous = 0 found_collection = False for _, call_tool, raw in raw_calls: if call_tool != tool: continue rows: list[Any] | None = None for path in LIST_PATHS_BY_TOOL.get(tool, ()): rows = at_path(raw, path) if rows is not None: break if rows is None: continue found_collection = True for row in rows: if not isinstance(row, dict): anonymous += 1 continue identity = "" for key in identity_keys: value = row.get(key) if value not in (None, ""): identity = str(value) break if identity: seen.add(identity) else: anonymous += 1 if not found_collection: return None return len(seen) + anonymous def response_is_error(value: Any) -> bool: if not isinstance(value, dict): return False if value.get("success") is False: return True if str(value.get("status") or "").lower() == "error": return True for key in ("error", "errorCode", "error_code", "code"): item = value.get(key) if item not in (None, False, "", 0, "0", "ok", "success", "succeed"): return True return False def error_facts(stderr: str) -> dict[str, Any]: facts: dict[str, Any] = {} try: value = json.loads(stderr) error = value.get("error", value) if isinstance(value, dict) else {} if isinstance(error, dict): for key in ("category", "code", "reason", "server_error_code", "server_key"): if key in error: facts[key] = error[key] message = str(error.get("message") or "") else: message = stderr except json.JSONDecodeError: message = stderr lowered = message.lower() for marker, label in ( ("permission", "permission"), ("not found", "not_found"), ("不存在", "not_found"), ("invalid", "invalid_argument"), ("必填", "missing_argument"), ("超时", "timeout"), ): if marker in lowered: facts["message_class"] = label break return facts def shape(value: Any) -> dict[str, Any]: if isinstance(value, dict): return {"type": "object", "keys": sorted(value)} if isinstance(value, list): return {"type": "array", "length": len(value)} if value is None: return {"type": "none"} return {"type": type(value).__name__} def reaction_message_count(value: Any, *, upper: bool) -> int: """Count reaction-bearing message objects without retaining business values.""" count = 0 if isinstance(value, dict): keys = ("reactions", "emotionReplyList", "reactionList") if upper else ( "emotionReplyList", "reactionList", "reactions", ) if any(value.get(key) not in (None, [], {}) for key in keys): count += 1 for item in value.values(): count += reaction_message_count(item, upper=upper) elif isinstance(value, list): for item in value: count += reaction_message_count(item, upper=upper) return count def primary_projection_for_message_metrics(value: Any) -> Any: """Select one canonical projected collection for message-level metrics. Some stable Shortcut payloads publish compatibility aliases such as ``messages`` and ``items`` with the same rows. Walking the entire envelope would count reactions and thread IDs once per alias and report a false projection mismatch even though the lower and upper message sets agree. """ if not isinstance(value, dict): return value for key in ("messages", "replies", "results", "items"): items = value.get(key) if isinstance(items, list): return items return value def thread_message_count(value: Any, *, upper: bool) -> int: keys = {"threadId"} if upper else { "openConvThreadId", "openConversationThreadId", "threadId", "topicId", } count = 0 if isinstance(value, dict): if any(value.get(key) not in (None, "") for key in keys): count += 1 for item in value.values(): count += thread_message_count(item, upper=upper) elif isinstance(value, list): for item in value: count += thread_message_count(item, upper=upper) return count def pagination_meta(value: Any) -> dict[str, Any]: """Extract only completeness facts; never retain cursor values.""" if isinstance(value, dict): has_more = None for key in ("hasMore", "has_more"): if key in value and isinstance(value[key], bool): has_more = value[key] break cursor_present = any( value.get(key) not in (None, "", 0, "0") for key in ("nextCursor", "next_cursor", "nextToken", "next_token", "pageToken", "page_token") ) next_page = value.get("nextPage") resume_present = cursor_present or ( isinstance(next_page, dict) and next_page.get("time") not in (None, "") ) if has_more is not None or resume_present: return { "has_more": has_more, "next_cursor_present": cursor_present, "resume_present": resume_present, } for item in value.values(): found = pagination_meta(item) if found: return found elif isinstance(value, list): for item in value: found = pagination_meta(item) if found: return found return {} def summarize_capture(capture: Capture) -> dict[str, Any]: upper = capture.upper() raw = capture.raw_calls[-1][2] if capture.raw_calls else None tool = capture.raw_calls[-1][1] if capture.raw_calls else None upper_count = count_projection_items(capture.stdout) expected_lower_count = lower_count(tool, raw) if tool else None pagination_raw = raw lower_message_projection = raw lower_message_metrics: tuple[int, int, int] | None = None if capture.command == "+thread-replies" and tool == "list_topic_replies": lower_message_metrics = distinct_lower_message_metrics( capture.raw_calls, "list_topic_replies" ) expected_lower_count = lower_message_metrics[0] if lower_message_metrics else None elif capture.command == "+at-me" and tool == "search_at_me_message": lower_message_metrics = distinct_lower_message_metrics( capture.raw_calls, "search_at_me_message" ) expected_lower_count = lower_message_metrics[0] if lower_message_metrics else None elif ( capture.command == "+messages-list-direct" and tool == "list_individual_chat_message" ): lower_message_metrics = distinct_lower_message_metrics( capture.raw_calls, "list_individual_chat_message" ) expected_lower_count = lower_message_metrics[0] if lower_message_metrics else None elif capture.command in {"+chat-list-all", "+my-groups"} and tool == ( "list_my_groups_pagination" ): expected_lower_count = distinct_lower_collection_count( capture.raw_calls, "list_my_groups_pagination", ( "openConversationId", "openconversation_id", "conversationId", "openCid", "cid", "id", ), ) if capture.command == "+chat-members-list": counts = upper.get("counts", {}) if isinstance(upper, dict) else {} upper_count = counts.get("total") if isinstance(counts, dict) else None expected_lower_count = 0 for _, call_tool, call_raw in capture.raw_calls: if call_tool in {"get_group_members", "list_group_bots"}: item_count = lower_count(call_tool, call_raw) if item_count is not None: expected_lower_count += item_count if call_tool == "get_group_members": pagination_raw = call_raw elif capture.command == "+messages-resource-download": upper_count = ( 1 if isinstance(upper, dict) and isinstance(upper.get("sizeBytes"), (int, float)) and upper.get("sizeBytes") >= 0 and bool(upper.get("localPath")) else 0 ) expected_lower_count = ( 1 if first_string(raw, "resourceUrl", "downloadUrl") else 0 ) exact_passthrough = bool(capture.raw_calls and upper == raw) upper_message_projection = primary_projection_for_message_metrics(upper) upper_reactions = reaction_message_count(upper_message_projection, upper=True) upper_threads = thread_message_count(upper_message_projection, upper=True) if lower_message_metrics is not None: _, lower_reactions, lower_threads = lower_message_metrics else: lower_reactions = reaction_message_count(lower_message_projection, upper=False) lower_threads = thread_message_count(lower_message_projection, upper=False) upper_pagination = pagination_meta(upper) lower_pagination = pagination_meta(pagination_raw) upper_explicitly_incomplete = ( isinstance(upper, dict) and upper.get("complete") is False and ( upper.get("hasMore") is True or bool(upper.get("failures")) or bool(upper.get("truncatedByPageLimit")) or bool(upper.get("truncatedByResultLimit")) ) ) if capture.timed_out: status = "timeout" elif capture.exit_code != 0: status = "backend_error" if capture.stderr else "cli_error" elif payload_indicates_error(capture.stdout) or response_is_error(raw): status = "error_envelope" elif not capture.raw_calls: status = "no_lower_capture" elif upper_explicitly_incomplete: status = "incomplete" elif expected_lower_count == 0 and upper_count in (None, 0): # An empty collection is useful evidence, but it is not a successful # non-empty business-data E2E. This must precede exact-passthrough so a # raw empty list cannot be promoted to pass merely because projection # preserved it byte-for-byte. status = "pass_empty" elif expected_lower_count is not None and upper_count != expected_lower_count: status = "projection_mismatch" elif upper_count == 0: # The upper projection is empty but the lower response shape does not # expose a countable collection, so correctness cannot be proven. status = "projection_unverified" elif exact_passthrough: status = "pass" elif lower_reactions > 0 and upper_reactions != lower_reactions: status = "projection_mismatch" elif lower_threads > 0 and upper_threads != lower_threads: status = "projection_mismatch" elif lower_pagination.get("has_more") is True and ( upper_pagination.get("has_more") is not True or not upper_pagination.get("resume_present") ): status = "pagination_mismatch" else: status = "pass" return { "command": capture.command, "status": status, "exit_code": capture.exit_code, "duration_ms": capture.duration_ms, "upper": { **shape(upper), "count": upper_count, "reaction_message_count": upper_reactions, "thread_message_count": upper_threads, "pagination": upper_pagination, }, "lower": { "call_count": len(capture.raw_calls), "tools": [f"{server}/{name}" for server, name, _ in capture.raw_calls], "shape": shape(raw), "count": expected_lower_count, "reaction_message_count": lower_reactions, "thread_message_count": lower_threads, "pagination": lower_pagination, }, "exact_passthrough": exact_passthrough, "error": error_facts(capture.stderr) if capture.stderr else {}, } def blocked(command: str, requirement: str) -> dict[str, Any]: return { "command": command, "status": "fixture_blocked", "requirement": requirement, } def dig_values(value: Any, keys: set[str]) -> list[Any]: found: list[Any] = [] if isinstance(value, dict): for key, item in value.items(): if key in keys and item not in (None, "", [], {}): found.append(item) found.extend(dig_values(item, keys)) elif isinstance(value, list): for item in value: found.extend(dig_values(item, keys)) elif isinstance(value, str): stripped = value.strip() if stripped.startswith(("{", "[")): try: embedded = json.loads(stripped) except json.JSONDecodeError: embedded = None if isinstance(embedded, (dict, list)): found.extend(dig_values(embedded, keys)) return found def first_string(value: Any, *keys: str) -> str: for item in dig_values(value, set(keys)): if isinstance(item, str) and item.strip(): return item.strip() if isinstance(item, (int, float)) and not isinstance(item, bool): return str(int(item)) return "" def first_list(value: Any, *keys: str) -> list[Any]: for item in dig_values(value, set(keys)): if isinstance(item, list): return item return [] def safe_keyword(messages: list[Any]) -> str: for message in messages: if not isinstance(message, dict): continue text = message.get("text") if not isinstance(text, str): continue for token in re.findall(r"[\u4e00-\u9fff]{2,6}|[A-Za-z0-9]{3,12}", text): if token: return token return "工作" def group_candidates(capture: Capture) -> list[dict[str, Any]]: raw = capture.raw_calls[-1][2] if capture.raw_calls else {} groups = first_list(raw, "groups") valid = [item for item in groups if isinstance(item, dict)] priority = {"OWNER": 0, "ADMIN": 1} return sorted(valid, key=lambda item: priority.get(str(item.get("myRole")), 2)) def merge_group_candidates( groups_all: Capture, groups_mine: Capture ) -> list[dict[str, Any]]: all_groups = group_candidates(groups_all) mine_groups = group_candidates(groups_mine) ordered: list[dict[str, Any]] = [] seen: set[str] = set() for item in mine_groups: group_id = str(item.get("openConversationId") or "") if not group_id or group_id in seen: continue # The owned/admin endpoint carries myRole and ownerOpenDingtalkId, # which are needed to locate a message sent by the current user. Prefer # that richer record; the all-groups page is only a fallback. ordered.append(item) seen.add(group_id) for item in all_groups: group_id = str(item.get("openConversationId") or "") if group_id and group_id not in seen: ordered.append(item) seen.add(group_id) return ordered def live_audit( binary: Path, timeout: int, max_group_probes: int, max_member_probes: int ) -> list[dict[str, Any]]: results: dict[str, dict[str, Any]] = {} captures: dict[str, Capture] = {} group_override = os.environ.get("DWS_AUDIT_GROUP_ID", "").strip() numeric_group_override = os.environ.get("DWS_AUDIT_NUMERIC_GROUP_ID", "").strip() open_task_override = os.environ.get("DWS_AUDIT_OPEN_TASK_ID", "").strip() topic_group_override = os.environ.get("DWS_AUDIT_TOPIC_GROUP_ID", "").strip() topic_id_override = os.environ.get("DWS_AUDIT_TOPIC_ID", "").strip() topic_page_size = os.environ.get("DWS_AUDIT_TOPIC_PAGE_SIZE", "").strip() media_group_override = os.environ.get("DWS_AUDIT_MEDIA_GROUP_ID", "").strip() media_message_override = os.environ.get("DWS_AUDIT_MEDIA_MESSAGE_ID", "").strip() media_resource_override = os.environ.get("DWS_AUDIT_MEDIA_RESOURCE_ID", "").strip() def run(command: str, *args: str, retries: int = 0) -> Capture: capture = run_capture(binary, command, list(args), timeout) for _ in range(retries): if summarize_capture(capture)["status"] not in { "backend_error", "cli_error", "error_envelope", "timeout", "no_lower_capture", }: break capture = run_capture(binary, command, list(args), timeout) captures[command] = capture results[command] = summarize_capture(capture) return capture category = run("+category-list") groups_all = run( "+chat-list-all", "--limit", "200", "--page-all", "--page-limit", "50" ) groups_mine = run("+chat-list-mine", "--limit", "200") run("+conversation-list", "--limit", "100") run("+conversation-list-top", "--limit", "100") run("+messages-list-unread-conversations", "--count", "100") run("+unread-chats", "--count", "100") run("+chat-list-join-requests", "--limit", "50") run("+at-me", "--days", "30", "--page-all", "--page-limit", "50") run("+bot-search", "--page", "1", "--size", "100") run("+bot-find", "--query", "机器人", "--limit", "20") run("+my-groups", "--page-all", "--page-limit", "50") category_id = first_string(category.upper(), "categoryId", "category_id") if category_id: run("+category-list-conversations", "--category-id", category_id) else: results["+category-list-conversations"] = blocked( "+category-list-conversations", "账号下需要至少一个真实会话分组 categoryId", ) candidates = merge_group_candidates(groups_all, groups_mine) group = candidates[0] if candidates else {} messages: Capture | None = None best_group: dict[str, Any] = {} best_score = -1 # A valid but quiet first group is not sufficient evidence for the # message-dependent Shortcuts. Walk real joined groups until one produces a # non-empty message page. Prefer a page that also supplies downstream # fixtures (a message sent by the current user, media, or a thread ID). # This prevents the audit from stopping at the first technically valid but # fixture-poor group. probe_candidates = candidates[:max_group_probes] if group_override: forced = next( ( candidate for candidate in candidates if str(candidate.get("openConversationId") or "") == group_override ), {"openConversationId": group_override}, ) probe_candidates = [forced] for candidate in probe_candidates: candidate_id = str(candidate.get("openConversationId") or "") if not candidate_id: continue probe = run_capture( binary, "+messages-list", [ "--group", candidate_id, "--time", time.strftime("%Y-%m-%d %H:%M:%S"), "--forward=false", "--limit", "50", ], timeout, ) probe_summary = summarize_capture(probe) if probe_summary["status"] not in {"pass", "pass_empty"}: continue rows = first_list(probe.upper(), "messages") own_open_id = ( str(candidate.get("ownerOpenDingtalkId") or "") if str(candidate.get("myRole") or "") == "OWNER" else "" ) own_message = any( isinstance(item, dict) and item.get("senderId") == own_open_id for item in rows ) probe_raw = probe.raw_calls[-1][2] if probe.raw_calls else {} has_resource = bool( first_string(probe_raw, "mediaId", "resourceId", "downloadCode") ) has_thread = bool( first_string( probe_raw, "openConvThreadId", "openConversationThreadId", "threadId", "topicId", ) ) score = ( int(probe_summary.get("upper", {}).get("count") or 0) + (1000 if own_message else 0) + (500 if has_resource else 0) + (500 if has_thread else 0) ) if score > best_score: best_score = score best_group = candidate messages = probe if messages is not None: group = best_group group_id = str(group.get("openConversationId") or "") group_name = str(group.get("title") or group.get("name") or "") if group_id: conversation = run("+conversation-info", "--group", group_id) run("+chat-invite-url", "--group", group_id, "--expires-seconds", "60") run("+chat-bots", "--group", group_id) run("+chat-role-list", "--group", group_id) run("+messages-list-pin", "--open-conversation-id", group_id, "--size", "100") if messages is None: messages = run( "+messages-list", "--group", group_id, "--time", time.strftime("%Y-%m-%d %H:%M:%S"), "--forward=false", "--limit", "50", ) else: captures["+messages-list"] = messages results["+messages-list"] = summarize_capture(messages) run( "+chat-messages", "--group", group_id, "--time", time.strftime("%Y-%m-%d %H:%M:%S"), "--direction", "older", "--limit", "50", retries=2, ) if group_name: run("+chat-search", "--query", group_name, "--limit", "20") members = run("+group-members", "--group", group_name) run("+chat-members-list", "--group", group_name) else: results["+chat-search"] = blocked("+chat-search", "真实群需要有可搜索标题") results["+group-members"] = blocked("+group-members", "真实群需要有可搜索标题") results["+chat-members-list"] = blocked( "+chat-members-list", "真实群需要有可搜索标题" ) members = None numeric_group_id = numeric_group_override or first_string( conversation.raw_calls[-1][2] if conversation.raw_calls else {}, "groupId", "group_id", ) if numeric_group_id and numeric_group_id.isdigit(): run("+chat-get-by-id", "--group-id", numeric_group_id) else: results["+chat-get-by-id"] = blocked( "+chat-get-by-id", "需要一个真实数字群号 groupId;openConversationId 不能替代", ) member_rows = ( first_list(members.upper(), "members") if members is not None else [] ) member_open_id = first_string( member_rows, "openDingtalkId", "openDingTalkId", "memberDingtalkId", ) if member_open_id: run( "+chat-members-get", "--id", group_id, "--users", member_open_id, ) role_query_capture: Capture | None = None for member in member_rows[:max_member_probes]: candidate_open_id = first_string( member, "openDingtalkId", "openDingTalkId", "memberDingtalkId", ) if not candidate_open_id: continue probe = run_capture( binary, "+chat-role-query-user", ["--group", group_id, "--user", candidate_open_id], timeout, ) role_query_capture = probe if summarize_capture(probe)["status"] in {"pass", "pass_empty"}: break if role_query_capture is not None: captures["+chat-role-query-user"] = role_query_capture results["+chat-role-query-user"] = summarize_capture( role_query_capture ) direct_capture: Capture | None = None cross_org_only = True for member in member_rows[:max_member_probes]: candidate_open_id = first_string( member, "openDingtalkId", "openDingTalkId", "memberDingtalkId", ) if not candidate_open_id: continue probe = run_capture( binary, "+messages-list-direct", [ "--open-dingtalk-id", candidate_open_id, "--time", time.strftime("%Y-%m-%d %H:%M:%S"), "--forward=false", "--limit", "20", "--page-all", "--page-limit", "50", ], timeout, ) summary = summarize_capture(probe) if summary["status"] in {"pass", "pass_empty"}: direct_capture = probe break if summary.get("error", {}).get("server_error_code") != ( "CrossOrgPermissionDenied" ): cross_org_only = False direct_capture = probe break if direct_capture is not None: captures["+messages-list-direct"] = direct_capture results["+messages-list-direct"] = summarize_capture(direct_capture) elif cross_org_only: results["+messages-list-direct"] = blocked( "+messages-list-direct", "需要一个当前账号有权读取单聊记录的同组织成员", ) else: requirement = "需要一个可读取 openDingTalkId 的真实群成员" for command in ( "+chat-members-get", "+chat-role-query-user", "+messages-list-direct", ): results[command] = blocked(command, requirement) message_rows = first_list(messages.upper(), "messages") own_open_id = ( str(group.get("ownerOpenDingtalkId") or "") if str(group.get("myRole") or "") == "OWNER" else "" ) own_message_rows = [ item for item in message_rows if isinstance(item, dict) and item.get("senderId") == own_open_id ] message_id = first_string( own_message_rows or message_rows, "messageId", "openMessageId", "openMsgId", ) if message_id: run("+messages-mget", "--msg-ids", message_id) if own_message_rows: run( "+messages-read-status", "--conversation-id", group_id, "--message-id", message_id, ) else: results["+messages-read-status"] = blocked( "+messages-read-status", "需要当前账号自己发送的一条真实消息 openMessageId", ) else: requirement = "需要该群内至少一条可读取的真实消息 openMessageId" results["+messages-mget"] = blocked("+messages-mget", requirement) results["+messages-read-status"] = blocked( "+messages-read-status", requirement ) keyword = safe_keyword(message_rows) run( "+search-msg", "--group", group_id, "--query", keyword, "--days", "30", ) raw_messages = messages.raw_calls[-1][2] if messages.raw_calls else {} topic_id = topic_id_override or first_string( raw_messages, "openConvThreadId", "openConversationThreadId", "threadId", "topicId", ) if topic_id: topic_args = [ "--group", topic_group_override or group_id, "--topic-id", topic_id, ] if topic_page_size: topic_args.extend( [ "--limit", topic_page_size, "--page-all", "--page-limit", "50", ] ) else: topic_args.extend( [ "--time", time.strftime("%Y-%m-%d %H:%M:%S"), "--limit", "20", ] ) run("+thread-replies", *topic_args) else: results["+thread-replies"] = blocked( "+thread-replies", "需要一个真实话题群及其 topic/thread ID", ) resource_id = media_resource_override resource_message_id = media_message_override resource_group_id = media_group_override or group_id for raw_message in first_list(raw_messages, "messages", "messageList"): if resource_id and resource_message_id: break if not isinstance(raw_message, dict): continue candidate_resource_id = first_string( raw_message, "mediaId", "resourceId", "downloadCode", ) candidate_message_id = first_string( raw_message, "openMessageId", "openMsgId", ) if candidate_resource_id and candidate_message_id: resource_id = candidate_resource_id resource_message_id = candidate_message_id break if resource_id and resource_message_id: run( "+messages-resource-url", "--type", "mediaId", "--resource-id", resource_id, "--message-id", resource_message_id, "--open-conversation-id", resource_group_id, ) download_output = ( Path("tmp") / "im-shortcut-live-audit" / f".read-resource-{os.getpid()}-{time.time_ns()}.bin" ) download_output.parent.mkdir(parents=True, exist_ok=True) try: download_capture = run( "+messages-resource-download", "--type", "mediaId", "--resource-id", resource_id, "--message-id", resource_message_id, "--open-conversation-id", resource_group_id, "--output", str(download_output), ) download_summary = results["+messages-resource-download"] upper_download = download_capture.upper() expected_size = ( upper_download.get("sizeBytes") if isinstance(upper_download, dict) else None ) if ( download_summary["status"] == "pass" and ( not download_output.is_file() or not isinstance(expected_size, (int, float)) or download_output.stat().st_size != expected_size ) ): download_summary["status"] = "local_artifact_mismatch" finally: download_output.unlink(missing_ok=True) else: results["+messages-resource-url"] = blocked( "+messages-resource-url", "需要一条含 mediaId 的真实图片/视频/语音消息", ) results["+messages-resource-download"] = blocked( "+messages-resource-download", "需要一条含 mediaId 的真实图片/视频/语音消息", ) else: requirement = "当前账号需要至少加入一个可读真实群" for command in ( "+conversation-info", "+chat-invite-url", "+chat-bots", "+chat-role-list", "+messages-list-pin", "+messages-list", "+chat-messages", "+chat-search", "+group-members", "+chat-members-list", "+chat-get-by-id", "+chat-members-get", "+chat-role-query-user", "+messages-list-direct", "+messages-mget", "+messages-read-status", "+search-msg", "+thread-replies", "+messages-resource-download", "+messages-resource-url", ): results[command] = blocked(command, requirement) open_task_id = open_task_override for capture in captures.values(): if open_task_id: break for _, _, raw in capture.raw_calls: open_task_id = first_string(raw, "openTaskId", "taskId") if open_task_id: break if open_task_id: break if open_task_id: run("+messages-query-send-status", "--open-task-id", open_task_id) else: results["+messages-query-send-status"] = blocked( "+messages-query-send-status", "需要一次真实消息发送返回的 openTaskId", ) return [results.get(command, blocked(command, "审计器未覆盖")) for command in READ_SHORTCUTS] def report_markdown(results: list[dict[str, Any]]) -> str: counts: dict[str, int] = {} for result in results: counts[result["status"]] = counts.get(result["status"], 0) + 1 lines = [ "# Chat Shortcut live read audit", "", "This report was produced through the real Shortcut entry point and the", "lower MCP response captured from the same invocation. It intentionally", "contains no business payload values.", "", f"- Total: {len(results)}", ] for status in sorted(counts): lines.append(f"- {status}: {counts[status]}") lines.extend( [ "", "| Shortcut | Status | Upper | Lower | Reactions U/L | Threads U/L | MCP calls | Evidence / requirement |", "|---|---|---:|---:|---:|---:|---:|---|", ] ) for result in results: upper = result.get("upper", {}).get("count") lower = result.get("lower", {}).get("count") calls = result.get("lower", {}).get("call_count") upper_reactions = result.get("upper", {}).get("reaction_message_count") lower_reactions = result.get("lower", {}).get("reaction_message_count") upper_threads = result.get("upper", {}).get("thread_message_count") lower_threads = result.get("lower", {}).get("thread_message_count") tools = ", ".join(result.get("lower", {}).get("tools", [])) evidence = result.get("requirement") or tools lines.append( f"| `{result['command']}` | {result['status']} | " f"{'' if upper is None else upper} | " f"{'' if lower is None else lower} | " f"{'' if upper_reactions is None else upper_reactions}/" f"{'' if lower_reactions is None else lower_reactions} | " f"{'' if upper_threads is None else upper_threads}/" f"{'' if lower_threads is None else lower_threads} | " f"{'' if calls is None else calls} | {evidence} |" ) lines.append("") return "\n".join(lines) def validate_report(results: list[dict[str, Any]]) -> None: commands = [result["command"] for result in results] if tuple(commands) != READ_SHORTCUTS: raise RuntimeError("read Shortcut audit coverage drifted from the expected set") encoded = json.dumps(results, ensure_ascii=False) for forbidden in ("stdout", "stderr", "backend_raw", "raw_backend", "inviteUrl"): if f'"{forbidden}"' in encoded: raise RuntimeError(f"sanitized report contains forbidden field {forbidden!r}") if "https://" in encoded or "http://" in encoded: raise RuntimeError("sanitized report appears to contain a URL") def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--dws", required=True, type=Path, help="locally built dws binary") parser.add_argument("--out-dir", required=True, type=Path) parser.add_argument("--timeout", type=int, default=60) parser.add_argument("--max-group-probes", type=int, default=8) parser.add_argument("--max-member-probes", type=int, default=8) args = parser.parse_args() binary = args.dws.resolve() if not binary.is_file(): parser.error(f"--dws is not a file: {binary}") numeric_group_id = os.environ.get("DWS_AUDIT_NUMERIC_GROUP_ID", "").strip() if numeric_group_id and not numeric_group_id.isdigit(): parser.error("DWS_AUDIT_NUMERIC_GROUP_ID must contain only digits") topic_override = [ os.environ.get("DWS_AUDIT_TOPIC_GROUP_ID", "").strip(), os.environ.get("DWS_AUDIT_TOPIC_ID", "").strip(), ] if any(topic_override) and not all(topic_override): parser.error("topic fixture override requires both group and topic IDs") topic_page_size = os.environ.get("DWS_AUDIT_TOPIC_PAGE_SIZE", "").strip() if topic_page_size and ( not topic_page_size.isdigit() or int(topic_page_size) < 1 ): parser.error("DWS_AUDIT_TOPIC_PAGE_SIZE must be a positive integer") media_override = [ os.environ.get("DWS_AUDIT_MEDIA_GROUP_ID", "").strip(), os.environ.get("DWS_AUDIT_MEDIA_MESSAGE_ID", "").strip(), os.environ.get("DWS_AUDIT_MEDIA_RESOURCE_ID", "").strip(), ] if any(media_override) and not all(media_override): parser.error("media fixture override requires group, message, and resource IDs") results = live_audit( binary, args.timeout, args.max_group_probes, args.max_member_probes, ) validate_report(results) args.out_dir.mkdir(parents=True, exist_ok=True) json_path = args.out_dir / "chat-read-live-audit.json" markdown_path = args.out_dir / "chat-read-live-audit.md" json_path.write_text( json.dumps( { "scope": "chat-read-shortcuts", "count": len(results), "results": results, }, ensure_ascii=False, indent=2, ) + "\n", encoding="utf-8", ) markdown_path.write_text(report_markdown(results), encoding="utf-8") print(markdown_path) failures = { "projection_mismatch", "projection_unverified", "pagination_mismatch", "backend_error", "cli_error", "error_envelope", "timeout", "no_lower_capture", "local_artifact_mismatch", "incomplete", } return 1 if any(result["status"] in failures for result in results) else 0 if __name__ == "__main__": raise SystemExit(main())