chore: lint
This commit is contained in:
@@ -567,7 +567,7 @@ def build_llm_compaction_prompt(
|
||||
user_messages_section = (
|
||||
"6. **User Messages** — Reproduce EVERY user message verbatim and "
|
||||
"in full, in chronological order, each on its own line prefixed "
|
||||
"with the message index (e.g. \"[U1] ...\"). Do NOT paraphrase, "
|
||||
'with the message index (e.g. "[U1] ..."). Do NOT paraphrase, '
|
||||
"summarise, merge, or omit any user message. Preserve markdown, "
|
||||
"code fences, whitespace, and punctuation exactly as the user "
|
||||
"wrote them.\n"
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -227,9 +228,10 @@ def discover_agents() -> dict[str, list[AgentEntry]]:
|
||||
# Fallback: use directory creation time if metadata lacks created_at
|
||||
if not colony_created_at:
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
|
||||
stat = path.stat()
|
||||
colony_created_at = datetime.fromtimestamp(stat.st_birthtime, tz=timezone.utc).isoformat()
|
||||
colony_created_at = datetime.fromtimestamp(stat.st_birthtime, tz=UTC).isoformat()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -112,10 +112,7 @@ def format_conversation_excerpt(messages: list[Message]) -> str:
|
||||
# Surface tool-call intent for empty assistant turns so the
|
||||
# evaluator sees what the queen has been doing.
|
||||
if not content and msg.tool_calls:
|
||||
names = [
|
||||
tc.get("function", {}).get("name", "?")
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
names = [tc.get("function", {}).get("name", "?") for tc in msg.tool_calls]
|
||||
content = f"(called: {', '.join(names)})"
|
||||
if len(content) > _MAX_ASSISTANT_CONTENT_CHARS:
|
||||
content = content[:_MAX_ASSISTANT_CONTENT_CHARS] + "..."
|
||||
@@ -224,9 +221,7 @@ async def evaluate(
|
||||
return {
|
||||
"ready": False,
|
||||
"reasons": ["evaluation_failed"],
|
||||
"missing_prerequisites": [
|
||||
"evaluator LLM call failed; retry once the queen can reach the model again"
|
||||
],
|
||||
"missing_prerequisites": ["evaluator LLM call failed; retry once the queen can reach the model again"],
|
||||
}
|
||||
|
||||
raw = (getattr(response, "content", "") or "").strip()
|
||||
@@ -239,9 +234,7 @@ async def evaluate(
|
||||
return {
|
||||
"ready": False,
|
||||
"reasons": ["evaluation_failed"],
|
||||
"missing_prerequisites": [
|
||||
"evaluator returned malformed JSON; retry"
|
||||
],
|
||||
"missing_prerequisites": ["evaluator returned malformed JSON; retry"],
|
||||
}
|
||||
|
||||
return _normalize_verdict(parsed)
|
||||
|
||||
@@ -474,12 +474,7 @@ queen_node = NodeSpec(
|
||||
nullable_output_keys=[], # Queen should never have this
|
||||
skip_judge=True, # Queen is a conversational agent; suppress tool-use pressure feedback
|
||||
tools=sorted(
|
||||
set(
|
||||
_QUEEN_INDEPENDENT_TOOLS
|
||||
+ _QUEEN_INCUBATING_TOOLS
|
||||
+ _QUEEN_WORKING_TOOLS
|
||||
+ _QUEEN_REVIEWING_TOOLS
|
||||
)
|
||||
set(_QUEEN_INDEPENDENT_TOOLS + _QUEEN_INCUBATING_TOOLS + _QUEEN_WORKING_TOOLS + _QUEEN_REVIEWING_TOOLS)
|
||||
),
|
||||
system_prompt=(
|
||||
_queen_character_core
|
||||
@@ -492,12 +487,7 @@ queen_node = NodeSpec(
|
||||
)
|
||||
|
||||
ALL_QUEEN_TOOLS = sorted(
|
||||
set(
|
||||
_QUEEN_INDEPENDENT_TOOLS
|
||||
+ _QUEEN_INCUBATING_TOOLS
|
||||
+ _QUEEN_WORKING_TOOLS
|
||||
+ _QUEEN_REVIEWING_TOOLS
|
||||
)
|
||||
set(_QUEEN_INDEPENDENT_TOOLS + _QUEEN_INCUBATING_TOOLS + _QUEEN_WORKING_TOOLS + _QUEEN_REVIEWING_TOOLS)
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -141,8 +141,7 @@ async def await_completion(
|
||||
return last
|
||||
if loop.time() >= deadline:
|
||||
logger.warning(
|
||||
"compaction_status: timed out after %.0fs waiting for %s "
|
||||
"(proceeding with raw transcript)",
|
||||
"compaction_status: timed out after %.0fs waiting for %s (proceeding with raw transcript)",
|
||||
timeout,
|
||||
queen_dir,
|
||||
)
|
||||
|
||||
@@ -1081,8 +1081,7 @@ async def _compact_inherited_conversation(
|
||||
of the summary.
|
||||
"""
|
||||
import json as _json
|
||||
from datetime import UTC as _UTC
|
||||
from datetime import datetime as _datetime
|
||||
from datetime import UTC as _UTC, datetime as _datetime
|
||||
|
||||
try:
|
||||
result = await _compact_queen_conversation_in_place(
|
||||
@@ -1140,8 +1139,7 @@ async def _compact_inherited_conversation(
|
||||
logger.warning("compact_inherited: failed to append fork marker", exc_info=True)
|
||||
|
||||
logger.info(
|
||||
"compact_inherited: compacted %d parent message(s) -> 1 summary "
|
||||
"(%d chars) for colony forked from %s",
|
||||
"compact_inherited: compacted %d parent message(s) -> 1 summary (%d chars) for colony forked from %s",
|
||||
messages_compacted,
|
||||
summary_chars,
|
||||
source_session_id,
|
||||
@@ -1501,8 +1499,7 @@ async def fork_session_into_colony(
|
||||
)
|
||||
except TimeoutError:
|
||||
compaction_error = (
|
||||
f"compaction timed out after {_COMPACTION_TIMEOUT_SECONDS:.0f}s "
|
||||
"(falling back to raw transcript)"
|
||||
f"compaction timed out after {_COMPACTION_TIMEOUT_SECONDS:.0f}s (falling back to raw transcript)"
|
||||
)
|
||||
logger.warning(
|
||||
"fork_session_into_colony: %s for %s",
|
||||
@@ -1512,8 +1509,7 @@ async def fork_session_into_colony(
|
||||
except Exception as exc:
|
||||
compaction_error = f"compaction failed: {exc}"
|
||||
logger.warning(
|
||||
"fork_session_into_colony: %s for %s "
|
||||
"(falling back to raw transcript)",
|
||||
"fork_session_into_colony: %s for %s (falling back to raw transcript)",
|
||||
compaction_error,
|
||||
_dest_queen_dir,
|
||||
exc_info=True,
|
||||
@@ -1619,9 +1615,7 @@ async def fork_session_into_colony(
|
||||
# compact). Frontend uses this to decide whether to display a
|
||||
# "preparing colony…" state while session-load blocks on the
|
||||
# compaction marker.
|
||||
"compaction_status": (
|
||||
"in_progress" if source_queen_dir.exists() else "skipped"
|
||||
),
|
||||
"compaction_status": ("in_progress" if source_queen_dir.exists() else "skipped"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -86,8 +86,6 @@ _INCUBATING_APPROVAL_GUIDANCE = (
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_credentials_block(provider: Any) -> str:
|
||||
"""Call a credentials_prompt_provider safely and return its output.
|
||||
|
||||
@@ -570,9 +568,7 @@ async def _start_trigger_timer(session: Any, trigger_id: str, tdef: Any) -> None
|
||||
if cron_expr:
|
||||
try:
|
||||
_peek = croniter(cron_expr, datetime.now(tz=UTC)).get_next(datetime)
|
||||
_next_delay = max(
|
||||
0.0, (_peek - datetime.now(tz=UTC)).total_seconds()
|
||||
)
|
||||
_next_delay = max(0.0, (_peek - datetime.now(tz=UTC)).total_seconds())
|
||||
except Exception:
|
||||
_next_delay = 60.0
|
||||
else:
|
||||
@@ -1557,8 +1553,7 @@ def register_queen_lifecycle_tools(
|
||||
return None, f"triggers[{idx}] ('{tid}') interval_minutes must be > 0, got {interval}"
|
||||
else:
|
||||
return None, (
|
||||
f"triggers[{idx}] ('{tid}') timer trigger needs 'cron' or "
|
||||
"'interval_minutes' in trigger_config."
|
||||
f"triggers[{idx}] ('{tid}') timer trigger needs 'cron' or 'interval_minutes' in trigger_config."
|
||||
)
|
||||
else: # webhook
|
||||
path = (t_config.get("path") or "").strip() if isinstance(t_config.get("path"), str) else ""
|
||||
@@ -1573,7 +1568,9 @@ def register_queen_lifecycle_tools(
|
||||
"trigger_type": t_type,
|
||||
"trigger_config": t_config,
|
||||
"task": task_str.strip(),
|
||||
"name": entry.get("name") if isinstance(entry.get("name"), str) and entry.get("name").strip() else tid,
|
||||
"name": (
|
||||
entry.get("name") if isinstance(entry.get("name"), str) and entry.get("name").strip() else tid
|
||||
),
|
||||
}
|
||||
)
|
||||
return normalized, None
|
||||
@@ -1698,7 +1695,9 @@ def register_queen_lifecycle_tools(
|
||||
colony_name=cn,
|
||||
task=(task or "").strip(),
|
||||
tasks=tasks if isinstance(tasks, list) else None,
|
||||
concurrency_hint=concurrency_hint if isinstance(concurrency_hint, int) and concurrency_hint > 0 else None,
|
||||
concurrency_hint=(
|
||||
concurrency_hint if isinstance(concurrency_hint, int) and concurrency_hint > 0 else None
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("create_colony: fork failed after installing skill")
|
||||
@@ -1739,9 +1738,7 @@ def register_queen_lifecycle_tools(
|
||||
# the background; opening the colony will
|
||||
# block on that until it finishes. "skipped"
|
||||
# means no compaction was needed.
|
||||
"compaction_status": fork_result.get(
|
||||
"compaction_status", "skipped"
|
||||
),
|
||||
"compaction_status": fork_result.get("compaction_status", "skipped"),
|
||||
},
|
||||
)
|
||||
)
|
||||
@@ -1820,9 +1817,7 @@ def register_queen_lifecycle_tools(
|
||||
"tasks_seeded": len(fork_result.get("task_ids") or []),
|
||||
# Transcript compaction runs in the background; opening
|
||||
# the colony blocks on this marker until it finishes.
|
||||
"compaction_status": fork_result.get(
|
||||
"compaction_status", "skipped"
|
||||
),
|
||||
"compaction_status": fork_result.get("compaction_status", "skipped"),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2105,12 +2100,7 @@ def register_queen_lifecycle_tools(
|
||||
cn = (colony_name or "").strip()
|
||||
if not _COLONY_NAME_RE.match(cn):
|
||||
return json.dumps(
|
||||
{
|
||||
"error": (
|
||||
"colony_name must be lowercase alphanumeric with "
|
||||
"underscores (e.g. 'morning_hn_digest')."
|
||||
)
|
||||
}
|
||||
{"error": ("colony_name must be lowercase alphanumeric with underscores (e.g. 'morning_hn_digest').")}
|
||||
)
|
||||
|
||||
purpose = (intended_purpose or "").strip()
|
||||
@@ -2197,9 +2187,7 @@ def register_queen_lifecycle_tools(
|
||||
"status": "not_ready",
|
||||
"colony_name": cn,
|
||||
"reasons": verdict.get("reasons", []),
|
||||
"missing_prerequisites": verdict.get(
|
||||
"missing_prerequisites", []
|
||||
),
|
||||
"missing_prerequisites": verdict.get("missing_prerequisites", []),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -565,9 +565,7 @@ async def test_triggers_written_to_triggers_json(patched_home: Path, patched_for
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_triggers_omitted_does_not_write_triggers_json(
|
||||
patched_home: Path, patched_fork: list[dict]
|
||||
) -> None:
|
||||
async def test_triggers_omitted_does_not_write_triggers_json(patched_home: Path, patched_fork: list[dict]) -> None:
|
||||
"""No triggers arg → no triggers.json (colony runs on-demand)."""
|
||||
executor, _ = _make_executor()
|
||||
|
||||
@@ -585,9 +583,7 @@ async def test_triggers_omitted_does_not_write_triggers_json(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_triggers_invalid_cron_fails_before_fork(
|
||||
patched_home: Path, patched_fork: list[dict]
|
||||
) -> None:
|
||||
async def test_triggers_invalid_cron_fails_before_fork(patched_home: Path, patched_fork: list[dict]) -> None:
|
||||
"""A bad cron fails fast: no skill written, no fork call."""
|
||||
executor, _ = _make_executor()
|
||||
|
||||
|
||||
@@ -469,11 +469,13 @@ class BeelineBridge:
|
||||
parsed = json.loads(payload)
|
||||
except Exception:
|
||||
parsed = {"_raw": payload}
|
||||
write_log({
|
||||
"type": "viewport_event",
|
||||
"tab_id": tab_id,
|
||||
**parsed,
|
||||
})
|
||||
write_log(
|
||||
{
|
||||
"type": "viewport_event",
|
||||
"tab_id": tab_id,
|
||||
**parsed,
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# Attach-time canary → attach_canary (proves extension
|
||||
@@ -483,11 +485,13 @@ class BeelineBridge:
|
||||
parsed = json.loads(payload)
|
||||
except Exception:
|
||||
parsed = {"_raw": payload}
|
||||
write_log({
|
||||
"type": "attach_canary",
|
||||
"tab_id": tab_id,
|
||||
**parsed,
|
||||
})
|
||||
write_log(
|
||||
{
|
||||
"type": "attach_canary",
|
||||
"tab_id": tab_id,
|
||||
**parsed,
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# Everything else — keep a compact row so we can tell
|
||||
@@ -503,24 +507,28 @@ class BeelineBridge:
|
||||
compact.append(v[:120])
|
||||
elif v is not None:
|
||||
compact.append(str(v)[:120])
|
||||
write_log({
|
||||
"type": "cdp_event",
|
||||
"tab_id": tab_id,
|
||||
"method": method,
|
||||
"level": params.get("type"),
|
||||
"args": compact,
|
||||
})
|
||||
write_log(
|
||||
{
|
||||
"type": "cdp_event",
|
||||
"tab_id": tab_id,
|
||||
"method": method,
|
||||
"level": params.get("type"),
|
||||
"args": compact,
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# Other forwarded events (Page.lifecycleEvent, frameResized,
|
||||
# frameNavigated, Target.targetInfoChanged) are rare and high
|
||||
# signal — keep the full param dict but truncate strings.
|
||||
write_log({
|
||||
"type": "cdp_event",
|
||||
"tab_id": tab_id,
|
||||
"method": method,
|
||||
"params": params,
|
||||
})
|
||||
write_log(
|
||||
{
|
||||
"type": "cdp_event",
|
||||
"tab_id": tab_id,
|
||||
"method": method,
|
||||
"params": params,
|
||||
}
|
||||
)
|
||||
|
||||
# Main-frame navigation wipes the previous document's scripts.
|
||||
# Our [hive_vp] probe's event listeners die with it. Reinstall
|
||||
@@ -541,6 +549,7 @@ class BeelineBridge:
|
||||
if method == "Page.frameResized" and tab_id is not None:
|
||||
try:
|
||||
from .tools.inspection import _viewport_sizes
|
||||
|
||||
_viewport_sizes.pop(tab_id, None)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -550,6 +559,7 @@ class BeelineBridge:
|
||||
current document of ``tab_id``. Called from sync context
|
||||
inside ``_handle_cdp_event``, so we create a task on the
|
||||
running loop. Failures are silent."""
|
||||
|
||||
async def _do() -> None:
|
||||
try:
|
||||
# The new document's global scope doesn't have
|
||||
@@ -566,6 +576,7 @@ class BeelineBridge:
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
asyncio.get_event_loop().create_task(_do())
|
||||
except RuntimeError:
|
||||
@@ -868,8 +879,7 @@ class BeelineBridge:
|
||||
{
|
||||
"expression": (
|
||||
"console.info('[hive_attach_canary]', "
|
||||
"JSON.stringify({tabId: "
|
||||
+ str(tab_id) + ", ts: Date.now()}))"
|
||||
"JSON.stringify({tabId: " + str(tab_id) + ", ts: Date.now()}))"
|
||||
),
|
||||
"returnByValue": True,
|
||||
"awaitPromise": False,
|
||||
@@ -1379,9 +1389,7 @@ class BeelineBridge:
|
||||
# `(function(){ return (...)(x,y) })()` and the value
|
||||
# actually comes back — without it the wrapper drops
|
||||
# the result on the floor (returns undefined).
|
||||
probe_result = await self.evaluate(
|
||||
tab_id, f"return ({_HIT_ELEMENT_JS})({x}, {y})"
|
||||
)
|
||||
probe_result = await self.evaluate(tab_id, f"return ({_HIT_ELEMENT_JS})({x}, {y})")
|
||||
hit_probe = (probe_result or {}).get("result")
|
||||
except Exception:
|
||||
hit_probe = None
|
||||
@@ -1410,16 +1418,19 @@ class BeelineBridge:
|
||||
if hit_probe is not None:
|
||||
try:
|
||||
from .telemetry import write_log
|
||||
write_log({
|
||||
"type": "click_hit_probe",
|
||||
"tab_id": tab_id,
|
||||
"intended": {"x": x, "y": y},
|
||||
"viewport": hit_probe.get("viewport"),
|
||||
"hit": hit_probe.get("hit"),
|
||||
"stack": hit_probe.get("stack"),
|
||||
"sweep": hit_probe.get("sweep"),
|
||||
"offsetInRect": hit_probe.get("offsetInRect"),
|
||||
})
|
||||
|
||||
write_log(
|
||||
{
|
||||
"type": "click_hit_probe",
|
||||
"tab_id": tab_id,
|
||||
"intended": {"x": x, "y": y},
|
||||
"viewport": hit_probe.get("viewport"),
|
||||
"hit": hit_probe.get("hit"),
|
||||
"stack": hit_probe.get("stack"),
|
||||
"sweep": hit_probe.get("sweep"),
|
||||
"offsetInRect": hit_probe.get("offsetInRect"),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return resp
|
||||
|
||||
@@ -240,23 +240,22 @@ async def _ensure_viewport_size(tab_id: int, _caller: str = "unknown") -> tuple[
|
||||
|
||||
try:
|
||||
from ..telemetry import write_log
|
||||
write_log({
|
||||
"type": "viewport_sample",
|
||||
"tab_id": tab_id,
|
||||
"caller": _caller,
|
||||
"live_w": cw,
|
||||
"live_h": ch,
|
||||
"cached_w": cached_before[0] if cached_before else None,
|
||||
"cached_h": cached_before[1] if cached_before else None,
|
||||
"deltaH_vs_cache": (
|
||||
(ch - cached_before[1])
|
||||
if (cached_before and ch > 0)
|
||||
else None
|
||||
),
|
||||
"returned_w": result_cw,
|
||||
"returned_h": result_ch,
|
||||
"evaluate_error": evaluate_error,
|
||||
})
|
||||
|
||||
write_log(
|
||||
{
|
||||
"type": "viewport_sample",
|
||||
"tab_id": tab_id,
|
||||
"caller": _caller,
|
||||
"live_w": cw,
|
||||
"live_h": ch,
|
||||
"cached_w": cached_before[0] if cached_before else None,
|
||||
"cached_h": cached_before[1] if cached_before else None,
|
||||
"deltaH_vs_cache": ((ch - cached_before[1]) if (cached_before and ch > 0) else None),
|
||||
"returned_w": result_cw,
|
||||
"returned_h": result_ch,
|
||||
"evaluate_error": evaluate_error,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -362,31 +361,32 @@ def register_inspection_tools(mcp: FastMCP) -> None:
|
||||
# physical_scale is derived from pngWidth.
|
||||
try:
|
||||
from ..telemetry import write_log
|
||||
|
||||
expected_w = css_width * dpr
|
||||
expected_h = css_height_raw * dpr
|
||||
write_log({
|
||||
"type": "screenshot_geometry",
|
||||
"tab_id": target_tab,
|
||||
"url": screenshot_result.get("url", ""),
|
||||
"pngWidth": png_w,
|
||||
"pngHeight": png_h,
|
||||
"cssWidth": css_width,
|
||||
"cssHeight": css_height_raw,
|
||||
"dpr": dpr,
|
||||
"expectedPngWidth": expected_w,
|
||||
"expectedPngHeight": expected_h,
|
||||
"deltaPngWidthPx": png_w - expected_w,
|
||||
"deltaPngHeightPx": png_h - expected_h,
|
||||
# If the PNG is taller than cssHeight×dpr (e.g. a
|
||||
# devtools-attached banner adds rows above the page
|
||||
# in the capture), clicks land BELOW intended at
|
||||
# the top of the page and converge to 0 error at
|
||||
# the bottom. Reverse signs if PNG is shorter.
|
||||
# Worst-case error in CSS px at fy=0:
|
||||
"yErrorAtTopCssPx": (
|
||||
(png_h - expected_h) / dpr if dpr else 0
|
||||
),
|
||||
})
|
||||
write_log(
|
||||
{
|
||||
"type": "screenshot_geometry",
|
||||
"tab_id": target_tab,
|
||||
"url": screenshot_result.get("url", ""),
|
||||
"pngWidth": png_w,
|
||||
"pngHeight": png_h,
|
||||
"cssWidth": css_width,
|
||||
"cssHeight": css_height_raw,
|
||||
"dpr": dpr,
|
||||
"expectedPngWidth": expected_w,
|
||||
"expectedPngHeight": expected_h,
|
||||
"deltaPngWidthPx": png_w - expected_w,
|
||||
"deltaPngHeightPx": png_h - expected_h,
|
||||
# If the PNG is taller than cssHeight×dpr (e.g. a
|
||||
# devtools-attached banner adds rows above the page
|
||||
# in the capture), clicks land BELOW intended at
|
||||
# the top of the page and converge to 0 error at
|
||||
# the bottom. Reverse signs if PNG is shorter.
|
||||
# Worst-case error in CSS px at fy=0:
|
||||
"yErrorAtTopCssPx": ((png_h - expected_h) / dpr if dpr else 0),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -64,11 +64,7 @@ async def _build_visual_response(result: dict, bridge, target_tab: int | None) -
|
||||
shot = await bridge.screenshot(target_tab, full_page=False)
|
||||
if not shot.get("ok"):
|
||||
return [text_block]
|
||||
highlights = (
|
||||
[_interaction_highlights[target_tab]]
|
||||
if target_tab in _interaction_highlights
|
||||
else None
|
||||
)
|
||||
highlights = [_interaction_highlights[target_tab]] if target_tab in _interaction_highlights else None
|
||||
data, _ = await asyncio.to_thread(
|
||||
_resize_and_annotate,
|
||||
shot["data"],
|
||||
|
||||
Reference in New Issue
Block a user