feat: oo_bridge_send — cliente registry del puente OnlyOffice (grupo onlyoffice)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Egutierrez
2026-07-03 18:35:03 +02:00
parent 8a78a70ef6
commit c411df1cfc
3 changed files with 271 additions and 0 deletions
+143
View File
@@ -0,0 +1,143 @@
"""Programmatic client for the OnlyOffice live bridge (apps/onlyoffice_bridge).
Speaks to the loopback server (server.py, 127.0.0.1:8791) that queues commands
for a system plugin running inside OnlyOffice Desktop Editors. The plugin runs
each command with the Automation API (Api.*) over the focused document and
answers. This function pushes one command, polls for its result and returns a
non-throwing dict — it never raises, so callers (pipelines, heredocs, other
functions) can compose it without try/except.
Only stdlib is used (urllib.request, json, time) so the function is importable
from any registry consumer without extra dependencies.
"""
import json
import time
import urllib.error
import urllib.request
# Monotonic per-process counter combined with a millisecond timestamp keeps the
# id unique across quick successive calls without pulling in uuid/random.
_counter = [0]
def _next_id() -> str:
_counter[0] += 1
return "oo%d_%d" % (int(time.time() * 1000), _counter[0])
def oo_bridge_send(
cmd: str,
ref: str = "",
text: str = "",
opts: dict = None,
target: str = "cell",
port: int = 8791,
wait_s: float = 6.0,
) -> dict:
"""Send one command to the OnlyOffice live bridge and return its result.
Pushes ``{cmd, ref, text, opts, id, target}`` to ``POST /push`` on the
loopback bridge server, then long-polls ``GET /pull?id=<id>`` until the
plugin answers or ``wait_s`` elapses. The plugin's answer is a JSON string
such as ``{"result": ...}`` or ``{"error": "..."}``.
Args:
cmd: Automation-API command name. Cell reads: get_cell, get_range,
get_formula, get_used_range, get_sheets. Cell writes: set_cell,
set_range, set_formula. Structure: insert_rows, delete_rows,
insert_cols, delete_cols, merge, add_sheet, rename_sheet,
delete_sheet, activate_sheet. Format: set_format, set_borders,
set_colwidth, set_rowheight. Data: add_named_range, sort,
autofilter. Charts/tables: add_chart, format_as_table,
table_chart. Word: insert_text, get_text.
ref: A1 reference or range the command acts on ("B2", "A1:C10",
"4:6" for rows, "B:C" for cols). Empty for commands with no ref.
text: Text value for write commands (cell value, formula "=SUM(...)",
sheet name, inserted paragraph). Empty for reads.
opts: Extra parameters as a dict, per command. Common keys: values
(2D matrix for set_range), sheet (target sheet by name for any
cell command), bold/italic/underline/fill=[r,g,b]/
fontColor=[r,g,b]/numFormat/fontName/fontSize/wrap/alignH/alignV
(set_format), style (set_borders), width (set_colwidth), height
(set_rowheight), by/desc (sort), type/dataRange/pos (add_chart),
dataRange/type (table_chart). None is treated as {}.
target: Destination editor: "cell" (Spreadsheet), "word" (Document),
"slide" (Presentation). Default "cell".
port: Loopback bridge server port. Default 8791.
wait_s: Seconds to poll /pull before giving up with a timeout error.
Default 6.0.
Returns:
A non-throwing dict. On success: ``{"status": "ok", "result": <value>}``
where ``<value>`` is whatever the plugin returned (a cell string, a 2D
matrix for get_range, a list of sheet names, or "ok" for writes). On
failure: ``{"status": "error", "error": <message>}`` when the plugin
replied with an error, when the poll timed out (editor not focused or
plugin not started), or on any network failure (server not running).
Never raises.
"""
base = "http://127.0.0.1:%d" % port
rid = _next_id()
payload = {
"cmd": cmd,
"ref": ref,
"text": text,
"opts": opts if opts is not None else {},
"id": rid,
"target": target,
}
net_timeout = max(wait_s, 2.0)
# 1) Enqueue the command on the bridge server.
try:
push_body = json.dumps(payload).encode("utf-8")
push_req = urllib.request.Request(
base + "/push",
data=push_body,
method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(push_req, timeout=net_timeout) as resp:
resp.read()
except Exception as exc: # noqa: BLE001 — no-throw contract
return {
"status": "error",
"error": "push failed: %s (is server.py running on port %d?)"
% (exc, port),
}
# 2) Poll for the plugin's answer until it is ready or wait_s elapses.
deadline = time.time() + wait_s
while time.time() < deadline:
try:
pull_req = urllib.request.Request(
base + "/pull?id=" + rid, method="GET"
)
with urllib.request.urlopen(pull_req, timeout=net_timeout) as resp:
res = json.loads(resp.read().decode("utf-8"))
except Exception as exc: # noqa: BLE001 — no-throw contract
return {"status": "error", "error": "pull failed: %s" % exc}
if res.get("ready"):
raw = res.get("text", "")
if not raw:
return {"status": "ok", "result": None}
try:
parsed = json.loads(raw)
except (ValueError, TypeError):
# Plugin returned non-JSON text; hand it back raw.
return {"status": "ok", "result": raw}
if isinstance(parsed, dict) and "error" in parsed:
return {"status": "error", "error": parsed["error"]}
if isinstance(parsed, dict) and "result" in parsed:
return {"status": "ok", "result": parsed["result"]}
return {"status": "ok", "result": parsed}
time.sleep(0.4)
return {
"status": "error",
"error": "timeout: no reply in %.1fs "
"(editor not focused or plugin not started)" % wait_s,
}