fix(slack): make video attachments available to agents (#45512)
This commit is contained in:
parent
197337cc47
commit
2a5dc0ef3d
@ -45,10 +45,12 @@ from gateway.platforms.base import (
|
|||||||
ProcessingOutcome,
|
ProcessingOutcome,
|
||||||
SendResult,
|
SendResult,
|
||||||
SUPPORTED_DOCUMENT_TYPES,
|
SUPPORTED_DOCUMENT_TYPES,
|
||||||
|
SUPPORTED_VIDEO_TYPES,
|
||||||
is_host_excluded_by_no_proxy,
|
is_host_excluded_by_no_proxy,
|
||||||
resolve_proxy_url,
|
resolve_proxy_url,
|
||||||
safe_url_for_log,
|
safe_url_for_log,
|
||||||
cache_document_from_bytes,
|
cache_document_from_bytes,
|
||||||
|
cache_video_from_bytes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -880,7 +882,7 @@ class SlackAdapter(BasePlatformAdapter):
|
|||||||
# doesn't log noisy 404 "unhandled request" warnings.
|
# doesn't log noisy 404 "unhandled request" warnings.
|
||||||
@self._app.event("file_shared")
|
@self._app.event("file_shared")
|
||||||
async def handle_file_shared(event, say):
|
async def handle_file_shared(event, say):
|
||||||
pass
|
await self._handle_slack_file_shared(event)
|
||||||
|
|
||||||
@self._app.event("file_created")
|
@self._app.event("file_created")
|
||||||
async def handle_file_created(event, say):
|
async def handle_file_created(event, say):
|
||||||
@ -2173,6 +2175,84 @@ class SlackAdapter(BasePlatformAdapter):
|
|||||||
self._cache_assistant_thread_metadata(metadata)
|
self._cache_assistant_thread_metadata(metadata)
|
||||||
self._seed_assistant_thread_session(metadata)
|
self._seed_assistant_thread_session(metadata)
|
||||||
|
|
||||||
|
async def _handle_slack_file_shared(self, event: dict) -> None:
|
||||||
|
"""Fallback for Slack file shares that do not arrive as message.files.
|
||||||
|
|
||||||
|
Slack documents ``file_shared`` as a file-ID-only event; callers must
|
||||||
|
fetch ``files.info`` to get the file object. Keep this intentionally
|
||||||
|
narrow: normal image/audio/document uploads already arrive on the
|
||||||
|
message event, but some video shares have only been observed through
|
||||||
|
this lifecycle event.
|
||||||
|
"""
|
||||||
|
channel_id = event.get("channel_id") or event.get("channel") or ""
|
||||||
|
file_id = event.get("file_id") or (event.get("file") or {}).get("id") or ""
|
||||||
|
if not channel_id or not file_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
team_id = event.get("team_id") or event.get("team") or ""
|
||||||
|
try:
|
||||||
|
client = self._team_clients.get(team_id) if team_id else None
|
||||||
|
info_resp = await (client or self._get_client(channel_id)).files_info(
|
||||||
|
file=file_id
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
response = getattr(exc, "response", None)
|
||||||
|
detail = self._describe_slack_api_error(response, file_obj={"id": file_id})
|
||||||
|
logger.warning("[Slack] files.info error for file_shared %s: %s", file_id, detail or exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not info_resp.get("ok"):
|
||||||
|
detail = self._describe_slack_api_error(info_resp, file_obj={"id": file_id})
|
||||||
|
logger.warning(
|
||||||
|
"[Slack] files.info failed for file_shared %s: %s",
|
||||||
|
file_id,
|
||||||
|
detail or info_resp.get("error"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
file_obj = info_resp.get("file") or {}
|
||||||
|
if not str(file_obj.get("mimetype", "")).startswith("video/"):
|
||||||
|
return
|
||||||
|
|
||||||
|
share = None
|
||||||
|
for bucket in (file_obj.get("shares") or {}).values():
|
||||||
|
if not isinstance(bucket, dict):
|
||||||
|
continue
|
||||||
|
channel_shares = bucket.get(channel_id)
|
||||||
|
if channel_shares:
|
||||||
|
share = channel_shares[0]
|
||||||
|
break
|
||||||
|
if share is None:
|
||||||
|
for shares in bucket.values():
|
||||||
|
if shares:
|
||||||
|
share = shares[0]
|
||||||
|
break
|
||||||
|
share = share or {}
|
||||||
|
ts = share.get("ts") or event.get("event_ts") or ""
|
||||||
|
thread_ts = share.get("thread_ts") or ""
|
||||||
|
|
||||||
|
# Give Slack's normal message.file_share event a chance to arrive first.
|
||||||
|
# If it does, _handle_slack_message records the same share ts and this
|
||||||
|
# fallback skips instead of duplicating the user turn.
|
||||||
|
await asyncio.sleep(0.75)
|
||||||
|
if ts and self._dedup.is_duplicate(ts):
|
||||||
|
return
|
||||||
|
|
||||||
|
fallback_event = {
|
||||||
|
"type": "message",
|
||||||
|
"subtype": "file_share",
|
||||||
|
"text": "",
|
||||||
|
"user": event.get("user_id") or file_obj.get("user", ""),
|
||||||
|
"channel": channel_id,
|
||||||
|
"channel_type": "im" if channel_id.startswith("D") else "channel",
|
||||||
|
"team": team_id,
|
||||||
|
"ts": "", # already recorded above; avoid tripping our own dedup guard
|
||||||
|
"files": [file_obj],
|
||||||
|
}
|
||||||
|
if thread_ts and thread_ts != ts:
|
||||||
|
fallback_event["thread_ts"] = thread_ts
|
||||||
|
await self._handle_slack_message(fallback_event)
|
||||||
|
|
||||||
async def _handle_slack_message(self, event: dict) -> None:
|
async def _handle_slack_message(self, event: dict) -> None:
|
||||||
"""Handle an incoming Slack message event."""
|
"""Handle an incoming Slack message event."""
|
||||||
# Dedup: Slack Socket Mode can redeliver events after reconnects (#4777)
|
# Dedup: Slack Socket Mode can redeliver events after reconnects (#4777)
|
||||||
@ -2572,6 +2652,36 @@ class SlackAdapter(BasePlatformAdapter):
|
|||||||
e,
|
e,
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
|
elif mimetype.startswith("video/") and url:
|
||||||
|
try:
|
||||||
|
original_filename = f.get("name", "")
|
||||||
|
_, ext = os.path.splitext(original_filename)
|
||||||
|
ext = ext.lower()
|
||||||
|
if ext not in SUPPORTED_VIDEO_TYPES:
|
||||||
|
mime_to_ext = {v: k for k, v in SUPPORTED_VIDEO_TYPES.items()}
|
||||||
|
ext = mime_to_ext.get(
|
||||||
|
mimetype.split(";", 1)[0].lower(), ".mp4"
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_bytes = await self._download_slack_file_bytes(
|
||||||
|
url, team_id=team_id
|
||||||
|
)
|
||||||
|
cached_path = cache_video_from_bytes(raw_bytes, ext=ext)
|
||||||
|
media_urls.append(cached_path)
|
||||||
|
media_types.append(SUPPORTED_VIDEO_TYPES.get(ext, mimetype))
|
||||||
|
logger.debug("[Slack] Cached user video: %s", cached_path)
|
||||||
|
except Exception as e: # pragma: no cover - defensive logging
|
||||||
|
detail = self._describe_slack_download_failure(e, file_obj=f)
|
||||||
|
if detail:
|
||||||
|
attachment_notices.append(detail)
|
||||||
|
logger.warning("[Slack] %s", detail)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"[Slack] Failed to cache video from %s: %s",
|
||||||
|
url,
|
||||||
|
e,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
elif url:
|
elif url:
|
||||||
# Try to handle as a document attachment
|
# Try to handle as a document attachment
|
||||||
try:
|
try:
|
||||||
@ -2666,6 +2776,8 @@ class SlackAdapter(BasePlatformAdapter):
|
|||||||
if msg_type != MessageType.COMMAND and media_types:
|
if msg_type != MessageType.COMMAND and media_types:
|
||||||
if any(m.startswith("image/") for m in media_types):
|
if any(m.startswith("image/") for m in media_types):
|
||||||
msg_type = MessageType.PHOTO
|
msg_type = MessageType.PHOTO
|
||||||
|
elif any(m.startswith("video/") for m in media_types):
|
||||||
|
msg_type = MessageType.VIDEO
|
||||||
elif any(m.startswith("audio/") for m in media_types):
|
elif any(m.startswith("audio/") for m in media_types):
|
||||||
msg_type = MessageType.VOICE
|
msg_type = MessageType.VOICE
|
||||||
else:
|
else:
|
||||||
|
|||||||
@ -1420,6 +1420,8 @@ def _build_media_placeholder(event) -> str:
|
|||||||
parts.append(f"[User sent an image: {url}]")
|
parts.append(f"[User sent an image: {url}]")
|
||||||
elif mtype.startswith("audio/"):
|
elif mtype.startswith("audio/"):
|
||||||
parts.append(f"[User sent audio: {url}]")
|
parts.append(f"[User sent audio: {url}]")
|
||||||
|
elif mtype.startswith("video/") or getattr(event, "message_type", None) == MessageType.VIDEO:
|
||||||
|
parts.append(f"[User sent a video: {url}]")
|
||||||
else:
|
else:
|
||||||
parts.append(f"[User sent a file: {url}]")
|
parts.append(f"[User sent a file: {url}]")
|
||||||
return "\n".join(parts)
|
return "\n".join(parts)
|
||||||
@ -7637,6 +7639,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||||||
# Declare at outer scope so the audio-file-paths handling block below
|
# Declare at outer scope so the audio-file-paths handling block below
|
||||||
# remains safe when ``event.media_urls`` is empty (no inner block runs).
|
# remains safe when ``event.media_urls`` is empty (no inner block runs).
|
||||||
audio_file_paths: list[str] = []
|
audio_file_paths: list[str] = []
|
||||||
|
video_paths: list[str] = []
|
||||||
|
|
||||||
if event.media_urls:
|
if event.media_urls:
|
||||||
image_paths = []
|
image_paths = []
|
||||||
@ -7654,6 +7657,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||||||
and event.message_type not in {MessageType.AUDIO, MessageType.DOCUMENT}
|
and event.message_type not in {MessageType.AUDIO, MessageType.DOCUMENT}
|
||||||
):
|
):
|
||||||
audio_paths.append(path)
|
audio_paths.append(path)
|
||||||
|
if mtype.startswith("video/") or event.message_type == MessageType.VIDEO:
|
||||||
|
video_paths.append(path)
|
||||||
|
|
||||||
if image_paths:
|
if image_paths:
|
||||||
# Decide routing: native (attach pixels) vs text (vision_analyze
|
# Decide routing: native (attach pixels) vs text (vision_analyze
|
||||||
@ -7752,6 +7757,25 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||||||
)
|
)
|
||||||
message_text = f"{_note}\n\n{message_text}"
|
message_text = f"{_note}\n\n{message_text}"
|
||||||
|
|
||||||
|
if video_paths:
|
||||||
|
from tools.credential_files import to_agent_visible_cache_path as _to_agent_path
|
||||||
|
for _vpath in video_paths:
|
||||||
|
_basename = os.path.basename(_vpath)
|
||||||
|
_parts = _basename.split("_", 2)
|
||||||
|
_display = _parts[2] if len(_parts) >= 3 else _basename
|
||||||
|
_display = re.sub(r'[^\w.\- ]', '_', _display)
|
||||||
|
_agent_path = _to_agent_path(_vpath)
|
||||||
|
_note = (
|
||||||
|
f"[The user sent a video attachment: '{_display}'. "
|
||||||
|
f"It is saved at: {_agent_path}. "
|
||||||
|
f"Its content is not inlined here. If the user's request involves "
|
||||||
|
f"what the video contains, inspect or process it yourself — for "
|
||||||
|
f"example by passing the path to a video analysis or media tool — "
|
||||||
|
f"instead of asking the user to describe it. Only ask what to do "
|
||||||
|
f"with it if their intent is genuinely unclear.]"
|
||||||
|
)
|
||||||
|
message_text = f"{_note}\n\n{message_text}"
|
||||||
|
|
||||||
if event.media_urls and event.message_type == MessageType.DOCUMENT:
|
if event.media_urls and event.message_type == MessageType.DOCUMENT:
|
||||||
import mimetypes as _mimetypes
|
import mimetypes as _mimetypes
|
||||||
from tools.credential_files import to_agent_visible_cache_path
|
from tools.credential_files import to_agent_visible_cache_path
|
||||||
|
|||||||
@ -20,6 +20,7 @@ from gateway.config import Platform, PlatformConfig
|
|||||||
from gateway.platforms.base import (
|
from gateway.platforms.base import (
|
||||||
MessageEvent,
|
MessageEvent,
|
||||||
MessageType,
|
MessageType,
|
||||||
|
SUPPORTED_VIDEO_TYPES,
|
||||||
is_host_excluded_by_no_proxy,
|
is_host_excluded_by_no_proxy,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -120,6 +121,9 @@ def _redirect_cache(tmp_path, monkeypatch):
|
|||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"gateway.platforms.base.DOCUMENT_CACHE_DIR", tmp_path / "doc_cache"
|
"gateway.platforms.base.DOCUMENT_CACHE_DIR", tmp_path / "doc_cache"
|
||||||
)
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"gateway.platforms.base.VIDEO_CACHE_DIR", tmp_path / "video_cache"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -1443,6 +1447,84 @@ class TestIncomingDocumentHandling:
|
|||||||
msg_event = adapter.handle_message.call_args[0][0]
|
msg_event = adapter.handle_message.call_args[0][0]
|
||||||
assert msg_event.message_type == MessageType.PHOTO
|
assert msg_event.message_type == MessageType.PHOTO
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_video_attachment_cached(self, adapter):
|
||||||
|
"""Video attachments should be downloaded into the video cache."""
|
||||||
|
video_bytes = b"\x00\x00\x00\x18ftypmp42fake-mp4"
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
adapter, "_download_slack_file_bytes", new_callable=AsyncMock
|
||||||
|
) as dl:
|
||||||
|
dl.return_value = video_bytes
|
||||||
|
event = self._make_event(
|
||||||
|
text="what happens in this?",
|
||||||
|
files=[
|
||||||
|
{
|
||||||
|
"mimetype": "video/mp4",
|
||||||
|
"name": "clip.mp4",
|
||||||
|
"url_private_download": "https://files.slack.com/clip.mp4",
|
||||||
|
"size": len(video_bytes),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
await adapter._handle_slack_message(event)
|
||||||
|
|
||||||
|
msg_event = adapter.handle_message.call_args[0][0]
|
||||||
|
assert msg_event.message_type == MessageType.VIDEO
|
||||||
|
assert len(msg_event.media_urls) == 1
|
||||||
|
assert os.path.exists(msg_event.media_urls[0])
|
||||||
|
assert msg_event.media_types == [SUPPORTED_VIDEO_TYPES[".mp4"]]
|
||||||
|
dl.assert_awaited_once_with("https://files.slack.com/clip.mp4", team_id="")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_file_shared_video_fallback_fetches_file_info(self, adapter):
|
||||||
|
"""file_shared-only video events should still reach the agent."""
|
||||||
|
video_bytes = b"\x00\x00\x00\x18ftypmp42fake-mp4"
|
||||||
|
adapter._app.client.files_info = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"ok": True,
|
||||||
|
"file": {
|
||||||
|
"id": "FVIDEO",
|
||||||
|
"mimetype": "video/mp4",
|
||||||
|
"name": "clip.mp4",
|
||||||
|
"url_private_download": "https://files.slack.com/clip.mp4",
|
||||||
|
"size": len(video_bytes),
|
||||||
|
"user": "U_USER",
|
||||||
|
"shares": {
|
||||||
|
"private": {
|
||||||
|
"D123": [
|
||||||
|
{"ts": "1234567890.000001"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
adapter, "_download_slack_file_bytes", new_callable=AsyncMock
|
||||||
|
) as dl,
|
||||||
|
patch("asyncio.sleep", new_callable=AsyncMock),
|
||||||
|
):
|
||||||
|
dl.return_value = video_bytes
|
||||||
|
await adapter._handle_slack_file_shared(
|
||||||
|
{
|
||||||
|
"type": "file_shared",
|
||||||
|
"channel_id": "D123",
|
||||||
|
"file_id": "FVIDEO",
|
||||||
|
"user_id": "U_USER",
|
||||||
|
"event_ts": "1234567890.000002",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
adapter._app.client.files_info.assert_awaited_once_with(file="FVIDEO")
|
||||||
|
msg_event = adapter.handle_message.call_args[0][0]
|
||||||
|
assert msg_event.message_type == MessageType.VIDEO
|
||||||
|
assert len(msg_event.media_urls) == 1
|
||||||
|
assert os.path.exists(msg_event.media_urls[0])
|
||||||
|
assert msg_event.media_types == [SUPPORTED_VIDEO_TYPES[".mp4"]]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_download_failure_is_surfaced_in_message_text(self, adapter):
|
async def test_download_failure_is_surfaced_in_message_text(self, adapter):
|
||||||
"""Attachment download failures (401/403/HTML-body/etc.) should be
|
"""Attachment download failures (401/403/HTML-body/etc.) should be
|
||||||
|
|||||||
50
tests/gateway/test_video_context_note.py
Normal file
50
tests/gateway/test_video_context_note.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
"""Tests for video attachment context notes in gateway turns."""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from gateway.config import GatewayConfig, Platform
|
||||||
|
from gateway.platforms.base import MessageEvent, MessageType
|
||||||
|
from gateway.session import SessionSource
|
||||||
|
|
||||||
|
|
||||||
|
def _make_runner() -> "GatewayRunner": # type: ignore[name-defined]
|
||||||
|
from gateway.run import GatewayRunner
|
||||||
|
|
||||||
|
runner = GatewayRunner.__new__(GatewayRunner)
|
||||||
|
runner.config = GatewayConfig()
|
||||||
|
runner.adapters = {}
|
||||||
|
runner._has_setup_skill = lambda: False
|
||||||
|
return runner
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_video_attachment_adds_path_note_without_document_wording():
|
||||||
|
from gateway.run import _build_media_placeholder
|
||||||
|
|
||||||
|
runner = _make_runner()
|
||||||
|
source = SessionSource(platform=Platform.SLACK, chat_id="D123", chat_type="dm")
|
||||||
|
event = MessageEvent(
|
||||||
|
text="what happens here?",
|
||||||
|
message_type=MessageType.VIDEO,
|
||||||
|
source=source,
|
||||||
|
media_urls=["/tmp/video_clip.mp4"],
|
||||||
|
media_types=["video/mp4"],
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"tools.credential_files.to_agent_visible_cache_path",
|
||||||
|
side_effect=lambda path: path,
|
||||||
|
):
|
||||||
|
result = await runner._prepare_inbound_message_text(
|
||||||
|
event=event,
|
||||||
|
source=source,
|
||||||
|
history=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "video attachment" in result
|
||||||
|
assert "/tmp/video_clip.mp4" in result
|
||||||
|
assert "video analysis or media tool" in result
|
||||||
|
assert "The user sent a document" not in result
|
||||||
|
assert _build_media_placeholder(event) == "[User sent a video: /tmp/video_clip.mp4]"
|
||||||
@ -378,12 +378,14 @@ class TestCacheDirectoryMounts:
|
|||||||
hermes_home.mkdir()
|
hermes_home.mkdir()
|
||||||
(hermes_home / "cache" / "documents").mkdir(parents=True)
|
(hermes_home / "cache" / "documents").mkdir(parents=True)
|
||||||
(hermes_home / "cache" / "audio").mkdir(parents=True)
|
(hermes_home / "cache" / "audio").mkdir(parents=True)
|
||||||
|
(hermes_home / "cache" / "videos").mkdir(parents=True)
|
||||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||||
|
|
||||||
mounts = get_cache_directory_mounts()
|
mounts = get_cache_directory_mounts()
|
||||||
paths = {m["container_path"] for m in mounts}
|
paths = {m["container_path"] for m in mounts}
|
||||||
assert "/root/.hermes/cache/documents" in paths
|
assert "/root/.hermes/cache/documents" in paths
|
||||||
assert "/root/.hermes/cache/audio" in paths
|
assert "/root/.hermes/cache/audio" in paths
|
||||||
|
assert "/root/.hermes/cache/videos" in paths
|
||||||
|
|
||||||
def test_skips_nonexistent_dirs(self, tmp_path, monkeypatch):
|
def test_skips_nonexistent_dirs(self, tmp_path, monkeypatch):
|
||||||
"""Dirs that don't exist on disk are not returned."""
|
"""Dirs that don't exist on disk are not returned."""
|
||||||
|
|||||||
@ -338,15 +338,16 @@ def iter_skills_files(
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Cache directory mounts (documents, images, audio, screenshots)
|
# Cache directory mounts (documents, images, audio, videos, screenshots)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# The four cache subdirectories that should be mirrored into remote backends.
|
# The cache subdirectories that should be mirrored into remote backends.
|
||||||
# Each tuple is (new_subpath, old_name) matching hermes_constants.get_hermes_dir().
|
# Each tuple is (new_subpath, old_name) matching hermes_constants.get_hermes_dir().
|
||||||
_CACHE_DIRS: list[tuple[str, str]] = [
|
_CACHE_DIRS: list[tuple[str, str]] = [
|
||||||
("cache/documents", "document_cache"),
|
("cache/documents", "document_cache"),
|
||||||
("cache/images", "image_cache"),
|
("cache/images", "image_cache"),
|
||||||
("cache/audio", "audio_cache"),
|
("cache/audio", "audio_cache"),
|
||||||
|
("cache/videos", "video_cache"),
|
||||||
("cache/screenshots", "browser_screenshots"),
|
("cache/screenshots", "browser_screenshots"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user