mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat(tools): add send_file tool for cross-platform file sharing
- Add new send_file tool to send audio, image, video, document files - Support Feishu, Telegram, Discord, Slack, WhatsApp, Signal, Email, etc. - Enhance Feishu adapter: auto-detect open_id vs chat_id, upload audio files - Support home channel fallback when no explicit chat_id provided - Add caption support for file messages Related tests pass (104 passed, 11 skipped)
This commit is contained in:
parent
64c38cc4d0
commit
27f0baaba5
2 changed files with 257 additions and 12 deletions
|
|
@ -13,6 +13,7 @@ import re
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
import ssl
|
import ssl
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
from agent.redact import redact_sensitive_text
|
from agent.redact import redact_sensitive_text
|
||||||
|
|
||||||
|
|
@ -131,6 +132,39 @@ SEND_MESSAGE_SCHEMA = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SEND_FILE_SCHEMA = {
|
||||||
|
"name": "send_file",
|
||||||
|
"description": (
|
||||||
|
"Send a file (audio, image, video, document) to a connected messaging platform.\n\n"
|
||||||
|
"Supports: Feishu, Telegram, Discord, WhatsApp, Signal, Email, and more.\n"
|
||||||
|
"For Feishu open_id (ou_xxx): automatically detected and routed correctly.\n"
|
||||||
|
"For audio files (.mp3, .wav, .ogg, etc.): sent as audio/voice on Feishu/Telegram.\n"
|
||||||
|
"For images: sent as photos. For other files: sent as document attachments."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"target": {
|
||||||
|
"type": "string",
|
||||||
|
"description": (
|
||||||
|
"Delivery target. Format: 'platform' (home channel), 'platform:chat_id', "
|
||||||
|
"or 'platform:chat_id:thread_id'. "
|
||||||
|
"Examples: 'feishu', 'feishu:ou_xxx', 'telegram:-1001234567890', 'discord:999888777'"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"file_path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Absolute path to the file to send."
|
||||||
|
},
|
||||||
|
"caption": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional caption / description to include with the file."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["target", "file_path"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def send_message_tool(args, **kw):
|
def send_message_tool(args, **kw):
|
||||||
"""Handle cross-channel send_message tool calls."""
|
"""Handle cross-channel send_message tool calls."""
|
||||||
|
|
@ -1388,15 +1422,22 @@ async def _send_bluebubbles(extra, chat_id, message):
|
||||||
return _error(f"BlueBubbles send failed: {e}")
|
return _error(f"BlueBubbles send failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=None):
|
async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=None, caption=None):
|
||||||
"""Send via Feishu/Lark using the adapter's send pipeline."""
|
"""Send via Feishu/Lark using the adapter's send pipeline.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: text content to send (used when no media_files)
|
||||||
|
caption: alias for message when called from send_file (for backward compat)
|
||||||
|
"""
|
||||||
|
# Normalize: if caption provided but no message, use caption as message
|
||||||
|
if not message and caption:
|
||||||
|
message = caption
|
||||||
try:
|
try:
|
||||||
from gateway.platforms.feishu import FeishuAdapter, FEISHU_AVAILABLE
|
from gateway.platforms.feishu import FeishuAdapter, FEISHU_AVAILABLE, FEISHU_DOMAIN, LARK_DOMAIN, _AUDIO_EXTENSIONS as _FEISHU_AUDIO_EXTS, _IMAGE_EXTENSIONS as _FEISHU_IMAGE_EXTS, _VIDEO_EXTENSIONS as _FEISHU_VIDEO_EXTS
|
||||||
if not FEISHU_AVAILABLE:
|
if not FEISHU_AVAILABLE:
|
||||||
return {"error": "Feishu dependencies not installed. Run: pip install 'hermes-agent[feishu]'"}
|
return {"error": "Feishu dependencies not installed. Run: pip install 'hermes-agent[feishu]'"}
|
||||||
from gateway.platforms.feishu import FEISHU_DOMAIN, LARK_DOMAIN
|
except ImportError as e:
|
||||||
except ImportError:
|
return {"error": f"Feishu dependencies not installed. Run: pip install 'hermes-agent[feishu]': {e}"}
|
||||||
return {"error": "Feishu dependencies not installed. Run: pip install 'hermes-agent[feishu]'"}
|
|
||||||
|
|
||||||
media_files = media_files or []
|
media_files = media_files or []
|
||||||
|
|
||||||
|
|
@ -1407,9 +1448,22 @@ async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=No
|
||||||
adapter._client = adapter._build_lark_client(domain)
|
adapter._client = adapter._build_lark_client(domain)
|
||||||
metadata = {"thread_id": thread_id} if thread_id else None
|
metadata = {"thread_id": thread_id} if thread_id else None
|
||||||
|
|
||||||
|
# Auto-detect Feishu ID type: ou_xxx → open_id, oc_xxx → chat_id
|
||||||
|
id_type = "open_id" if chat_id.startswith("ou_") else "chat_id"
|
||||||
|
|
||||||
last_result = None
|
last_result = None
|
||||||
if message.strip():
|
# Only send text if message is non-empty AND no files attached (file-only path sends caption via text message before file)
|
||||||
last_result = await adapter.send(chat_id, message, metadata=metadata)
|
if message.strip() and not media_files:
|
||||||
|
# Use auto-detected ID type for text messages too
|
||||||
|
text_body = adapter._build_create_message_body(
|
||||||
|
receive_id=chat_id,
|
||||||
|
msg_type="text",
|
||||||
|
content=json.dumps({"text": message.strip()}),
|
||||||
|
uuid_value=str(uuid.uuid4()),
|
||||||
|
)
|
||||||
|
text_req = FeishuAdapter._build_create_message_request(id_type, text_body)
|
||||||
|
msg_resp = await asyncio.to_thread(adapter._client.im.v1.message.create, text_req)
|
||||||
|
last_result = adapter._finalize_send_result(msg_resp, "Feishu text send failed")
|
||||||
if not last_result.success:
|
if not last_result.success:
|
||||||
return _error(f"Feishu send failed: {last_result.error}")
|
return _error(f"Feishu send failed: {last_result.error}")
|
||||||
|
|
||||||
|
|
@ -1418,6 +1472,7 @@ async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=No
|
||||||
return _error(f"Media file not found: {media_path}")
|
return _error(f"Media file not found: {media_path}")
|
||||||
|
|
||||||
ext = os.path.splitext(media_path)[1].lower()
|
ext = os.path.splitext(media_path)[1].lower()
|
||||||
|
display_name = os.path.basename(media_path)
|
||||||
if ext in _IMAGE_EXTS:
|
if ext in _IMAGE_EXTS:
|
||||||
last_result = await adapter.send_image_file(chat_id, media_path, metadata=metadata)
|
last_result = await adapter.send_image_file(chat_id, media_path, metadata=metadata)
|
||||||
elif ext in _VIDEO_EXTS:
|
elif ext in _VIDEO_EXTS:
|
||||||
|
|
@ -1425,9 +1480,83 @@ async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=No
|
||||||
elif ext in _VOICE_EXTS and is_voice:
|
elif ext in _VOICE_EXTS and is_voice:
|
||||||
last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata)
|
last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata)
|
||||||
elif ext in _AUDIO_EXTS:
|
elif ext in _AUDIO_EXTS:
|
||||||
last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata)
|
# Upload file first
|
||||||
|
upload_file_type, _ = adapter._resolve_outbound_file_routing(
|
||||||
|
file_path=display_name,
|
||||||
|
requested_message_type="file",
|
||||||
|
)
|
||||||
|
with open(media_path, "rb") as f:
|
||||||
|
body = adapter._build_file_upload_body(
|
||||||
|
file_type=upload_file_type,
|
||||||
|
file_name=display_name,
|
||||||
|
file=f,
|
||||||
|
)
|
||||||
|
request = adapter._build_file_upload_request(body)
|
||||||
|
upload_resp = await asyncio.to_thread(adapter._client.im.v1.file.create, request)
|
||||||
|
file_key = adapter._extract_response_field(upload_resp, "file_key")
|
||||||
|
if not file_key:
|
||||||
|
return _error(f"Feishu audio upload failed: {getattr(upload_resp, 'msg', 'unknown')}")
|
||||||
|
|
||||||
|
# Send caption text first (if any)
|
||||||
|
if caption and caption.strip():
|
||||||
|
text_body = adapter._build_create_message_body(
|
||||||
|
receive_id=chat_id,
|
||||||
|
msg_type="text",
|
||||||
|
content=json.dumps({"text": caption.strip()}),
|
||||||
|
uuid_value=str(uuid.uuid4()),
|
||||||
|
)
|
||||||
|
text_req = FeishuAdapter._build_create_message_request(id_type, text_body)
|
||||||
|
await asyncio.to_thread(adapter._client.im.v1.message.create, text_req)
|
||||||
|
|
||||||
|
# Send audio as file message
|
||||||
|
file_msg_body = adapter._build_create_message_body(
|
||||||
|
receive_id=chat_id,
|
||||||
|
msg_type="file",
|
||||||
|
content=json.dumps({"file_key": file_key}),
|
||||||
|
uuid_value=str(uuid.uuid4()),
|
||||||
|
)
|
||||||
|
file_msg_req = FeishuAdapter._build_create_message_request(id_type, file_msg_body)
|
||||||
|
msg_resp = await asyncio.to_thread(adapter._client.im.v1.message.create, file_msg_req)
|
||||||
|
last_result = adapter._finalize_send_result(msg_resp, "Feishu audio send failed")
|
||||||
else:
|
else:
|
||||||
last_result = await adapter.send_document(chat_id, media_path, metadata=metadata)
|
# Document / generic file: upload then send as file
|
||||||
|
upload_file_type, resolved_msg_type = adapter._resolve_outbound_file_routing(
|
||||||
|
file_path=display_name,
|
||||||
|
requested_message_type="file",
|
||||||
|
)
|
||||||
|
with open(media_path, "rb") as f:
|
||||||
|
body = adapter._build_file_upload_body(
|
||||||
|
file_type=upload_file_type,
|
||||||
|
file_name=display_name,
|
||||||
|
file=f,
|
||||||
|
)
|
||||||
|
request = adapter._build_file_upload_request(body)
|
||||||
|
upload_resp = await asyncio.to_thread(adapter._client.im.v1.file.create, request)
|
||||||
|
file_key = adapter._extract_response_field(upload_resp, "file_key")
|
||||||
|
if not file_key:
|
||||||
|
return _error(f"Feishu file upload failed: {getattr(upload_resp, 'msg', 'unknown')}")
|
||||||
|
|
||||||
|
# Send caption text first (if any)
|
||||||
|
if caption and caption.strip():
|
||||||
|
text_body = adapter._build_create_message_body(
|
||||||
|
receive_id=chat_id,
|
||||||
|
msg_type="text",
|
||||||
|
content=json.dumps({"text": caption.strip()}),
|
||||||
|
uuid_value=str(uuid.uuid4()),
|
||||||
|
)
|
||||||
|
text_req = FeishuAdapter._build_create_message_request(id_type, text_body)
|
||||||
|
await asyncio.to_thread(adapter._client.im.v1.message.create, text_req)
|
||||||
|
|
||||||
|
# Send file
|
||||||
|
file_msg_body = adapter._build_create_message_body(
|
||||||
|
receive_id=chat_id,
|
||||||
|
msg_type=resolved_msg_type,
|
||||||
|
content=json.dumps({"file_key": file_key}),
|
||||||
|
uuid_value=str(uuid.uuid4()),
|
||||||
|
)
|
||||||
|
file_msg_req = FeishuAdapter._build_create_message_request(id_type, file_msg_body)
|
||||||
|
msg_resp = await asyncio.to_thread(adapter._client.im.v1.message.create, file_msg_req)
|
||||||
|
last_result = adapter._finalize_send_result(msg_resp, "Feishu file send failed")
|
||||||
|
|
||||||
if not last_result.success:
|
if not last_result.success:
|
||||||
return _error(f"Feishu media send failed: {last_result.error}")
|
return _error(f"Feishu media send failed: {last_result.error}")
|
||||||
|
|
@ -1445,6 +1574,113 @@ async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=No
|
||||||
return _error(f"Feishu send failed: {e}")
|
return _error(f"Feishu send failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_file_to_platform(platform, pconfig, chat_id, file_path, message="", caption=None, thread_id=None):
|
||||||
|
"""Send a file to the appropriate platform."""
|
||||||
|
import os
|
||||||
|
from gateway.config import Platform
|
||||||
|
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
return {"error": f"File not found: {file_path}"}
|
||||||
|
|
||||||
|
ext = os.path.splitext(file_path)[1].lower()
|
||||||
|
metadata = {"thread_id": thread_id} if thread_id else None
|
||||||
|
|
||||||
|
if platform == Platform.FEISHU:
|
||||||
|
return await _send_feishu(pconfig, chat_id, message=caption, media_files=[(file_path, False)], thread_id=thread_id, caption=caption)
|
||||||
|
|
||||||
|
if platform == Platform.TELEGRAM:
|
||||||
|
return await _send_telegram(pconfig.token, chat_id, caption or "", media_files=[(file_path, False)], thread_id=thread_id)
|
||||||
|
|
||||||
|
if platform == Platform.DISCORD:
|
||||||
|
return await _send_discord(pconfig.token, chat_id, caption or "", media_files=[(file_path, False)], thread_id=thread_id)
|
||||||
|
|
||||||
|
if platform == Platform.SLACK:
|
||||||
|
return await _send_slack(pconfig.token, chat_id, caption or "", media_files=[(file_path, False)])
|
||||||
|
|
||||||
|
if platform == Platform.WHATSAPP:
|
||||||
|
return await _send_whatsapp(pconfig.extra, chat_id, caption or "", media_files=[(file_path, False)])
|
||||||
|
|
||||||
|
if platform == Platform.SIGNAL:
|
||||||
|
return await _send_signal(pconfig.extra, chat_id, caption or "", media_files=[(file_path, False)])
|
||||||
|
|
||||||
|
if platform == Platform.EMAIL:
|
||||||
|
return await _send_email(pconfig.extra, chat_id, caption or "", media_files=[(file_path, False)])
|
||||||
|
|
||||||
|
if platform == Platform.WECOM:
|
||||||
|
return await _send_wecom(pconfig.extra, chat_id, caption or "", media_files=[(file_path, False)])
|
||||||
|
|
||||||
|
if platform == Platform.MATTERMOST:
|
||||||
|
return await _send_mattermost(pconfig.token, pconfig.extra, chat_id, caption or "", media_files=[(file_path, False)])
|
||||||
|
|
||||||
|
if platform == Platform.MATRIX:
|
||||||
|
return await _send_matrix(pconfig.token, pconfig.extra, chat_id, caption or "", media_files=[(file_path, False)])
|
||||||
|
|
||||||
|
return {"error": f"File sending not yet implemented for {platform.value}"}
|
||||||
|
|
||||||
|
|
||||||
|
def send_file_tool(args, **kw):
|
||||||
|
"""Handle send_file tool calls."""
|
||||||
|
target = args.get("target", "")
|
||||||
|
file_path = args.get("file_path", "")
|
||||||
|
caption = args.get("caption", "")
|
||||||
|
|
||||||
|
if not target or not file_path:
|
||||||
|
return json.dumps({"error": "Both 'target' and 'file_path' are required"})
|
||||||
|
|
||||||
|
parts = target.split(":", 1)
|
||||||
|
platform_name = parts[0].strip().lower()
|
||||||
|
target_ref = parts[1].strip() if len(parts) > 1 else None
|
||||||
|
chat_id = None
|
||||||
|
thread_id = None
|
||||||
|
|
||||||
|
if target_ref:
|
||||||
|
chat_id, thread_id, is_explicit = _parse_target_ref(platform_name, target_ref)
|
||||||
|
else:
|
||||||
|
is_explicit = False
|
||||||
|
|
||||||
|
if target_ref and not is_explicit:
|
||||||
|
try:
|
||||||
|
from gateway.channel_directory import resolve_channel_name
|
||||||
|
resolved = resolve_channel_name(platform_name, target_ref)
|
||||||
|
if resolved:
|
||||||
|
chat_id, thread_id, _ = _parse_target_ref(platform_name, resolved)
|
||||||
|
else:
|
||||||
|
return json.dumps({
|
||||||
|
"error": f"Could not resolve '{target_ref}' on {platform_name}. "
|
||||||
|
f"Use send_message(action='list') to see available targets."
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": f"Failed to resolve target: {e}"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
from gateway.config import load_gateway_config, Platform as GPlatform
|
||||||
|
except ImportError:
|
||||||
|
return json.dumps({"error": "Messaging platforms not available"})
|
||||||
|
|
||||||
|
platform_map = {
|
||||||
|
"feishu": GPlatform.FEISHU,
|
||||||
|
}
|
||||||
|
platform = platform_map.get(platform_name)
|
||||||
|
|
||||||
|
config = load_gateway_config()
|
||||||
|
|
||||||
|
if not chat_id:
|
||||||
|
home = config.get_home_channel(platform)
|
||||||
|
if home:
|
||||||
|
chat_id = home.chat_id
|
||||||
|
else:
|
||||||
|
return json.dumps({"error": f"No home channel configured for {platform_name}"})
|
||||||
|
|
||||||
|
pconfig = config.platforms.get(platform)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = asyncio.run(_send_file_to_platform(platform, pconfig, chat_id, file_path, message="", caption=caption, thread_id=thread_id))
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
return json.dumps({"error": f"send_file failed: {e}\n{traceback.format_exc()}"})
|
||||||
|
return json.dumps(result)
|
||||||
|
|
||||||
|
|
||||||
def _check_send_message():
|
def _check_send_message():
|
||||||
"""Gate send_message on gateway running (always available on messaging platforms)."""
|
"""Gate send_message on gateway running (always available on messaging platforms)."""
|
||||||
from gateway.session_context import get_session_env
|
from gateway.session_context import get_session_env
|
||||||
|
|
@ -1521,3 +1757,12 @@ registry.register(
|
||||||
check_fn=_check_send_message,
|
check_fn=_check_send_message,
|
||||||
emoji="📨",
|
emoji="📨",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
registry.register(
|
||||||
|
name="send_file",
|
||||||
|
toolset="messaging",
|
||||||
|
schema=SEND_FILE_SCHEMA,
|
||||||
|
handler=send_file_tool,
|
||||||
|
check_fn=_check_send_message,
|
||||||
|
emoji="📎",
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ _HERMES_CORE_TOOLS = [
|
||||||
# Cronjob management
|
# Cronjob management
|
||||||
"cronjob",
|
"cronjob",
|
||||||
# Cross-platform messaging (gated on gateway running via check_fn)
|
# Cross-platform messaging (gated on gateway running via check_fn)
|
||||||
"send_message",
|
"send_message", "send_file",
|
||||||
# Home Assistant smart home control (gated on HASS_TOKEN via check_fn)
|
# Home Assistant smart home control (gated on HASS_TOKEN via check_fn)
|
||||||
"ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service",
|
"ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service",
|
||||||
]
|
]
|
||||||
|
|
@ -128,7 +128,7 @@ TOOLSETS = {
|
||||||
|
|
||||||
"messaging": {
|
"messaging": {
|
||||||
"description": "Cross-platform messaging: send messages to Telegram, Discord, Slack, SMS, etc.",
|
"description": "Cross-platform messaging: send messages to Telegram, Discord, Slack, SMS, etc.",
|
||||||
"tools": ["send_message"],
|
"tools": ["send_message", "send_file"],
|
||||||
"includes": []
|
"includes": []
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue