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
174 changes: 174 additions & 0 deletions cli/feishu_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
"""``deepcode feishu`` — run the Feishu approval bridge.

The bridge itself lives in :mod:`integrations.feishu_approver`; this module is
only the CLI surface so the integration has a discoverable, documented entry
point instead of a bare ``python -m`` invocation. The heavy ``lark_oapi``
import stays inside :func:`integrations.feishu_approver.__main__.main` so a
configuration error (or ``--help``) never pays the SDK import cost.
"""

from __future__ import annotations

import argparse
import json
import os
import subprocess
import sys
from pathlib import Path


def _pid_file() -> Path:
"""Where the detached bridge records its pid.

Fixed location derived from ``DEEPCODE_HOME`` (falling back to
``~/.deepcode``), never the process cwd — a bridge spawned from another
working directory must still be visible to ``status``/``stop``.
"""
home = os.environ.get("DEEPCODE_HOME") or str(Path.home() / ".deepcode")
return Path(home) / "feishu-approver.pid"


def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="deepcode feishu",
description="Run the Feishu approval bridge.",
)
commands = parser.add_subparsers(dest="command")

start = commands.add_parser("start", help="Run the bridge in the foreground.")
start.add_argument(
"--detach",
action="store_true",
help="Spawn the bridge in the background and return immediately.",
)

commands.add_parser("status", help="Report whether a detached bridge is running.")
commands.add_parser("stop", help="Stop a bridge started with --detach.")
return parser


def _pid_alive(pid: int) -> bool:
"""True when *pid* names a live process (POSIX and Windows)."""
if pid <= 0:
return False
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except OSError:
# Windows raises OSError for "exists but not ours"; anything other
# than a clean lookup is treated as running so stop() can still try.
return True
return True


def _read_pid() -> int | None:
try:
return int(_pid_file().read_text(encoding="utf-8").strip())
except (OSError, ValueError):
return None


def _write_pid(pid: int) -> None:
path = _pid_file()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(str(pid), encoding="utf-8")


def _clear_pid() -> None:
try:
_pid_file().unlink()
except OSError:
pass


def _run_foreground() -> int:
# Deferred so `--help` and a config error stay lark_oapi-free.
from integrations.feishu_approver.__main__ import main as bridge_main

return bridge_main()


def _start_detached() -> int:
existing = _read_pid()
if existing is not None and _pid_alive(existing):
print(f"feishu bridge already running (pid {existing})", file=sys.stderr)
return 1

# Run from the repo root (same convention as the app_server service) so
# ``-m integrations.feishu_approver`` resolves the package regardless of
# the caller's cwd.
from app_server.service_state import SERVICE_WORKING_DIRECTORY

# The bridge already mirrors its logs to ``FEISHU_APPROVER_LOG``; capture
# the detached stdio there too (else a ``pythonw``/detached run drops the
# startup/config-error output into a black hole).
log_path = os.environ.get("FEISHU_APPROVER_LOG", "").strip()
stdout_target = subprocess.DEVNULL
log_handle = None
if log_path:
Path(log_path).parent.mkdir(parents=True, exist_ok=True)
log_handle = open(log_path, "ab") # noqa: SIM115
stdout_target = log_handle
try:
proc = subprocess.Popen(
[sys.executable, "-m", "integrations.feishu_approver"],
stdout=stdout_target,
stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL,
cwd=str(SERVICE_WORKING_DIRECTORY),
close_fds=True,
)
finally:
if log_handle is not None:
log_handle.close()
_write_pid(proc.pid)
print(f"feishu bridge started in background (pid {proc.pid})")
return 0


def _status() -> int:
pid = _read_pid()
if pid is None:
print(json.dumps({"running": False, "reason": "no pid file"}))
return 3
if _pid_alive(pid):
print(json.dumps({"running": True, "pid": pid}))
return 0
print(json.dumps({"running": False, "pid": pid, "reason": "stale pid file"}))
_clear_pid()
return 3


def _stop() -> int:
pid = _read_pid()
if pid is None or not _pid_alive(pid):
print("feishu bridge is not running", file=sys.stderr)
_clear_pid()
return 3
try:
os.kill(pid, 15) # SIGTERM; on Windows this is a best-effort terminate.
except OSError as exc:
print(f"failed to signal pid {pid}: {exc}", file=sys.stderr)
return 1
_clear_pid()
print(f"feishu bridge stopped (pid {pid})")
return 0


def run(argv: list[str] | None = None) -> int:
args = _parser().parse_args(argv)
if args.command == "start":
if args.detach:
return _start_detached()
return _run_foreground()
if args.command == "status":
return _status()
if args.command == "stop":
return _stop()
_parser().print_help()
return 0


if __name__ == "__main__":
raise SystemExit(run())
8 changes: 8 additions & 0 deletions deepcode.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,10 @@ def main():
from cli.automation_cli import run as automation_run

raise SystemExit(automation_run(sys.argv[2:]))
elif sys.argv[1] == "feishu":
from cli.feishu_cli import run as feishu_run

raise SystemExit(feishu_run(sys.argv[2:]))
elif sys.argv[1] in ["--help", "-h", "help"]:
print_banner()

Expand Down Expand Up @@ -240,6 +244,10 @@ def row(cmd, desc):
),
row("deepcode provider list", "Show model connections"),
row("deepcode service status", "Check the background service"),
row(
"deepcode feishu start",
"Run the Feishu approval bridge",
),
"",
"Run 'deepcode <command> --help' for command options.",
"Run 'deepcode chat --help' for TUI options.",
Expand Down
1 change: 1 addition & 0 deletions integrations/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Optional integrations that extend DeepCode at the edges (E1)."""
31 changes: 31 additions & 0 deletions integrations/feishu_approver/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Feishu approval bridge: a phone-side approver for DeepCode runs.

This package is a *separate process* that connects to a running DeepCode
app_server over its native RPC, relays ``approval.requested`` prompts to Feishu
as interactive cards, and writes decisions back via ``approval/respond``. It
makes no changes to the core; it is an edge extension.

Run it with::

python -m integrations.feishu_approver
"""

from __future__ import annotations

__all__ = ["ApprovalBridge", "BridgeSettings", "load_settings"]


def __getattr__(name: str): # lazy re-export; lark_oapi import stays off the hot path
if name == "ApprovalBridge":
from .bridge import ApprovalBridge

return ApprovalBridge
if name == "BridgeSettings":
from .config import BridgeSettings

return BridgeSettings
if name == "load_settings":
from .config import load_settings

return load_settings
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
112 changes: 112 additions & 0 deletions integrations/feishu_approver/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""Entry point: ``python -m integrations.feishu_approver``.

Reads configuration from the environment (see ``config.py`` for the variables),
connects to the local DeepCode app_server, and starts relaying approval prompts
to Feishu. Runs until interrupted; Ctrl-C stops it cleanly.

Example (POSIX)::

export FEISHU_APP_ID=cli_xxx
export FEISHU_APP_SECRET=xxx
export FEISHU_OPEN_IDS=ou_xxx
python -m integrations.feishu_approver
"""

from __future__ import annotations

import asyncio
import logging
import signal
import sys

from integrations.feishu_approver.config import load_settings

try: # Optional convenience: plain env vars still work without python-dotenv.
from dotenv import load_dotenv

load_dotenv()
except ImportError:
pass


def _install_signal_handlers(bridge, loop: asyncio.AbstractEventLoop) -> None:
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, bridge.stop)
except (NotImplementedError, RuntimeError):
# Windows / non-main thread: fall back to the default KeyboardInterrupt.
pass


def _install_file_logging() -> None:
"""Mirror logs to ``FEISHU_APPROVER_LOG`` when set.

``pythonw.exe`` (Windows scheduled task) discards stderr, so without this a
crashed bridge leaves no traceback anywhere. A logging failure here must
never take the bridge down, so it is swallowed (stderr still works under a
real console).
"""
import os

path = os.environ.get("FEISHU_APPROVER_LOG", "").strip()
if not path:
return
try:
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
handler = logging.FileHandler(path, encoding="utf-8")
handler.setFormatter(
logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
)
logging.getLogger().addHandler(handler)
except OSError:
logging.getLogger("feishu-approver").exception(
"failed to open FEISHU_APPROVER_LOG=%s", path
)


async def _amain() -> int:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
_install_file_logging()
log = logging.getLogger("feishu-approver")

try:
settings = load_settings()
except ValueError as exc:
log.error("Configuration error: %s", exc)
return 2

# Imported here so a config error does not pay the lark_oapi import cost.
from integrations.feishu_approver.bridge import ApprovalBridge

bridge = ApprovalBridge(settings)
loop = asyncio.get_running_loop()
_install_signal_handlers(bridge, loop)

log.info(
"Starting Feishu approval bridge (timeout=%ss action=%s, open_ids=%d, chat_ids=%d)",
settings.timeout_seconds,
settings.timeout_action,
len(settings.open_ids),
len(settings.chat_ids),
)
try:
await bridge.run()
except Exception:
log.exception("Feishu approval bridge crashed")
return 1
log.info("Feishu approval bridge stopped")
return 0


def main() -> int:
try:
return asyncio.run(_amain())
except KeyboardInterrupt:
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading