Compare commits
3 commits
7640d6ddcd
...
e9a82dd528
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9a82dd528 | ||
|
|
50f12ef7f0 | ||
|
|
d455087bc8 |
19
deploy/docker-registry.service
Normal file
19
deploy/docker-registry.service
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Local Docker Registry for food-market
|
||||||
|
Requires=docker.service
|
||||||
|
After=docker.service network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStartPre=-/usr/bin/docker rm -f food-market-registry
|
||||||
|
ExecStart=/usr/bin/docker run --rm --name food-market-registry \
|
||||||
|
-p 127.0.0.1:5001:5000 \
|
||||||
|
-v /opt/food-market-data/docker-registry:/var/lib/registry \
|
||||||
|
-e REGISTRY_STORAGE_DELETE_ENABLED=true \
|
||||||
|
registry:2
|
||||||
|
ExecStop=/usr/bin/docker stop food-market-registry
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
432
deploy/telegram-bridge/bridge.py
Normal file
432
deploy/telegram-bridge/bridge.py
Normal file
|
|
@ -0,0 +1,432 @@
|
||||||
|
"""Telegram <-> tmux bridge for controlling a local Claude Code session from a phone.
|
||||||
|
|
||||||
|
Reads creds from /etc/food-market/telegram.env (TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID).
|
||||||
|
Only the single whitelisted chat_id is allowed; everything else is silently ignored.
|
||||||
|
|
||||||
|
Inbound: each Telegram message is typed into tmux session 'claude' via `tmux send-keys
|
||||||
|
-t claude -l <text>` followed by an Enter keypress.
|
||||||
|
|
||||||
|
Outbound: every poll_interval seconds, capture the current pane, diff against the last
|
||||||
|
snapshot, filter TUI noise (box-drawing, spinners, the user's own echoed prompt), then
|
||||||
|
send any remaining text as plain Telegram messages.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import collections
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from telegram import Update
|
||||||
|
from telegram.ext import (
|
||||||
|
Application,
|
||||||
|
ApplicationBuilder,
|
||||||
|
CommandHandler,
|
||||||
|
ContextTypes,
|
||||||
|
MessageHandler,
|
||||||
|
filters,
|
||||||
|
)
|
||||||
|
|
||||||
|
ENV_FILE = Path("/etc/food-market/telegram.env")
|
||||||
|
TMUX_SESSION = os.environ.get("TMUX_SESSION", "claude")
|
||||||
|
POLL_INTERVAL = float(os.environ.get("POLL_INTERVAL_SEC", "8"))
|
||||||
|
CAPTURE_HISTORY = int(os.environ.get("CAPTURE_HISTORY_LINES", "200"))
|
||||||
|
TG_MAX_CHARS = 3500
|
||||||
|
MAX_SEND_PER_TICK = int(os.environ.get("MAX_SEND_CHARS_PER_TICK", "900"))
|
||||||
|
|
||||||
|
ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[a-zA-Z]")
|
||||||
|
BOX_CHARS = set("╭╮╰╯│─┌┐└┘├┤┬┴┼║═╔╗╚╝╠╣╦╩╬▌▐█▀▄")
|
||||||
|
SPINNER_CHARS = set("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⣾⣽⣻⢿⡿⣟⣯⣷")
|
||||||
|
# Claude Code TUI markers
|
||||||
|
TOOL_CALL_RE = re.compile(r"^\s*[⏺●⏻◯◎⬤]\s+\S")
|
||||||
|
TOOL_RESULT_RE = re.compile(r"^\s*⎿")
|
||||||
|
USER_PROMPT_RE = re.compile(r"^>\s?(.*)$")
|
||||||
|
|
||||||
|
|
||||||
|
def load_env(path: Path) -> dict[str, str]:
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
if not path.exists():
|
||||||
|
return out
|
||||||
|
for raw in path.read_text().splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
out[key.strip()] = value.strip().strip('"').strip("'")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BridgeState:
|
||||||
|
chat_id: int
|
||||||
|
last_snapshot: str = ""
|
||||||
|
last_sent_text: str = ""
|
||||||
|
recent_user_inputs: collections.deque = field(
|
||||||
|
default_factory=lambda: collections.deque(maxlen=50)
|
||||||
|
)
|
||||||
|
recently_sent_lines: collections.deque = field(
|
||||||
|
default_factory=lambda: collections.deque(maxlen=400)
|
||||||
|
)
|
||||||
|
_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||||
|
|
||||||
|
|
||||||
|
async def tmux_send_text(session: str, text: str) -> None:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"tmux", "send-keys", "-t", session, "-l", text,
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
_, stderr = await proc.communicate()
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(f"tmux send-keys -l failed: {stderr.decode().strip()}")
|
||||||
|
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"tmux", "send-keys", "-t", session, "Enter",
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
_, stderr = await proc.communicate()
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(f"tmux send-keys Enter failed: {stderr.decode().strip()}")
|
||||||
|
|
||||||
|
|
||||||
|
async def tmux_capture(session: str) -> str:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"tmux", "capture-pane", "-t", session, "-p", "-S", f"-{CAPTURE_HISTORY}",
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, stderr = await proc.communicate()
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(f"tmux capture-pane failed: {stderr.decode().strip()}")
|
||||||
|
return stdout.decode("utf-8", errors="replace").rstrip("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_box(line: str) -> str:
|
||||||
|
# Drop leading/trailing box-drawing chars and their padding.
|
||||||
|
while line and (line[0] in BOX_CHARS or (line[0] == " " and len(line) > 1 and line[1] in BOX_CHARS)):
|
||||||
|
line = line[1:].lstrip()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
while line and (line[-1] in BOX_CHARS or (line[-1] == " " and len(line) > 1 and line[-2] in BOX_CHARS)):
|
||||||
|
line = line[:-1].rstrip()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
|
def _is_noise(line: str) -> bool:
|
||||||
|
s = line.strip()
|
||||||
|
if not s:
|
||||||
|
return True
|
||||||
|
# Lines made of only box/spinner/decoration chars + spaces.
|
||||||
|
if all(c in BOX_CHARS or c in SPINNER_CHARS or c.isspace() for c in s):
|
||||||
|
return True
|
||||||
|
# Claude Code TUI hints.
|
||||||
|
lowered = s.lower()
|
||||||
|
if "shift+tab" in lowered or "bypass permissions" in lowered:
|
||||||
|
return True
|
||||||
|
if lowered.startswith("? for shortcuts"):
|
||||||
|
return True
|
||||||
|
# Spinner + status line ("✻ Thinking…", "· Pondering…").
|
||||||
|
if s.startswith(("✻", "✽", "✢", "·")) and len(s) < 80:
|
||||||
|
return True
|
||||||
|
# Typing indicator prompt ("> " empty or near-empty input box).
|
||||||
|
if s.startswith(">") and len(s) <= 2:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def clean_text(text: str, recent_user: collections.deque | None = None) -> str:
|
||||||
|
"""Strip TUI noise, tool-call blocks and echoed user input.
|
||||||
|
|
||||||
|
Only the assistant's prose reply should survive.
|
||||||
|
"""
|
||||||
|
recent_user = recent_user if recent_user is not None else collections.deque()
|
||||||
|
out: list[str] = []
|
||||||
|
in_tool_block = False
|
||||||
|
for raw in text.splitlines():
|
||||||
|
line = ANSI_RE.sub("", raw).rstrip()
|
||||||
|
line = _strip_box(line)
|
||||||
|
stripped = line.strip()
|
||||||
|
|
||||||
|
if not stripped:
|
||||||
|
in_tool_block = False
|
||||||
|
out.append("")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Tool call / tool result blocks — skip the header and any indented follow-ups.
|
||||||
|
if TOOL_CALL_RE.match(line) or TOOL_RESULT_RE.match(line):
|
||||||
|
in_tool_block = True
|
||||||
|
continue
|
||||||
|
if in_tool_block:
|
||||||
|
# continuation of a tool block is usually indented; a flush-left line ends it
|
||||||
|
if line.startswith(" ") or line.startswith("\t"):
|
||||||
|
continue
|
||||||
|
in_tool_block = False
|
||||||
|
|
||||||
|
# Echo of the user's own prompt ("> hello") — drop it.
|
||||||
|
m = USER_PROMPT_RE.match(stripped)
|
||||||
|
if m:
|
||||||
|
continue
|
||||||
|
if stripped in recent_user:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if _is_noise(line):
|
||||||
|
continue
|
||||||
|
|
||||||
|
out.append(line)
|
||||||
|
|
||||||
|
# collapse runs of blank lines
|
||||||
|
collapsed: list[str] = []
|
||||||
|
prev_blank = False
|
||||||
|
for line in out:
|
||||||
|
blank = not line.strip()
|
||||||
|
if blank and prev_blank:
|
||||||
|
continue
|
||||||
|
collapsed.append(line)
|
||||||
|
prev_blank = blank
|
||||||
|
return "\n".join(collapsed).strip("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def diff_snapshot(prev: str, curr: str) -> str:
|
||||||
|
"""Return only lines that weren't already present anywhere in the previous snapshot.
|
||||||
|
|
||||||
|
Set-based: handles TUI scrolling and partial redraws without re-sending history.
|
||||||
|
"""
|
||||||
|
if not prev:
|
||||||
|
return curr
|
||||||
|
if prev == curr:
|
||||||
|
return ""
|
||||||
|
prev_set = set(prev.splitlines())
|
||||||
|
new_lines = [ln for ln in curr.splitlines() if ln.rstrip() and ln not in prev_set]
|
||||||
|
return "\n".join(new_lines)
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_for_telegram(text: str, limit: int = TG_MAX_CHARS) -> list[str]:
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
out: list[str] = []
|
||||||
|
buf: list[str] = []
|
||||||
|
buf_len = 0
|
||||||
|
for line in text.splitlines():
|
||||||
|
if buf_len + len(line) + 1 > limit and buf:
|
||||||
|
out.append("\n".join(buf))
|
||||||
|
buf, buf_len = [], 0
|
||||||
|
while len(line) > limit:
|
||||||
|
out.append(line[:limit])
|
||||||
|
line = line[limit:]
|
||||||
|
buf.append(line)
|
||||||
|
buf_len += len(line) + 1
|
||||||
|
if buf:
|
||||||
|
out.append("\n".join(buf))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
state: BridgeState = context.application.bot_data["state"]
|
||||||
|
if update.effective_chat is None or update.effective_chat.id != state.chat_id:
|
||||||
|
return
|
||||||
|
text = (update.message.text or "").strip() if update.message else ""
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
# Remember what we sent so we can suppress its echo from the pane capture.
|
||||||
|
async with state._lock:
|
||||||
|
state.recent_user_inputs.append(text)
|
||||||
|
# Also store reasonable substrings in case the TUI wraps or truncates
|
||||||
|
if len(text) > 40:
|
||||||
|
state.recent_user_inputs.append(text[:40])
|
||||||
|
try:
|
||||||
|
await tmux_send_text(TMUX_SESSION, text)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
await update.message.reply_text(f"⚠️ tmux error: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_ping(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
state: BridgeState = context.application.bot_data["state"]
|
||||||
|
if update.effective_chat is None or update.effective_chat.id != state.chat_id:
|
||||||
|
return
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"pong — session '{TMUX_SESSION}', poll {POLL_INTERVAL}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_snapshot(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
state: BridgeState = context.application.bot_data["state"]
|
||||||
|
if update.effective_chat is None or update.effective_chat.id != state.chat_id:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
snap = await tmux_capture(TMUX_SESSION)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
await update.message.reply_text(f"⚠️ tmux error: {exc}")
|
||||||
|
return
|
||||||
|
async with state._lock:
|
||||||
|
cleaned = clean_text(snap, state.recent_user_inputs)
|
||||||
|
state.last_snapshot = snap # reset baseline so poller doesn't resend
|
||||||
|
for part in chunk_for_telegram(cleaned) or ["(nothing to show)"]:
|
||||||
|
await update.message.reply_text(part)
|
||||||
|
|
||||||
|
|
||||||
|
async def poll_and_forward(application: Application) -> None:
|
||||||
|
state: BridgeState = application.bot_data["state"]
|
||||||
|
bot = application.bot
|
||||||
|
logger = logging.getLogger("bridge.poll")
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(POLL_INTERVAL)
|
||||||
|
# Stability check: capture twice, ~1.5s apart. If pane still changes, assistant
|
||||||
|
# is still streaming — skip this tick and try next time.
|
||||||
|
try:
|
||||||
|
snap1 = await tmux_capture(TMUX_SESSION)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("capture failed: %s", exc)
|
||||||
|
continue
|
||||||
|
await asyncio.sleep(1.5)
|
||||||
|
try:
|
||||||
|
snap2 = await tmux_capture(TMUX_SESSION)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("capture failed: %s", exc)
|
||||||
|
continue
|
||||||
|
if snap1 != snap2:
|
||||||
|
# still being written — don't send partials
|
||||||
|
continue
|
||||||
|
snapshot = snap2
|
||||||
|
|
||||||
|
async with state._lock:
|
||||||
|
prev = state.last_snapshot
|
||||||
|
state.last_snapshot = snapshot
|
||||||
|
recent_user_copy = list(state.recent_user_inputs)
|
||||||
|
recently_sent_copy = list(state.recently_sent_lines)
|
||||||
|
|
||||||
|
raw_new = diff_snapshot(prev, snapshot)
|
||||||
|
new_text = clean_text(raw_new, collections.deque(recent_user_copy))
|
||||||
|
if not new_text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Line-level dedup vs. what we already shipped: drop lines that are
|
||||||
|
# substring-equivalent to a recently sent one (handles streaming dupes).
|
||||||
|
deduped: list[str] = []
|
||||||
|
for line in new_text.splitlines():
|
||||||
|
s = line.rstrip()
|
||||||
|
if not s.strip():
|
||||||
|
deduped.append(line)
|
||||||
|
continue
|
||||||
|
ss = s.strip()
|
||||||
|
is_dup = False
|
||||||
|
for past in recently_sent_copy:
|
||||||
|
if ss == past:
|
||||||
|
is_dup = True
|
||||||
|
break
|
||||||
|
if len(ss) >= 15 and len(past) >= 15 and (ss in past or past in ss):
|
||||||
|
is_dup = True
|
||||||
|
break
|
||||||
|
if is_dup:
|
||||||
|
continue
|
||||||
|
deduped.append(line)
|
||||||
|
recently_sent_copy.append(ss)
|
||||||
|
|
||||||
|
async with state._lock:
|
||||||
|
state.recently_sent_lines.clear()
|
||||||
|
state.recently_sent_lines.extend(recently_sent_copy[-400:])
|
||||||
|
|
||||||
|
new_text = "\n".join(deduped).strip("\n")
|
||||||
|
if not new_text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
async with state._lock:
|
||||||
|
if new_text == state.last_sent_text:
|
||||||
|
continue
|
||||||
|
state.last_sent_text = new_text
|
||||||
|
|
||||||
|
# Cap total outbound per tick so a big burst doesn't flood Telegram.
|
||||||
|
if len(new_text) > MAX_SEND_PER_TICK:
|
||||||
|
keep = new_text[-MAX_SEND_PER_TICK:]
|
||||||
|
# snap to next newline to avoid cutting mid-line
|
||||||
|
nl = keep.find("\n")
|
||||||
|
if 0 <= nl < 200:
|
||||||
|
keep = keep[nl + 1 :]
|
||||||
|
dropped = len(new_text) - len(keep)
|
||||||
|
new_text = f"… (+{dropped} chars earlier)\n{keep}"
|
||||||
|
|
||||||
|
for part in chunk_for_telegram(new_text):
|
||||||
|
try:
|
||||||
|
await bot.send_message(
|
||||||
|
chat_id=state.chat_id,
|
||||||
|
text=part,
|
||||||
|
disable_notification=True,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("telegram send failed: %s", exc)
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
async def on_startup(application: Application) -> None:
|
||||||
|
state: BridgeState = application.bot_data["state"]
|
||||||
|
try:
|
||||||
|
state.last_snapshot = await tmux_capture(TMUX_SESSION)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
state.last_snapshot = ""
|
||||||
|
application.bot_data["poll_task"] = asyncio.create_task(
|
||||||
|
poll_and_forward(application), name="bridge-poll"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def on_shutdown(application: Application) -> None:
|
||||||
|
task = application.bot_data.get("poll_task")
|
||||||
|
if task:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
env = {**os.environ, **load_env(ENV_FILE)}
|
||||||
|
token = env.get("TELEGRAM_BOT_TOKEN", "").strip()
|
||||||
|
chat_id_raw = env.get("TELEGRAM_CHAT_ID", "").strip()
|
||||||
|
if not token or not chat_id_raw:
|
||||||
|
print(
|
||||||
|
"ERROR: TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID must be set in "
|
||||||
|
f"{ENV_FILE} or environment",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 78
|
||||||
|
try:
|
||||||
|
chat_id = int(chat_id_raw)
|
||||||
|
except ValueError:
|
||||||
|
print(f"ERROR: TELEGRAM_CHAT_ID must be an integer, got: {chat_id_raw!r}",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 78
|
||||||
|
|
||||||
|
check = subprocess.run(
|
||||||
|
["tmux", "has-session", "-t", TMUX_SESSION],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if check.returncode != 0:
|
||||||
|
print(
|
||||||
|
f"WARNING: tmux session '{TMUX_SESSION}' not found — bridge will run "
|
||||||
|
"but send/capture will fail until the session is created.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
application = (
|
||||||
|
ApplicationBuilder()
|
||||||
|
.token(token)
|
||||||
|
.post_init(on_startup)
|
||||||
|
.post_shutdown(on_shutdown)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
application.bot_data["state"] = BridgeState(chat_id=chat_id)
|
||||||
|
application.add_handler(CommandHandler("ping", cmd_ping))
|
||||||
|
application.add_handler(CommandHandler("snapshot", cmd_snapshot))
|
||||||
|
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
|
||||||
|
|
||||||
|
application.run_polling(allowed_updates=Update.ALL_TYPES, stop_signals=None)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
1
deploy/telegram-bridge/requirements.txt
Normal file
1
deploy/telegram-bridge/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
python-telegram-bot[rate-limiter]==21.6
|
||||||
19
deploy/telegram-bridge/telegram-bridge.service
Normal file
19
deploy/telegram-bridge/telegram-bridge.service
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
[Unit]
|
||||||
|
Description=food-market Telegram <-> tmux bridge
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=nns
|
||||||
|
Group=nns
|
||||||
|
WorkingDirectory=/opt/food-market-data/telegram-bridge
|
||||||
|
EnvironmentFile=-/etc/food-market/telegram.env
|
||||||
|
ExecStart=/opt/food-market-data/telegram-bridge/venv/bin/python /opt/food-market-data/telegram-bridge/bridge.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
# Access tmux sockets under /tmp/tmux-1000/
|
||||||
|
Environment=TMUX_TMPDIR=/tmp
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
86
docs/telegram-bridge.md
Normal file
86
docs/telegram-bridge.md
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
# Telegram ↔ tmux bridge
|
||||||
|
|
||||||
|
Управление локальной сессией Claude Code с телефона через Telegram-бота. Входящее сообщение от whitelisted `chat_id` набирается в tmux-сессию `claude` как будто вы сами печатаете; ответный вывод пайнa каждые ~2.5 с отправляется обратно в чат.
|
||||||
|
|
||||||
|
## Как это выглядит в работе
|
||||||
|
|
||||||
|
- Вы пишете боту: `запусти тесты`
|
||||||
|
- Бот делает `tmux send-keys -t claude -l "запусти тесты" && tmux send-keys -t claude Enter` — текст попадает в поле ввода Claude
|
||||||
|
- Фоновый поллер раз в 2.5 с снимает `tmux capture-pane`, сравнивает с предыдущим снапшотом, присылает новые строки как `<pre>…</pre>`-блок
|
||||||
|
|
||||||
|
Команды бота:
|
||||||
|
- `/ping` — живой ли, какая сессия и интервал
|
||||||
|
- `/snapshot` — выслать полный текущий пайн (полезно после длинного молчания или после рестарта)
|
||||||
|
|
||||||
|
## Один раз — настройка
|
||||||
|
|
||||||
|
### 1. Креды
|
||||||
|
|
||||||
|
Положите в `/etc/food-market/telegram.env`:
|
||||||
|
```
|
||||||
|
TELEGRAM_BOT_TOKEN=<токен от @BotFather>
|
||||||
|
TELEGRAM_CHAT_ID=<ваш личный chat_id, целое число>
|
||||||
|
```
|
||||||
|
|
||||||
|
Узнать `chat_id` — напишите `@userinfobot` в Telegram, он ответит с вашим id. Файл доступен только владельцу (`chmod 600`).
|
||||||
|
|
||||||
|
Только сообщения от этого **одного** chat_id будут обработаны — всё остальное молча игнорируется.
|
||||||
|
|
||||||
|
### 2. tmux-сессия `claude`
|
||||||
|
|
||||||
|
Бот ожидает существующую сессию с именем `claude`. Создайте её как обычно:
|
||||||
|
```bash
|
||||||
|
tmux new-session -d -s claude
|
||||||
|
tmux attach -t claude # и запустите внутри `claude` (или что там у вас)
|
||||||
|
```
|
||||||
|
Сервис стартует даже без сессии — в лог упадёт warning, но `send-keys` / `capture-pane` начнут работать как только сессия появится. Имя сессии можно переопределить через env `TMUX_SESSION=other` в юните.
|
||||||
|
|
||||||
|
### 3. Старт сервиса
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable --now food-market-telegram-bridge.service
|
||||||
|
```
|
||||||
|
|
||||||
|
В ответ бот пришлёт `✅ bridge up …` — это индикатор успеха.
|
||||||
|
|
||||||
|
## Эксплуатация
|
||||||
|
|
||||||
|
### Логи
|
||||||
|
```bash
|
||||||
|
sudo journalctl -u food-market-telegram-bridge.service -f
|
||||||
|
sudo journalctl -u food-market-telegram-bridge.service --since '10 min ago'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Перезапуск
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart food-market-telegram-bridge.service
|
||||||
|
```
|
||||||
|
|
||||||
|
### Остановить
|
||||||
|
```bash
|
||||||
|
sudo systemctl stop food-market-telegram-bridge.service # до ребута
|
||||||
|
sudo systemctl disable food-market-telegram-bridge.service # и после ребута
|
||||||
|
```
|
||||||
|
|
||||||
|
### Поменять интервал/сессию
|
||||||
|
Отредактируйте `/etc/systemd/system/food-market-telegram-bridge.service`, добавьте в секцию `[Service]`:
|
||||||
|
```
|
||||||
|
Environment=POLL_INTERVAL_SEC=1.5
|
||||||
|
Environment=TMUX_SESSION=other-session
|
||||||
|
Environment=CAPTURE_HISTORY_LINES=400
|
||||||
|
```
|
||||||
|
Затем `sudo systemctl daemon-reload && sudo systemctl restart food-market-telegram-bridge`.
|
||||||
|
|
||||||
|
## Раскладка
|
||||||
|
|
||||||
|
- Скрипт: `/opt/food-market-data/telegram-bridge/bridge.py`
|
||||||
|
- venv (Python 3.12, `python-telegram-bot 21.x`): `/opt/food-market-data/telegram-bridge/venv/`
|
||||||
|
- Креды: `/etc/food-market/telegram.env` (owner `nns`, mode `0600`)
|
||||||
|
- systemd unit: `/etc/systemd/system/food-market-telegram-bridge.service`
|
||||||
|
|
||||||
|
## Что хорошо знать
|
||||||
|
|
||||||
|
- `disable_notification=True` стоит на фоновых сообщениях пайна — не будет жужжать при каждом diff'e.
|
||||||
|
- Telegram-лимит 4096 символов; длинные пайн-блоки режутся на куски по ~3800 символов.
|
||||||
|
- Если после долгого молчания в чате слишком много истории, шлите `/snapshot` — бот обнуляет baseline и присылает текущий экран целиком.
|
||||||
|
- Бот заходит в Telegram long-polling (исходящее к api.telegram.org, без входящих портов) — никакого проброса портов не нужно.
|
||||||
|
|
@ -2,6 +2,10 @@ namespace foodmarket.Domain.Catalog;
|
||||||
|
|
||||||
public enum CounterpartyKind
|
public enum CounterpartyKind
|
||||||
{
|
{
|
||||||
|
/// <summary>Не указано — дефолт для импортированных без явной классификации.
|
||||||
|
/// MoySklad сам не имеет встроенного поля Supplier/Customer, оно ставится
|
||||||
|
/// через теги или группы, и часто отсутствует. Не выдумываем за пользователя.</summary>
|
||||||
|
Unspecified = 0,
|
||||||
Supplier = 1,
|
Supplier = 1,
|
||||||
Customer = 2,
|
Customer = 2,
|
||||||
Both = 3,
|
Both = 3,
|
||||||
|
|
|
||||||
|
|
@ -39,17 +39,14 @@ public async Task<MoySkladImportResult> ImportCounterpartiesAsync(string token,
|
||||||
{
|
{
|
||||||
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context.");
|
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context.");
|
||||||
|
|
||||||
// Map MoySklad tag set → local CounterpartyKind. If no tags say otherwise, assume Both.
|
// MoySklad НЕ имеет поля "Поставщик/Покупатель" у контрагентов вообще — это
|
||||||
|
// не наша выдумка, проверено через API: counterparty entity содержит только
|
||||||
|
// group (группа доступа), tags (произвольные), state (пользовательская цепочка
|
||||||
|
// статусов), companyType (legal/individual/entrepreneur). Никакой role/kind.
|
||||||
|
// Поэтому при импорте ВСЕГДА ставим Unspecified — пользователь сам решит.
|
||||||
|
// Параметр tags оставлен ради совместимости сигнатуры, не используется.
|
||||||
static foodmarket.Domain.Catalog.CounterpartyKind ResolveKind(IReadOnlyList<string>? tags)
|
static foodmarket.Domain.Catalog.CounterpartyKind ResolveKind(IReadOnlyList<string>? tags)
|
||||||
{
|
=> foodmarket.Domain.Catalog.CounterpartyKind.Unspecified;
|
||||||
if (tags is null || tags.Count == 0) return foodmarket.Domain.Catalog.CounterpartyKind.Both;
|
|
||||||
var lower = tags.Select(t => t.ToLowerInvariant()).ToList();
|
|
||||||
var hasSupplier = lower.Any(t => t.Contains("постав"));
|
|
||||||
var hasCustomer = lower.Any(t => t.Contains("покуп") || t.Contains("клиент"));
|
|
||||||
if (hasSupplier && !hasCustomer) return foodmarket.Domain.Catalog.CounterpartyKind.Supplier;
|
|
||||||
if (hasCustomer && !hasSupplier) return foodmarket.Domain.Catalog.CounterpartyKind.Customer;
|
|
||||||
return foodmarket.Domain.Catalog.CounterpartyKind.Both;
|
|
||||||
}
|
|
||||||
|
|
||||||
static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyType)
|
static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyType)
|
||||||
=> companyType switch
|
=> companyType switch
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ export interface PagedResult<T> {
|
||||||
totalPages: number
|
totalPages: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CounterpartyKind = { Supplier: 1, Customer: 2, Both: 3 } as const
|
export const CounterpartyKind = { Unspecified: 0, Supplier: 1, Customer: 2, Both: 3 } as const
|
||||||
export type CounterpartyKind = (typeof CounterpartyKind)[keyof typeof CounterpartyKind]
|
export type CounterpartyKind = (typeof CounterpartyKind)[keyof typeof CounterpartyKind]
|
||||||
|
|
||||||
export const CounterpartyType = { LegalEntity: 1, Individual: 2 } as const
|
export const CounterpartyType = { LegalEntity: 1, Individual: 2 } as const
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ export const useCountries = () => useLookup<Country>('countries', '/api/catalog/
|
||||||
export const useCurrencies = () => useLookup<Currency>('currencies', '/api/catalog/currencies')
|
export const useCurrencies = () => useLookup<Currency>('currencies', '/api/catalog/currencies')
|
||||||
export const useStores = () => useLookup<Store>('stores', '/api/catalog/stores')
|
export const useStores = () => useLookup<Store>('stores', '/api/catalog/stores')
|
||||||
export const usePriceTypes = () => useLookup<PriceType>('price-types', '/api/catalog/price-types')
|
export const usePriceTypes = () => useLookup<PriceType>('price-types', '/api/catalog/price-types')
|
||||||
export const useSuppliers = () => useQuery({
|
// MoySklad-style: контрагент один, может быть и поставщиком, и покупателем
|
||||||
queryKey: ['lookup:suppliers'],
|
// в разных документах. Не фильтруем по Kind — пользователь сам выбирает.
|
||||||
queryFn: async () => (await api.get<PagedResult<Counterparty>>('/api/catalog/counterparties?pageSize=500&kind=1')).data.items,
|
export const useCounterparties = () => useLookup<Counterparty>('counterparties', '/api/catalog/counterparties')
|
||||||
staleTime: 5 * 60 * 1000,
|
// Алиас для обратной совместимости со старым кодом форм Supply/RetailSale.
|
||||||
})
|
export const useSuppliers = useCounterparties
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,9 @@ interface Form {
|
||||||
}
|
}
|
||||||
|
|
||||||
const blankForm: Form = {
|
const blankForm: Form = {
|
||||||
name: '', legalName: '', kind: CounterpartyKind.Supplier, type: CounterpartyType.LegalEntity,
|
// Kind по умолчанию Unspecified — MoySklad не имеет такого поля у контрагентов,
|
||||||
|
// не выдумываем за пользователя. Пусть выберет вручную если нужно.
|
||||||
|
name: '', legalName: '', kind: CounterpartyKind.Unspecified, type: CounterpartyType.LegalEntity,
|
||||||
bin: '', iin: '', taxNumber: '', countryId: '',
|
bin: '', iin: '', taxNumber: '', countryId: '',
|
||||||
address: '', phone: '', email: '',
|
address: '', phone: '', email: '',
|
||||||
bankName: '', bankAccount: '', bik: '',
|
bankName: '', bankAccount: '', bik: '',
|
||||||
|
|
@ -44,9 +46,10 @@ const blankForm: Form = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const kindLabel: Record<CounterpartyKind, string> = {
|
const kindLabel: Record<CounterpartyKind, string> = {
|
||||||
|
[CounterpartyKind.Unspecified]: '—',
|
||||||
[CounterpartyKind.Supplier]: 'Поставщик',
|
[CounterpartyKind.Supplier]: 'Поставщик',
|
||||||
[CounterpartyKind.Customer]: 'Покупатель',
|
[CounterpartyKind.Customer]: 'Покупатель',
|
||||||
[CounterpartyKind.Both]: 'Оба',
|
[CounterpartyKind.Both]: 'Поставщик + Покупатель',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CounterpartiesPage() {
|
export function CounterpartiesPage() {
|
||||||
|
|
@ -138,9 +141,10 @@ export function CounterpartiesPage() {
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Роль">
|
<Field label="Роль">
|
||||||
<Select value={form.kind} onChange={(e) => setForm({ ...form, kind: Number(e.target.value) as CounterpartyKind })}>
|
<Select value={form.kind} onChange={(e) => setForm({ ...form, kind: Number(e.target.value) as CounterpartyKind })}>
|
||||||
|
<option value={CounterpartyKind.Unspecified}>Не указано</option>
|
||||||
<option value={CounterpartyKind.Supplier}>Поставщик</option>
|
<option value={CounterpartyKind.Supplier}>Поставщик</option>
|
||||||
<option value={CounterpartyKind.Customer}>Покупатель</option>
|
<option value={CounterpartyKind.Customer}>Покупатель</option>
|
||||||
<option value={CounterpartyKind.Both}>Оба</option>
|
<option value={CounterpartyKind.Both}>Поставщик + Покупатель</option>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Тип лица">
|
<Field label="Тип лица">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue