Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions gateway/dead_targets.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ def __init__(self, path: Optional[Path] = None) -> None:
def _load(self) -> None:
try:
if self._path.exists():
raw = json.loads(self._path.read_text())
raw = json.loads(self._path.read_text(encoding="utf-8"))
if isinstance(raw, dict):
# Only keep well-shaped entries.
self._dead = {
Expand All @@ -82,7 +82,7 @@ def _flush_locked(self) -> None:
try:
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
tmp.write_text(json.dumps(self._dead, indent=2))
tmp.write_text(json.dumps(self._dead, indent=2), encoding="utf-8")
tmp.replace(self._path)
except OSError as exc:
# Best-effort: keep the in-memory state, don't break delivery.
Expand Down
4 changes: 2 additions & 2 deletions gateway/delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ def _deliver_local(
lines.append("")
lines.append(content)

output_path.write_text("\n".join(lines))
output_path.write_text("\n".join(lines), encoding="utf-8")

return {
"path": str(output_path),
Expand All @@ -370,7 +370,7 @@ def _save_full_output(self, content: str, job_id: str) -> Path:
out_dir = get_hermes_home() / "cron" / "output"
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"{job_id}_{timestamp}.txt"
path.write_text(content)
path.write_text(content, encoding="utf-8")
return path

def _filter_silence_narration_enabled(self) -> bool:
Expand Down
2 changes: 1 addition & 1 deletion gateway/platforms/qqbot/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1193,7 +1193,7 @@ def _write_update_response(answer: str, operator: str = "") -> None:
home = get_hermes_home()
response_path = home / ".update_response"
tmp = response_path.with_suffix(".tmp")
tmp.write_text(answer)
tmp.write_text(answer, encoding="utf-8")
tmp.replace(response_path)
logger.info(
"QQ update prompt answered %r by %s",
Expand Down
38 changes: 19 additions & 19 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -3177,7 +3177,7 @@ def _voice_key(self, platform: Platform, chat_id: str) -> str:

def _load_voice_modes(self) -> Dict[str, str]:
try:
data = json.loads(self._VOICE_MODE_PATH.read_text())
data = json.loads(self._VOICE_MODE_PATH.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError, OSError):
return {}

Expand Down Expand Up @@ -3205,7 +3205,7 @@ def _save_voice_modes(self) -> None:
try:
self._VOICE_MODE_PATH.parent.mkdir(parents=True, exist_ok=True)
self._VOICE_MODE_PATH.write_text(
json.dumps(self._voice_mode, indent=2)
json.dumps(self._voice_mode, indent=2), encoding="utf-8"
)
except OSError as e:
logger.warning("Failed to save voice modes: %s", e)
Expand Down Expand Up @@ -5923,7 +5923,7 @@ def _increment_restart_failure_counts(self, active_session_keys: set) -> None:

path = _hermes_home / self._STUCK_LOOP_FILE
try:
counts = json.loads(path.read_text()) if path.exists() else {}
counts = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
except Exception:
counts = {}

Expand Down Expand Up @@ -5953,7 +5953,7 @@ def _suspend_stuck_loop_sessions(self) -> int:
return 0

try:
counts = json.loads(path.read_text())
counts = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return 0

Expand Down Expand Up @@ -5999,7 +5999,7 @@ def _clear_restart_failure_count(self, session_key: str) -> None:
if not path.exists():
return
try:
counts = json.loads(path.read_text())
counts = json.loads(path.read_text(encoding="utf-8"))
if session_key in counts:
del counts[session_key]
if counts:
Expand Down Expand Up @@ -8832,7 +8832,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
prompt_path = _hermes_home / ".update_prompt.json"
try:
tmp = response_path.with_suffix(".tmp")
tmp.write_text(response_text)
tmp.write_text(response_text, encoding="utf-8")
tmp.replace(response_path)
prompt_path.unlink(missing_ok=True)
except OSError as e:
Expand All @@ -8852,7 +8852,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
prompt_path = _hermes_home / ".update_prompt.json"
try:
tmp = response_path.with_suffix(".tmp")
tmp.write_text("")
tmp.write_text("", encoding="utf-8")
tmp.replace(response_path)
prompt_path.unlink(missing_ok=True)
logger.info(
Expand Down Expand Up @@ -12184,7 +12184,7 @@ def _is_stale_restart_redelivery(self, event: MessageEvent) -> bool:
self._booted_from_restart = False
return True
return False
data = json.loads(marker_path.read_text())
data = json.loads(marker_path.read_text(encoding="utf-8"))
except Exception:
return False

Expand Down Expand Up @@ -14019,7 +14019,7 @@ async def _watch_update_progress(
for path in (claimed_path, pending_path):
if path.exists():
try:
pending = json.loads(path.read_text())
pending = json.loads(path.read_text(encoding="utf-8"))
platform_str = pending.get("platform")
chat_id = pending.get("chat_id")
chat_type = pending.get("chat_type")
Expand Down Expand Up @@ -14058,7 +14058,7 @@ async def _watch_update_progress(
return
await asyncio.sleep(poll_interval)
if (pending_path.exists() or claimed_path.exists()) and not exit_code_path.exists():
exit_code_path.write_text("124")
exit_code_path.write_text("124", encoding="utf-8")
await self._send_update_notification()
return

Expand Down Expand Up @@ -14100,7 +14100,7 @@ async def _flush_buffer() -> None:
# Read any remaining output
if output_path.exists():
try:
content = output_path.read_text()
content = output_path.read_text(encoding="utf-8")
if len(content) > bytes_sent:
buffer += content[bytes_sent:]
bytes_sent = len(content)
Expand All @@ -14110,7 +14110,7 @@ async def _flush_buffer() -> None:

# Send final status
try:
exit_code_raw = exit_code_path.read_text().strip() or "1"
exit_code_raw = exit_code_path.read_text(encoding="utf-8").strip() or "1"
exit_code = int(exit_code_raw)
if exit_code == 0:
await adapter.send(
Expand Down Expand Up @@ -14139,7 +14139,7 @@ async def _flush_buffer() -> None:
# Check for new output
if output_path.exists():
try:
content = output_path.read_text()
content = output_path.read_text(encoding="utf-8")
if len(content) > bytes_sent:
buffer += content[bytes_sent:]
bytes_sent = len(content)
Expand All @@ -14157,7 +14157,7 @@ async def _flush_buffer() -> None:
if (prompt_path.exists() and session_key
and not self._update_prompt_pending.get(session_key)):
try:
prompt_data = json.loads(prompt_path.read_text())
prompt_data = json.loads(prompt_path.read_text(encoding="utf-8"))
prompt_text = prompt_data.get("prompt", "")
default = prompt_data.get("default", "")
if prompt_text:
Expand Down Expand Up @@ -14205,7 +14205,7 @@ async def _flush_buffer() -> None:
# Timeout
if not exit_code_path.exists():
logger.warning("Update watcher timed out after %.0fs", timeout)
exit_code_path.write_text("124")
exit_code_path.write_text("124", encoding="utf-8")
await _flush_buffer()
try:
await adapter.send(
Expand Down Expand Up @@ -14251,7 +14251,7 @@ async def _send_update_notification(self) -> bool:
elif not claimed_path.exists():
return True

pending = json.loads(claimed_path.read_text())
pending = json.loads(claimed_path.read_text(encoding="utf-8"))
platform_str = pending.get("platform")
chat_id = pending.get("chat_id")
chat_type = pending.get("chat_type")
Expand All @@ -14265,13 +14265,13 @@ async def _send_update_notification(self) -> bool:
claimed_path.replace(pending_path)
return False

exit_code_raw = exit_code_path.read_text().strip() or "1"
exit_code_raw = exit_code_path.read_text(encoding="utf-8").strip() or "1"
exit_code = int(exit_code_raw)

# Read the captured update output
output = ""
if output_path.exists():
output = output_path.read_text()
output = output_path.read_text(encoding="utf-8")

# Resolve adapter
platform = Platform(platform_str)
Expand Down Expand Up @@ -14346,7 +14346,7 @@ async def _send_restart_notification(self) -> Optional[tuple[str, str, Optional[
return None

try:
data = json.loads(notify_path.read_text())
data = json.loads(notify_path.read_text(encoding="utf-8"))
platform_str = data.get("platform")
chat_id = data.get("chat_id")
chat_type = data.get("chat_type")
Expand Down
2 changes: 1 addition & 1 deletion gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -4523,7 +4523,7 @@ async def _handle_update_command(self, event: MessageEvent) -> str:
if event.message_id:
pending["message_id"] = event.message_id
_tmp_pending = pending_path.with_suffix(".tmp")
_tmp_pending.write_text(json.dumps(pending))
_tmp_pending.write_text(json.dumps(pending), encoding="utf-8")
_tmp_pending.replace(pending_path)
exit_code_path.unlink(missing_ok=True)

Expand Down
2 changes: 1 addition & 1 deletion gateway/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,7 @@ def _read_pid_record(pid_path: Optional[Path] = None) -> Optional[dict]:
return None

try:
raw = pid_path.read_text().strip()
raw = pid_path.read_text(encoding="utf-8").strip()
except (OSError, UnicodeDecodeError):
# File was deleted between exists() and read_text(), permission
# flipped, or it holds non-UTF-8 / binary garbage.
Expand Down
2 changes: 1 addition & 1 deletion plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -6804,7 +6804,7 @@ async def _respond(
home = get_hermes_home()
response_path = home / ".update_response"
tmp = response_path.with_suffix(".tmp")
tmp.write_text(answer)
tmp.write_text(answer, encoding="utf-8")
tmp.replace(response_path)
logger.info(
"Discord update prompt answered '%s' by %s",
Expand Down
2 changes: 1 addition & 1 deletion plugins/platforms/feishu/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -2152,7 +2152,7 @@ def _build_resolved_update_prompt_card(*, answer: str, user_name: str) -> Dict[s
def _write_update_prompt_response(answer: str) -> None:
response_path = get_hermes_home() / ".update_response"
tmp_path = response_path.with_suffix(".tmp")
tmp_path.write_text(answer)
tmp_path.write_text(answer, encoding="utf-8")
tmp_path.replace(response_path)

async def send_voice(
Expand Down
2 changes: 1 addition & 1 deletion plugins/platforms/telegram/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -5565,7 +5565,7 @@ async def _handle_callback_query(
home = get_hermes_home()
Comment thread
rodboev marked this conversation as resolved.
response_path = home / ".update_response"
tmp = response_path.with_suffix(".tmp")
tmp.write_text(answer)
tmp.write_text(answer, encoding="utf-8")
tmp.replace(response_path)
logger.info("Telegram update prompt answered '%s' by user %s",
answer, getattr(query.from_user, "id", "unknown"))
Expand Down
10 changes: 6 additions & 4 deletions plugins/platforms/whatsapp/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ def _kill_stale_bridge_by_pidfile(session_path: Path) -> None:
try:
# Format: line 1 = pid, optional line 2 = kernel start time. Legacy
# files written before the guard existed have only the pid.
lines = pid_file.read_text().split("\n")
lines = pid_file.read_text(encoding="utf-8").split("\n")
pid = int(lines[0].strip())
if len(lines) > 1 and lines[1].strip():
recorded_start = int(lines[1].strip())
Expand Down Expand Up @@ -207,7 +207,7 @@ def _write_bridge_pidfile(session_path: Path, pid: int) -> None:
from gateway.status import get_process_start_time
start = get_process_start_time(pid)
text = str(pid) if start is None else "{}\n{}".format(pid, start)
(session_path / "bridge.pid").write_text(text)
(session_path / "bridge.pid").write_text(text, encoding="utf-8")
except OSError:
pass

Expand Down Expand Up @@ -530,7 +530,9 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:
_deps_fresh = False
if (bridge_dir / "node_modules").exists():
try:
_deps_fresh = (_dep_stamp.read_text().strip() == _pkg_hash) and bool(_pkg_hash)
_deps_fresh = (
_dep_stamp.read_text(encoding="utf-8").strip() == _pkg_hash
) and bool(_pkg_hash)
except OSError:
_deps_fresh = False
if not _deps_fresh:
Expand All @@ -556,7 +558,7 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:
print(f"[{self.name}] Dependencies installed")
if _pkg_hash:
try:
_dep_stamp.write_text(_pkg_hash)
_dep_stamp.write_text(_pkg_hash, encoding="utf-8")
except OSError:
pass # Stamp is an optimization; install still succeeded
except Exception as e:
Expand Down
57 changes: 57 additions & 0 deletions tests/gateway/test_gateway_utf8_encoding.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Static guard: every ``read_text`` / ``write_text`` call in the gateway and
bundled update-response adapters must pass an explicit ``encoding=`` keyword
argument so non-UTF-8 Windows locales don't corrupt file IPC. Mirrors the
AST-based guard pattern in
``tests/tools/test_windows_compat.py``.
"""

import ast
import pathlib
import pytest

REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
GATEWAY_DIR = REPO_ROOT / "gateway"
UPDATE_RESPONSE_FILES = (
REPO_ROOT / "plugins/platforms/discord/adapter.py",
REPO_ROOT / "plugins/platforms/telegram/adapter.py",
REPO_ROOT / "plugins/platforms/feishu/adapter.py",
)
METHODS = {"read_text", "write_text"}
SUPPRESSION = "# gateway-utf8: ok"


def _find_violations():
violations = []
py_files = list(GATEWAY_DIR.rglob("*.py")) + list(UPDATE_RESPONSE_FILES)
for py_file in sorted(py_files):
source = py_file.read_text(encoding="utf-8")
source_lines = source.splitlines()
try:
tree = ast.parse(source, filename=str(py_file))
except SyntaxError:
continue
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
if not isinstance(func, ast.Attribute):
continue
if func.attr not in METHODS:
continue
if any(kw.arg == "encoding" for kw in node.keywords):
continue
lineno = node.lineno
if lineno <= len(source_lines) and SUPPRESSION in source_lines[lineno - 1]:
continue
rel = py_file.relative_to(REPO_ROOT)
violations.append(f"{rel}:{lineno}")
return violations


def test_all_read_write_text_pass_encoding():
violations = _find_violations()
assert not violations, (
"Bare read_text()/write_text() calls found (missing encoding= kwarg).\n"
"Add encoding=\"utf-8\" or suppress with '# gateway-utf8: ok':\n"
+ "\n".join(f" {v}" for v in violations)
)
Loading