219 lines
6.5 KiB
Bash
Executable File
219 lines
6.5 KiB
Bash
Executable File
#!/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
|