#!/usr/bin/env bash set -Eeuo pipefail readonly BRIDGE_USER="${JWEBOT_RUNTIME_USER:-jwebot}" BRIDGE_HOME="${JWEBOT_RUNTIME_HOME:-$(getent passwd "${BRIDGE_USER}" | cut -d: -f6)}" readonly BRIDGE_HOME BRIDGE_UID=$(id -u "${BRIDGE_USER}") readonly BRIDGE_UID readonly DISPLAY_NAME=":1" readonly XAUTHORITY_FILE="${BRIDGE_HOME}/.Xauthority" readonly RUNTIME_DIR="/run/user/${BRIDGE_UID}" readonly STATE_DIR="${BRIDGE_HOME}/.local/state/wechat-gui-bridge" readonly PENDING_TEXT="${STATE_DIR}/pending.txt" readonly PENDING_TOKEN="${STATE_DIR}/pending.token" readonly PENDING_TTL=120 readonly EXPECTED_WIDTH=1440 readonly EXPECTED_HEIGHT=873 readonly DRAFT_CLICK_X=900 readonly DRAFT_CLICK_Y=780 readonly SEND_CLICK_X=1390 readonly SEND_CLICK_Y=842 require_root() { if [[ ${EUID} -ne 0 ]]; then printf 'error: run as root\n' >&2 exit 1 fi } as_bridge_user() { /usr/sbin/runuser -u "${BRIDGE_USER}" -- env \ DISPLAY="${DISPLAY_NAME}" \ XAUTHORITY="${XAUTHORITY_FILE}" \ XDG_RUNTIME_DIR="${RUNTIME_DIR}" \ DBUS_SESSION_BUS_ADDRESS="unix:path=${RUNTIME_DIR}/bus" \ "$@" } ensure_state_dir() { install -d -o "${BRIDGE_USER}" -g "${BRIDGE_USER}" -m 700 "${STATE_DIR}" } window_id() { local wid name while IFS= read -r wid; do [[ -n ${wid} ]] || continue name=$(as_bridge_user xdotool getwindowname "${wid}" 2>/dev/null || true) if [[ ${name} == "Weixin" ]]; then printf '%s\n' "${wid}" return 0 fi done < <(as_bridge_user xdotool search --onlyvisible --class wechat || true) printf 'error: visible main WeChat window not found\n' >&2 return 1 } window_geometry() { local wid=$1 as_bridge_user xdotool getwindowgeometry --shell "${wid}" } ensure_maximized_window() { local wid=$1 geometry width height geometry=$(window_geometry "${wid}") eval "${geometry}" width=${WIDTH:?} height=${HEIGHT:?} if (( width == EXPECTED_WIDTH && height == EXPECTED_HEIGHT )); then return fi as_bridge_user wmctrl -ir "${wid}" -b add,maximized_vert,maximized_horz sleep 0.25 geometry=$(window_geometry "${wid}") eval "${geometry}" width=${WIDTH:?} height=${HEIGHT:?} if (( width != EXPECTED_WIDTH || height != EXPECTED_HEIGHT )); then printf 'error: WeChat maximize failed; expected %sx%s, got %sx%s\n' \ "${EXPECTED_WIDTH}" "${EXPECTED_HEIGHT}" "${width}" "${height}" >&2 return 1 fi } capture_window() { local destination=${1:-"${STATE_DIR}/latest.png"} local wid wid=$(window_id) ensure_maximized_window "${wid}" as_bridge_user scrot --overwrite --window "${wid}" "${destination}" chown "${BRIDGE_USER}:${BRIDGE_USER}" "${destination}" chmod 600 "${destination}" printf '%s\n' "${destination}" } status_command() { local wid name geometry wid=$(window_id) ensure_maximized_window "${wid}" name=$(as_bridge_user xdotool getwindowname "${wid}") geometry=$(window_geometry "${wid}" | tr '\n' ' ') printf '{"ok":true,"display":"%s","window_id":"%s","window_name":"%s","geometry":"%s","wechat_pid":"%s"}\n' \ "${DISPLAY_NAME}" "${wid}" "${name//\"/}" "${geometry//\"/}" "$(pgrep -u "${BRIDGE_USER}" -x wechat | head -n 1)" } ocr_command() { ensure_state_dir local image="${STATE_DIR}/latest.png" local output="${STATE_DIR}/latest-ocr" capture_window "${image}" >/dev/null as_bridge_user tesseract "${image}" "${output}" -l chi_sim+eng --psm 11 >/dev/null 2>&1 local lines chars lines=$(grep -cve '^[[:space:]]*$' "${output}.txt" || true) chars=$(wc -m < "${output}.txt") printf '{"ok":true,"image":"%s","text":"%s","lines":%s,"characters":%s}\n' \ "${image}" "${output}.txt" "${lines}" "${chars}" } ocr_text_command() { ocr_command >/dev/null cat "${STATE_DIR}/latest-ocr.txt" } draft_command() { ensure_state_dir local text=${1:-} if [[ -z ${text} ]]; then printf 'error: draft text is empty\n' >&2 exit 2 fi if (( ${#text} > 1000 )); then printf 'error: draft exceeds 1000 characters\n' >&2 exit 2 fi printf '%s' "${text}" > "${PENDING_TEXT}" chown "${BRIDGE_USER}:${BRIDGE_USER}" "${PENDING_TEXT}" chmod 600 "${PENDING_TEXT}" local token wid token=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n') printf '%s\n' "${token}" > "${PENDING_TOKEN}" chown "${BRIDGE_USER}:${BRIDGE_USER}" "${PENDING_TOKEN}" chmod 600 "${PENDING_TOKEN}" wid=$(window_id) ensure_maximized_window "${wid}" # xclip backgrounds itself and otherwise keeps a service caller's captured # stdout/stderr pipes open, making a completed draft look like a timeout. as_bridge_user xclip -selection clipboard -in < "${PENDING_TEXT}" >/dev/null 2>&1 as_bridge_user xdotool windowactivate --sync "${wid}" as_bridge_user xdotool mousemove --window "${wid}" \ "${DRAFT_CLICK_X}" "${DRAFT_CLICK_Y}" click 1 as_bridge_user xdotool key --clearmodifiers ctrl+a BackSpace ctrl+v printf '{"ok":true,"state":"drafted","confirmation_token":"%s","expires_in_seconds":%s}\n' \ "${token}" "${PENDING_TTL}" } send_command() { local supplied=${1:-} if [[ -z ${supplied} || ! -s ${PENDING_TOKEN} || ! -s ${PENDING_TEXT} ]]; then printf 'error: no armed draft\n' >&2 exit 3 fi local expected created now age expected=$(tr -d '\r\n' < "${PENDING_TOKEN}") created=$(stat -c %Y "${PENDING_TOKEN}") now=$(date +%s) age=$((now - created)) if [[ ${supplied} != "${expected}" ]]; then printf 'error: confirmation token mismatch\n' >&2 exit 3 fi if (( age < 0 || age > PENDING_TTL )); then printf 'error: confirmation token expired\n' >&2 exit 3 fi local wid wid=$(window_id) ensure_maximized_window "${wid}" as_bridge_user xdotool windowactivate --sync "${wid}" as_bridge_user xdotool mousemove --window "${wid}" \ "${SEND_CLICK_X}" "${SEND_CLICK_Y}" click 1 rm -f -- "${PENDING_TOKEN}" "${PENDING_TEXT}" printf '{"ok":true,"state":"sent"}\n' } clear_command() { local wid wid=$(window_id) ensure_maximized_window "${wid}" as_bridge_user xdotool windowactivate --sync "${wid}" as_bridge_user xdotool mousemove --window "${wid}" \ "${DRAFT_CLICK_X}" "${DRAFT_CLICK_Y}" click 1 as_bridge_user xdotool key --clearmodifiers ctrl+a BackSpace rm -f -- "${PENDING_TOKEN}" "${PENDING_TEXT}" printf '{"ok":true,"state":"cleared"}\n' } usage() { printf 'usage: %s {status|capture|ocr|ocr-text|draft TEXT|send TOKEN|clear}\n' "$0" >&2 exit 64 } require_root case ${1:-} in status) status_command ;; capture) ensure_state_dir; capture_window ;; ocr) ocr_command ;; ocr-text) ocr_text_command ;; draft) shift; draft_command "$*" ;; send) shift; send_command "${1:-}" ;; clear) clear_command ;; *) usage ;; esac