diff --git a/ai_oca_bridge_hermes/README.rst b/ai_oca_bridge_hermes/README.rst new file mode 100644 index 00000000..dfcfc87b --- /dev/null +++ b/ai_oca_bridge_hermes/README.rst @@ -0,0 +1,234 @@ +==================== +AI OCA Bridge Hermes +==================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:5b6924a473322c4910a7139f6477a49c030d7c8a8e19ab0ea2254364c9c0467c + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fai-lightgray.png?logo=github + :target: https://github.com/OCA/ai/tree/18.0/ai_oca_bridge_hermes + :alt: OCA/ai +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/ai-18-0/ai-18-0-ai_oca_bridge_hermes + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/ai&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module integrates Odoo Discuss with Hermes AI Agent, allowing users +to chat with AI assistants directly within Odoo's messaging interface. + +**Features:** + +- Queue-based message processing with state tracking + (pending/processing/done/error) +- Secure webhook authentication via tokens +- Auto-detection of DM chats with AI users +- Typing indicators while AI processes responses +- Rate limiting on poll endpoint +- Extensible architecture for custom handlers (escalation, support, + etc.) +- Standalone gateway script for easy deployment + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +Why This Module? +---------------- + +The AI revolution is changing how users interact with software. Odoo +users should be able to ask questions, get help, and automate tasks +using natural language - directly inside the interface they already use +daily. + +Architecture Rationale +---------------------- + +Queue-Based Design (Not Real-Time) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +We chose polling over WebSockets for maximum compatibility: + +- Works behind firewalls and proxies +- No WebSocket server needed +- Simple to debug and monitor +- Compatible with all Odoo deployments + +Standalone Gateway Script +~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``scripts/hermes_odoo_gateway.py`` script runs outside Odoo: + +- No need to install Hermes inside Odoo's Python environment +- AI processing happens in a separate process +- Can be restarted independently +- Supports multiple Odoo instances + +Extension Points +~~~~~~~~~~~~~~~~ + +The module is intentionally minimal. Extra features are added via child +modules: + +- ``ai_oca_bridge_hermes_support`` - Support ticket escalation for + instance +- Custom handlers via the gateway script's handler system + +Escalation Concept +~~~~~~~~~~~~~~~~~~ + +The module includes a minimal escalation hook (``_action_escalate``) so +the AI can signal when human intervention is needed. The base module +does not implement any specific escalation backend — child modules can +override this hook to create tickets, notify teams, or route to another +Hermes instance. + +Security Model +-------------- + +- Webhook tokens are auto-generated and stored securely +- AI users are regular Odoo users with limited permissions +- Gateway script runs with its own credentials +- No direct database access from the gateway + +Multi-Tenancy +------------- + +A single Hermes instance can serve multiple Odoo databases: + +- Each database has its own gateway configuration +- Session keys include database hash for isolation +- Messages are queued per-gateway + +MCP Compatibility +----------------- + +This module is compatible with the ``ai_oca_mcp`` module (Model Context +Protocol). If ``ai_oca_mcp`` is installed, tools defined via ``ai.tool`` +are automatically exposed through the MCP endpoint. The Hermes gateway +can then call Odoo tools either via our native API or through MCP — both +approaches work side by side. + +Future Directions +----------------- + +- Native Hermes gateway adapter (inside Hermes process) +- WebSocket support for real-time communication +- Voice/video integration +- Multi-modal AI (images, documents) + +Usage +===== + +Configuration +------------- + +1. Go to *AI Bridge > Hermes > Gateways* and create a new gateway +2. Select an AI User (must have an AI Bridge configured) +3. Copy the webhook token to your Hermes gateway script configuration +4. Optionally select specific channels to monitor (leave empty for all) + +Running the Gateway Script +-------------------------- + +.. code:: bash + + # Basic echo mode (for testing) + python hermes_odoo_gateway.py \ + --odoo-url http://localhost:8069 \ + --webhook-token YOUR_TOKEN \ + --poll-interval 5 + + # With AIAgent (full Hermes with tools) + python hermes_odoo_gateway.py \ + --odoo-url http://localhost:8069 \ + --webhook-token YOUR_TOKEN \ + --use-agent \ + --model k2p6 \ + --provider kimi-coding + +Usage +----- + +Once configured, simply send a message in a monitored channel or DM the +AI user. Hermes will poll for messages, process them with the AI, and +post responses back. + +Extending +--------- + +To add custom message handlers, create a child module and override: + +- ``hermes_webhook._action_escalate()`` for escalation logic +- ``hermes_message_queue`` models for custom state handling +- Gateway script handlers for custom AI processing + +Known issues / Roadmap +====================== + +- WebSocket support for real-time communication (currently uses polling) +- Native Hermes gateway adapter (currently uses standalone script) +- Multi-tenant routing for shared Hermes instances +- Support for file attachments and images +- Voice message integration +- Interactive approval buttons in Discuss messages (e.g., + "Approve/Reject" actions for sensitive operations) — nice to have for + escalation workflows + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Akretion + +Contributors +------------ + +- Raphaël Valyi raphael.valyi@akretion.com + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/ai `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/ai_oca_bridge_hermes/__init__.py b/ai_oca_bridge_hermes/__init__.py new file mode 100644 index 00000000..f7209b17 --- /dev/null +++ b/ai_oca_bridge_hermes/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/ai_oca_bridge_hermes/__manifest__.py b/ai_oca_bridge_hermes/__manifest__.py new file mode 100644 index 00000000..2317eb4e --- /dev/null +++ b/ai_oca_bridge_hermes/__manifest__.py @@ -0,0 +1,30 @@ +# Copyright 2026-TODAY Akretion - Raphael Valyi +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "AI OCA Bridge Hermes", + "summary": """ + Integrate Hermes AI Agent as a native chatbot inside Odoo Discuss. + Requires a running Hermes gateway with the Odoo platform adapter. + """, + "version": "18.0.1.0.0", + "license": "AGPL-3", + "author": "Akretion,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/ai", + "category": "AI", + "development_status": "Beta", + "depends": [ + "ai_oca_bridge_chatter", + ], + "data": [ + "security/ir.model.access.csv", + "security/security.xml", + "views/hermes_gateway_views.xml", + "views/discuss_channel_views.xml", + "views/menu.xml", + "data/ir_cron.xml", + ], + "demo": [], + "installable": True, + "application": False, +} diff --git a/ai_oca_bridge_hermes/controllers/__init__.py b/ai_oca_bridge_hermes/controllers/__init__.py new file mode 100644 index 00000000..ff442ca6 --- /dev/null +++ b/ai_oca_bridge_hermes/controllers/__init__.py @@ -0,0 +1,3 @@ +from . import hermes_poll +from . import hermes_tools +from . import hermes_webhook diff --git a/ai_oca_bridge_hermes/controllers/hermes_poll.py b/ai_oca_bridge_hermes/controllers/hermes_poll.py new file mode 100644 index 00000000..e09c99aa --- /dev/null +++ b/ai_oca_bridge_hermes/controllers/hermes_poll.py @@ -0,0 +1,109 @@ +# Copyright 2026-TODAY Akretion - Raphael Valyi +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import timedelta + +from odoo import fields, http +from odoo.http import request + + +class HermesPoll(http.Controller): + """Endpoint for Hermes gateway to poll pending messages.""" + + @http.route("/hermes/poll", type="json", auth="public", csrf=False) + def hermes_poll(self, **kwargs): + """Return pending messages for Hermes to process. + + Hermes gateway polls this endpoint periodically to fetch + messages queued by Odoo users. + """ + # Validate auth token from Authorization header + auth_header = request.httprequest.headers.get("Authorization", "") + token = auth_header.replace("Bearer ", "").strip() if auth_header else "" + + gateway = ( + request.env["hermes.gateway"] + .sudo() + .search([("webhook_token", "=", token), ("active", "=", True)], limit=1) + ) + if not gateway: + return {"messages": [], "error": "Invalid token"} + + # Rate limiting (1 second minimum between polls) + if ( + gateway.last_poll + and (fields.Datetime.now() - gateway.last_poll).total_seconds() < 1 + ): + return {"messages": [], "error": "Rate limited"} + + # Update last poll timestamp + gateway.sudo().write({"last_poll": fields.Datetime.now()}) + + # Reset messages stuck in processing for > 5 minutes + stuck_domain = [ + ("state", "=", "processing"), + ("gateway_id", "=", gateway.id), + ("write_date", "<", fields.Datetime.now() - timedelta(minutes=5)), + ] + stuck = request.env["hermes.message.queue"].sudo().search(stuck_domain) + if stuck: + stuck.sudo().write({"state": "pending"}) + + # Fetch pending messages + pending = ( + request.env["hermes.message.queue"] + .sudo() + .search( + [ + ("state", "=", "pending"), + ("gateway_id", "=", gateway.id), + ], + order="create_date asc, id asc", + limit=50, + ) + ) + + messages = [] + for msg in pending: + msg_data = { + "id": msg.id, + "channel_id": msg.channel_id.id, + "channel_name": msg.channel_id.name, + "body": msg.body, + "author_id": msg.author_id.id, + "author_name": msg.author_name, + "db_hash": msg.db_hash, + "create_date": msg.create_date.isoformat() if msg.create_date else None, + } + # Include history if requested + if kwargs.get("include_history"): + msg_data["history"] = self._get_channel_history(msg.channel_id) + messages.append(msg_data) + # Mark as processing so we don't send again on next poll + msg.sudo().write({"state": "processing"}) + + return {"messages": messages, "gateway_id": gateway.id} + + def _get_channel_history(self, channel, limit=10): + """Get recent message history for context.""" + messages = ( + request.env["mail.message"] + .sudo() + .search( + [ + ("res_id", "=", channel.id), + ("model", "=", "discuss.channel"), + ("message_type", "=", "comment"), + ], + order="date desc", + limit=limit, + ) + ) + return [ + { + "role": "assistant" if msg.author_id.user_ids.ai_bridge_id else "user", + "body": msg.body, + "date": msg.date.isoformat() if msg.date else None, + } + for msg in reversed(messages) + ] diff --git a/ai_oca_bridge_hermes/controllers/hermes_tools.py b/ai_oca_bridge_hermes/controllers/hermes_tools.py new file mode 100644 index 00000000..2729d8f1 --- /dev/null +++ b/ai_oca_bridge_hermes/controllers/hermes_tools.py @@ -0,0 +1,178 @@ +# Copyright 2026-TODAY Akretion - Raphael Valyi +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import http +from odoo.http import request + +_logger = logging.getLogger(__name__) + +# Soft dependency on ai_tool +try: + from odoo.addons.ai_tool.tools import aitool + + AI_TOOL_AVAILABLE = True +except ImportError: + aitool = None # noqa: F841 + AI_TOOL_AVAILABLE = False + + +class HermesToolsController(http.Controller): + """Endpoint for Hermes to execute Odoo tools. + + This controller provides a native API for Hermes to call Odoo tools. + If ai_tool is installed, tools are also available via MCP. + + Tools are discovered dynamically from: + 1. ai.tool records (when ai_tool is installed) + 2. hermes.tools.mock methods (always available for testing) + """ + + @http.route( + "/hermes/tools/execute", + type="json", + auth="public", + methods=["POST"], + csrf=False, + ) + def hermes_tools_execute(self, **kwargs): + """Execute an Odoo tool and return the result. + + Expected JSON body: + { + "tool": "hello_world", + "args": {"name": "Raphaël"} + } + """ + # Validate auth token from Authorization header + auth_header = request.httprequest.headers.get("Authorization", "") + token = auth_header.replace("Bearer ", "").strip() if auth_header else "" + + gateway = ( + request.env["hermes.gateway"] + .sudo() + .search([("webhook_token", "=", token), ("active", "=", True)], limit=1) + ) + if not gateway: + return {"status": "error", "message": "Invalid token"} + + data = request.get_json_data() + if not data: + return {"status": "error", "message": "No JSON data"} + + tool_name = data.get("tool") + args = data.get("args", {}) + + if not tool_name: + return {"status": "error", "message": "Missing 'tool' parameter"} + + # Get the AI user from the gateway for permission context + ai_user = gateway.ai_user_id + if not ai_user: + return {"status": "error", "message": "No AI user configured"} + + # Execute tool with the AI user's permissions + try: + result = self._execute_tool(request.env(user=ai_user.id), tool_name, args) + return {"status": "ok", "result": result} + except Exception as e: + _logger.warning("Hermes tool execution failed: %s - %s", tool_name, e) + return {"status": "error", "message": str(e)} + + @http.route( + "/hermes/tools/list", + type="json", + auth="public", + methods=["GET", "POST"], + csrf=False, + ) + def hermes_tools_list(self, **kwargs): + """List available tools with their schemas.""" + auth_header = request.httprequest.headers.get("Authorization", "") + token = auth_header.replace("Bearer ", "").strip() if auth_header else "" + + gateway = ( + request.env["hermes.gateway"] + .sudo() + .search([("webhook_token", "=", token), ("active", "=", True)], limit=1) + ) + if not gateway: + return {"tools": [], "error": "Invalid token"} + + tools = self._get_available_tools(request.env) + return {"tools": tools} + + def _get_available_tools(self, env): + """Return list of available tool definitions. + + Discovers tools from: + 1. ai.tool records (if ai_tool installed) + 2. hermes.tools.mock methods (always available) + """ + tools = [] + + # 1. ai.tool records (when ai_tool is installed) + if AI_TOOL_AVAILABLE and hasattr(env, "ai.tool"): + try: + ai_tools = env["ai.tool"].search([]) + for tool in ai_tools: + try: + definition = tool._get_tool_definition() + tools.append( + { + "name": definition.get("name", tool.name), + "description": tool.description or "", + "input_schema": definition.get("inputSchema", {}), + "output_schema": definition.get("outputSchema", {}), + "source": "ai.tool", + } + ) + except Exception as e: + _logger.debug("Skipping ai.tool %s: %s", tool.name, e) + except Exception as e: + _logger.debug("Could not load ai.tool records: %s", e) + + # 2. Mock tools (always available for testing) + mock_model = env.get("hermes.tools.mock") + if mock_model: + for method_name in dir(mock_model): + if not method_name.startswith("_ai_"): + continue + method = getattr(mock_model, method_name) + if not callable(method): + continue + + # Build simple schema from docstring/defaults + doc = (method.__doc__ or "").strip().split("\n")[0] + tools.append( + { + "name": method_name[4:], # Remove _ai_ prefix + "description": doc, + "input_schema": {"type": "object", "properties": {}}, + "output_schema": {"type": "object", "properties": {}}, + "source": "hermes.tools.mock", + } + ) + + return tools + + def _execute_tool(self, env, tool_name, args): + """Execute a tool by name with the given environment.""" + # Try ai.tool first (if ai_tool is installed) + if AI_TOOL_AVAILABLE: + try: + ai_tool = env["ai.tool"].search([("name", "=", tool_name)], limit=1) + if ai_tool: + return ai_tool._execute_tool(**args) + except Exception as e: + _logger.debug("ai.tool execution failed: %s", e) + + # Fallback to mock tools + method_name = f"_ai_{tool_name}" + mock_model = env.get("hermes.tools.mock") + if mock_model and hasattr(mock_model, method_name): + method = getattr(mock_model, method_name) + return method(**args) + + raise ValueError(f"Unknown tool: {tool_name}") diff --git a/ai_oca_bridge_hermes/controllers/hermes_webhook.py b/ai_oca_bridge_hermes/controllers/hermes_webhook.py new file mode 100644 index 00000000..a63ce7ec --- /dev/null +++ b/ai_oca_bridge_hermes/controllers/hermes_webhook.py @@ -0,0 +1,309 @@ +# Copyright 2026-TODAY Akretion - Raphael Valyi +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import html as html_module +import logging +import re + +from markupsafe import Markup + +from odoo import http +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class HermesWebhook(http.Controller): + """Endpoint for Hermes gateway to post responses back to Odoo.""" + + @http.route("/hermes/notify/", type="json", auth="public", csrf=False) + def hermes_notify(self, token, **kwargs): + """Send a web notification to an Odoo user. + + This endpoint uses the web_notify module (soft dependency) to display + toast notifications in the user's browser. If web_notify is not + installed, it falls back to posting a message in the user's DM channel. + + Expected JSON body: + { + "user_id": 2, + "message": "Production order blocked!", + "title": "Alert", + "type": "danger", # success, warning, info, danger, default + "sticky": true, + "action": { # optional: open a record on click + "type": "ir.actions.act_window", + "res_model": "mrp.production", + "res_id": 123, + "views": [[False, "form"]] + } + } + """ + # Validate token + gateway = ( + request.env["hermes.gateway"] + .sudo() + .search([("webhook_token", "=", token), ("active", "=", True)], limit=1) + ) + if not gateway: + return {"status": "error", "message": "Invalid token"}, 403 + + data = request.get_json_data() + if not data: + return {"status": "error", "message": "No JSON data"}, 400 + + user_id = data.get("user_id") + if not user_id: + return {"status": "error", "message": "Missing user_id"}, 400 + + user = request.env["res.users"].sudo().browse(int(user_id)) + if not user.exists(): + return {"status": "error", "message": "User not found"}, 404 + + # Build notification parameters + notif_message = data.get("message", "") + notif_title = data.get("title", "Hermes") + notif_type = data.get("type", "info") + sticky = data.get("sticky", False) + action = data.get("action") + + # Try web_notify first (soft dependency) + notify_method = getattr(user, f"notify_{notif_type}", None) + if notify_method: + try: + notify_method( + notif_message, + title=notif_title, + sticky=sticky, + action=action, + ) + return { + "status": "ok", + "method": "web_notify", + "user_id": user.id, + } + except Exception as e: + _logger.warning("web_notify failed, falling back to DM: %s", e) + + # Fallback: post to user's DM channel + return self._notify_fallback(gateway, user, notif_title, notif_message) + + def _notify_fallback(self, gateway, user, title, message): + """Fallback notification via DM when web_notify is not available.""" + try: + channel = ( + request.env["discuss.channel"] + .with_user(gateway.ai_user_id.id) + .sudo() + .channel_get(partner_ids=[user.partner_id.id]) + ) + posted = channel.message_post( + body=f"

{title}

{message}

", + message_type="comment", + subtype_xmlid="mail.mt_comment", + ) + return { + "status": "ok", + "method": "dm_fallback", + "message_id": posted.id, + "channel_id": channel.id, + } + except Exception as e: + _logger.error("Fallback notification failed: %s", e) + return {"status": "error", "message": str(e)}, 500 + + @http.route("/hermes/push/", type="json", auth="public", csrf=False) + def hermes_push(self, token, **kwargs): + """Push a proactive message from Hermes to a channel. + + Unlike the webhook endpoint (which is a response to a queued message), + this endpoint allows Hermes to initiate a conversation or send + notifications without a prior user message. + + Expected JSON body: + { + "channel_id": 42, + "body": "Alert: production order blocked!", + "action": "post_message" # or "typing" + } + """ + # Validate token + gateway = ( + request.env["hermes.gateway"] + .sudo() + .search([("webhook_token", "=", token), ("active", "=", True)], limit=1) + ) + if not gateway: + return {"status": "error", "message": "Invalid token"}, 403 + + data = request.get_json_data() + if not data: + return {"status": "error", "message": "No JSON data"}, 400 + + channel_id = data.get("channel_id") + if not channel_id: + return {"status": "error", "message": "Missing channel_id"}, 400 + + # Dispatch to action handler (same as webhook) + action = data.get("action", "post_message") + if action == "post_message": + return self._action_post_message(gateway, channel_id, data) + elif action == "typing": + return self._action_typing( + gateway, channel_id, data.get("is_typing", False) + ) + else: + return {"status": "error", "message": f"Unknown action: {action}"}, 400 + + @http.route( + "/hermes/webhook/", type="json", auth="public", csrf=False + ) + def hermes_webhook(self, token, **kwargs): + """Receive a response from Hermes and post it to the channel. + + Expected JSON body: + { + "channel_id": 42, + "body": "Hello from Hermes!", + "session_key": "odoo:abc123:channel_42", + "action": "post_message" # or "typing", "escalate" + } + """ + # Validate token + gateway = ( + request.env["hermes.gateway"] + .sudo() + .search([("webhook_token", "=", token), ("active", "=", True)], limit=1) + ) + if not gateway: + return {"status": "error", "message": "Invalid token"}, 403 + + data = request.get_json_data() + if not data: + return {"status": "error", "message": "No JSON data"}, 400 + + channel_id = data.get("channel_id") + if not channel_id: + return {"status": "error", "message": "Missing channel_id"}, 400 + + # Dispatch to action handler + action = data.get("action", "post_message") + if action == "post_message": + return self._action_post_message(gateway, channel_id, data) + elif action == "typing": + return self._action_typing( + gateway, channel_id, data.get("is_typing", False) + ) + elif action == "escalate": + return self._action_escalate(gateway, channel_id, data) + else: + return {"status": "error", "message": f"Unknown action: {action}"}, 400 + + def _action_post_message(self, gateway, channel_id, data): + """Post a message response to the channel.""" + body = data.get("body", "") + if not body: + return {"status": "error", "message": "Missing body"}, 400 + + channel = request.env["discuss.channel"].sudo().browse(channel_id) + if not channel.exists(): + return {"status": "error", "message": "Channel not found"}, 404 + + # Get the AI user from the gateway + ai_user = gateway.ai_user_id + if not ai_user: + return {"status": "error", "message": "No AI user configured"}, 500 + + # Convert plain text to HTML if needed + body_html = self._format_body(body) + + # Post the message as the AI user + try: + message = ( + channel.with_user(ai_user.id) + .with_context( + mail_create_nosubscribe=True, + mail_notrack=True, + ) + .message_post( + body=Markup(body_html), # Mark as safe HTML + message_type="comment", + subtype_xmlid="mail.mt_comment", + ) + ) + + # Mark any processing messages for this channel as done + try: + with request.env.cr.savepoint(): + queue = ( + request.env["hermes.message.queue"] + .sudo() + .search( + [ + ("channel_id", "=", channel.id), + ("state", "=", "processing"), + ] + ) + ) + if queue: + queue.write({"state": "done"}) + except Exception: + # Ignore concurrent update errors on queue cleanup + _logger.debug("Concurrent update ignored during queue cleanup") + + return { + "status": "ok", + "message_id": message.id, + } + + except Exception as e: + return {"status": "error", "message": str(e)}, 500 + + def _action_typing(self, gateway, channel_id, is_typing): + """Show/hide typing indicator.""" + channel = request.env["discuss.channel"].sudo().browse(channel_id) + if not channel.exists(): + return {"status": "error", "message": "Channel not found"}, 404 + for member in channel.channel_member_ids: + if member.partner_id == gateway.ai_user_id.partner_id: + member._notify_typing(is_typing=is_typing) + return {"status": "ok"} + + def _action_escalate(self, gateway, channel_id, data): + """Handle escalation to human support. + + EXTENSION POINT: Override in a child module to implement escalation + (e.g., create a helpdesk.ticket, notify a support team, etc.). + """ + return { + "status": "ok", + "message": "Escalation not implemented in base module. " + "Override _action_escalate() in a child module.", + } + + def _format_body(self, body): + """Format Hermes plain text response as Odoo HTML. + + Hermes returns plain text with markdown-like formatting. + We convert it to simple HTML for Odoo's Discuss. + """ + if not body: + return "

" + + # Strip HTML tags from input to get plain text + # (Odoo messages already come as HTML, we want the text content) + text = re.sub(r"<[^>]+>", "", body) + text = html_module.unescape(text) # Decode HTML entities + + if not text.strip(): + return "

" + + # Escape HTML entities + text = html_module.escape(text) + + # Convert URLs to links + url_pattern = re.compile(r"(https?://[^\s<>\"{}|\\^`\[\]]+)") + text = url_pattern.sub(r'\1', text) + + # Odoo Discuss wraps messages in , so we use that + return f"{text}" diff --git a/ai_oca_bridge_hermes/data/hermes_tools_data.xml b/ai_oca_bridge_hermes/data/hermes_tools_data.xml new file mode 100644 index 00000000..96836243 --- /dev/null +++ b/ai_oca_bridge_hermes/data/hermes_tools_data.xml @@ -0,0 +1,51 @@ + + + + + + + execute_odoo_query + Execute a safe Python query on Odoo data. Use env['model'].search(), browse(), read(), etc. Runs with the current user's permissions. + + _ai_execute_odoo_query + generic + + + + search_records + Search and read Odoo records with a domain filter. Returns matching records with specified fields. + + _ai_search_records + generic + + + + read_record + Read a single Odoo record by ID. Returns all readable fields or specified fields. + + _ai_read_record + generic + + + + post_discuss_message + Post a message to an Odoo Discuss channel. Useful for escalation and notifications. + + _ai_post_discuss_message + generic + + + diff --git a/ai_oca_bridge_hermes/data/ir_cron.xml b/ai_oca_bridge_hermes/data/ir_cron.xml new file mode 100644 index 00000000..bcb42196 --- /dev/null +++ b/ai_oca_bridge_hermes/data/ir_cron.xml @@ -0,0 +1,14 @@ + + + + + Hermes: Cleanup old message queue + + code + model._cleanup_old_messages() + 1 + days + + + diff --git a/ai_oca_bridge_hermes/models/__init__.py b/ai_oca_bridge_hermes/models/__init__.py new file mode 100644 index 00000000..14e9de7f --- /dev/null +++ b/ai_oca_bridge_hermes/models/__init__.py @@ -0,0 +1,6 @@ +from . import ai_bridge +from . import discuss_channel +from . import hermes_gateway +from . import hermes_message_queue +from . import hermes_tools_mock +from . import res_users diff --git a/ai_oca_bridge_hermes/models/ai_bridge.py b/ai_oca_bridge_hermes/models/ai_bridge.py new file mode 100644 index 00000000..b50a6cef --- /dev/null +++ b/ai_oca_bridge_hermes/models/ai_bridge.py @@ -0,0 +1,62 @@ +# Copyright 2026-TODAY Akretion - Raphael Valyi +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class AiBridge(models.Model): + _inherit = "ai.bridge" + + usage = fields.Selection( + selection_add=[("hermes", "Hermes AI")], + ondelete={"hermes": "set default"}, + ) + payload_type = fields.Selection( + selection_add=[("hermes", "Hermes Chatter")], + ondelete={"hermes": "set default"}, + ) + hermes_gateway_id = fields.Many2one( + "hermes.gateway", + string="Hermes Gateway", + help="Link this bridge to a specific Hermes gateway configuration.", + ) + + def _prepare_payload_hermes(self, record=None, **kwargs): + """Prepare payload for Hermes gateway with conversation history.""" + payload = self._prepare_payload_chatter(record=record, **kwargs) + + if record and record._name == "mail.message": + channel = self.env["discuss.channel"].browse(record.res_id) + history = self._get_channel_history(channel, limit=20) + payload["history"] = history + payload["session_key"] = self._get_session_key(channel, payload) + + return payload + + def _get_channel_history(self, channel, limit=20): + """Fetch recent conversation history for context.""" + messages = self.env["mail.message"].search( + [ + ("res_id", "=", channel.id), + ("model", "=", "discuss.channel"), + ("message_type", "=", "comment"), + ], + order="date asc", + limit=limit, + ) + + return [ + { + "role": "assistant" if msg.author_id.user_ids.ai_bridge_id else "user", + "body": msg.body, + "date": msg.date.isoformat() if msg.date else None, + "author_name": msg.author_id.name, + } + for msg in messages + ] + + def _get_session_key(self, channel, payload): + """Generate a unique session key for Hermes.""" + odoo_info = payload.get("_odoo", {}) + db_hash = odoo_info.get("db_hash", "unknown") + return f"odoo:{db_hash}:channel_{channel.id}" diff --git a/ai_oca_bridge_hermes/models/discuss_channel.py b/ai_oca_bridge_hermes/models/discuss_channel.py new file mode 100644 index 00000000..7a42c410 --- /dev/null +++ b/ai_oca_bridge_hermes/models/discuss_channel.py @@ -0,0 +1,135 @@ +# Copyright 2026-TODAY Akretion - Raphael Valyi +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import hashlib + +from odoo import fields, models + + +class DiscussChannel(models.Model): + _inherit = "discuss.channel" + + hermes_gateway_id = fields.Many2one( + "hermes.gateway", + string="Hermes Gateway", + help="If set, messages in this channel will be forwarded to Hermes.", + ) + hermes_auto_respond = fields.Boolean( + default=True, + help="If checked, Hermes will automatically respond " + "to messages in this channel.", + ) + + def _get_hermes_gateway(self): + """Find the Hermes gateway for this channel. + + For channels with explicit gateway set, return it. + For DM chats with Hermes AI user, find the first active gateway. + """ + self.ensure_one() + if self.hermes_gateway_id: + return self.hermes_gateway_id + + # For DM chats, check if one of the members is an AI user + if self.channel_type == "chat": + for member in self.channel_member_ids: + if member.partner_id.user_ids.ai_bridge_id: + # Find a gateway using this AI user + gateway = self.env["hermes.gateway"].search( + [ + ("ai_user_id", "=", member.partner_id.user_ids.id), + ("active", "=", True), + ], + limit=1, + ) + if gateway: + return gateway + return False + + def _is_ai_mentioned(self, message, gateway): + """Check if the AI user is explicitly mentioned in the message. + + Returns True if the message partner_ids include the AI user's partner. + This handles @mentions in Discuss channels. + """ + if not message.partner_ids: + return False + return gateway.ai_user_id.partner_id in message.partner_ids + + def _should_queue_for_hermes(self, message, gateway): + """Determine whether this message should be queued for Hermes. + + Rules: + - DM (chat): always respond (1-to-1 conversation) + - Group/channel with explicit mention (@Hermes): respond + - Group/channel without mention: only respond if auto-respond + is enabled AND the channel is explicitly monitored + """ + # DMs always get a response + if self.channel_type == "chat": + return True + + # If explicitly mentioned, always respond + if self._is_ai_mentioned(message, gateway): + return True + + # In group channels without mention, only respond if: + # - auto-respond is enabled + # - the channel is explicitly in the gateway's monitored channels + if not self.hermes_auto_respond: + return False + + # If gateway monitors specific channels, this one must be listed + if gateway.channel_ids and self not in gateway.channel_ids: + return False + + return True + + def message_post(self, **kwargs): + """Override to queue messages for Hermes when a gateway is configured.""" + message = super().message_post(**kwargs) + + # Skip if message is from an AI user (prevent loops) + if message.author_id.user_ids.ai_bridge_id: + return message + + # Check if this channel has a Hermes gateway (direct or via DM) + gateway = self._get_hermes_gateway() + if not gateway: + return message + + # Validate gateway is active and has an AI user + if not gateway.active or not gateway.ai_user_id: + return message + + # Decide whether to queue this message + if not self._should_queue_for_hermes(message, gateway): + return message + + # Queue the message for Hermes + self.env["hermes.message.queue"].sudo().create( + { + "channel_id": self.id, + "body": message.body, + "author_id": message.author_id.id, + "gateway_id": gateway.id, + "db_hash": self._get_db_hash(), + } + ) + + # Trigger typing indicator on the AI user + for member in self.channel_member_ids: + if member.partner_id == gateway.ai_user_id.partner_id: + member._notify_typing(is_typing=True) + + return message + + def _get_db_hash(self): + """Compute a hash identifying this database for Hermes session keys.""" + IrConfig = self.env["ir.config_parameter"].sudo() + dbuuid = IrConfig.get_param("database.uuid") + db_create_date = IrConfig.get_param("database.create_date") + dbname = self.env.cr.dbname + return hashlib.sha256( + f"{dbuuid}:{db_create_date}:{dbname}".encode() + ).hexdigest()[:16] diff --git a/ai_oca_bridge_hermes/models/hermes_gateway.py b/ai_oca_bridge_hermes/models/hermes_gateway.py new file mode 100644 index 00000000..6fd17f57 --- /dev/null +++ b/ai_oca_bridge_hermes/models/hermes_gateway.py @@ -0,0 +1,110 @@ +# Copyright 2026-TODAY Akretion - Raphael Valyi +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import secrets + +from odoo import _, api, fields, models + + +class HermesGateway(models.Model): + _name = "hermes.gateway" + _description = "Hermes Gateway Configuration" + _inherit = ["mail.thread"] + + name = fields.Char(required=True, translate=True) + active = fields.Boolean(default=True) + company_id = fields.Many2one("res.company", default=lambda self: self.env.company) + odoo_url = fields.Char( + string="Odoo Base URL", + default=lambda self: ( + self.env["ir.config_parameter"].sudo().get_param("web.base.url") + ), + help="The base URL of this Odoo instance that Hermes will connect to.", + ) + webhook_token = fields.Char( + default=lambda self: secrets.token_hex(32), + groups="base.group_system", + help="Secret token shared with the Hermes gateway for authentication.", + readonly=True, + copy=False, + ) + hermes_url = fields.Char( + string="Hermes URL", + help="URL of the Hermes gateway (optional, for reference).", + ) + channel_ids = fields.Many2many( + "discuss.channel", + string="Monitored Channels", + help="Channels where Hermes will respond. Leave empty to monitor all channels.", + ) + ai_user_id = fields.Many2one( + "res.users", + string="AI User", + required=True, + domain=[("ai_bridge_id", "!=", False)], + help="The Odoo user that Hermes will post responses as.", + ) + state = fields.Selection( + [("draft", "Draft"), ("connected", "Connected"), ("error", "Error")], + default="draft", + ) + last_poll = fields.Datetime(readonly=True) + message_count = fields.Integer(compute="_compute_message_count") + mode = fields.Selection( + [ + ("polling", "Polling (pull)"), + ("webhook", "Webhook (push)"), + ], + default="polling", + required=True, + help="How Hermes connects to Odoo. " + "Polling = Hermes polls Odoo. " + "Webhook = Odoo pushes to Hermes.", + ) + gateway_version = fields.Char( + help="Version of the Hermes gateway script (for compatibility checks).", + ) + last_error = fields.Text(readonly=True) + last_error_date = fields.Datetime(readonly=True) + + @api.depends("message_count") + def _compute_message_count(self): + for gateway in self: + gateway.message_count = self.env["hermes.message.queue"].search_count( + [("gateway_id", "=", gateway.id)] + ) + + def action_regenerate_token(self): + """Generate a new webhook token (invalidates old one).""" + for gateway in self: + gateway.webhook_token = secrets.token_hex(32) + + def action_test_connection(self): + """Test connectivity by polling the gateway health endpoint.""" + self.ensure_one() + if not self.hermes_url: + return self._notify(_("No Hermes URL configured"), "warning") + try: + import requests + + resp = requests.get( + f"{self.hermes_url}/health", + timeout=5, + headers={"Authorization": f"Bearer {self.webhook_token}"}, + ) + if resp.status_code == 200: + self.state = "connected" + return self._notify(_("Connection successful!"), "success") + else: + self.state = "error" + return self._notify(f"HTTP {resp.status_code}", "error") + except Exception as e: + self.state = "error" + return self._notify(str(e), "error") + + def _notify(self, message, type_="info"): + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": {"title": "Hermes Gateway", "message": message, "type": type_}, + } diff --git a/ai_oca_bridge_hermes/models/hermes_message_queue.py b/ai_oca_bridge_hermes/models/hermes_message_queue.py new file mode 100644 index 00000000..6bb11626 --- /dev/null +++ b/ai_oca_bridge_hermes/models/hermes_message_queue.py @@ -0,0 +1,65 @@ +# Copyright 2026-TODAY Akretion - Raphael Valyi +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import timedelta + +from odoo import fields, models + + +class HermesMessageQueue(models.Model): + _name = "hermes.message.queue" + _description = "Messages queued for Hermes" + _order = "create_date asc, id asc" + + channel_id = fields.Many2one("discuss.channel", required=True, index=True) + body = fields.Text(required=True) + author_id = fields.Many2one("res.partner", string="Author") + author_name = fields.Char(related="author_id.name", store=True) + state = fields.Selection( + [ + ("pending", "Pending"), + ("processing", "Processing"), + ("done", "Done"), + ("error", "Error"), + ], + default="pending", + index=True, + ) + gateway_id = fields.Many2one("hermes.gateway", required=True, index=True) + db_hash = fields.Char(help="Database hash for Hermes session key.") + error_message = fields.Text() + message_type = fields.Selection( + [ + ("user", "User Message"), + ("system", "System Event"), + ("command", "Command"), + ], + default="user", + ) + escalation_id = fields.Many2one( + "mail.message", + string="Escalation Message", + help="Reference to the escalation message or ticket. Used by child modules.", + ) + is_escalated = fields.Boolean( + default=False, + help="Checked when a human has taken over the conversation.", + ) + + def action_retry(self): + """Reset failed messages to pending for reprocessing.""" + self.filtered(lambda m: m.state == "error").write( + {"state": "pending", "error_message": False} + ) + + def _cleanup_old_messages(self): + """Cron job: delete messages older than 7 days in done/error state.""" + cutoff = fields.Datetime.now() - timedelta(days=7) + old_messages = self.search( + [ + ("create_date", "<", cutoff), + ("state", "in", ["done", "error"]), + ] + ) + if old_messages: + old_messages.unlink() diff --git a/ai_oca_bridge_hermes/models/hermes_tools_mock.py b/ai_oca_bridge_hermes/models/hermes_tools_mock.py new file mode 100644 index 00000000..cbe09918 --- /dev/null +++ b/ai_oca_bridge_hermes/models/hermes_tools_mock.py @@ -0,0 +1,30 @@ +# Copyright 2026-TODAY Akretion - Raphael Valyi +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +"""Mock tools for testing the Hermes tools endpoint without ai_tool installed. + +This module provides a minimal hello_world tool so the /hermes/tools/* endpoints +can be tested even when no real tools are configured. In production, real tools +are either: +- Registered as ai.tool records (when ai_tool is installed) +- Provided by a companion module (e.g., ai_tool_odoo_shell) +""" + +from odoo import models + + +class HermesToolsMock(models.AbstractModel): + _name = "hermes.tools.mock" + _description = "Hermes Mock Tools (for testing)" + + def _ai_hello_world(self, name="World", **kwargs): + """A simple hello world tool for testing. + + Returns a greeting message. Useful for verifying that the + /hermes/tools/execute endpoint is working. + """ + return {"message": f"Hello, {name}!", "tool": "hello_world"} + + def _ai_get_odoo_version(self, **kwargs): + """Return the current Odoo version.""" + return {"version": self.env["ir.module.module"].sudo().get_current_version()} diff --git a/ai_oca_bridge_hermes/models/res_users.py b/ai_oca_bridge_hermes/models/res_users.py new file mode 100644 index 00000000..95fec4a6 --- /dev/null +++ b/ai_oca_bridge_hermes/models/res_users.py @@ -0,0 +1,14 @@ +# Copyright 2026-TODAY Akretion - Raphael Valyi +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResUsers(models.Model): + _inherit = "res.users" + + hermes_gateway_ids = fields.Many2many( + "hermes.gateway", + string="Hermes Gateways", + help="Hermes gateways this user is linked to.", + ) diff --git a/ai_oca_bridge_hermes/pyproject.toml b/ai_oca_bridge_hermes/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/ai_oca_bridge_hermes/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/ai_oca_bridge_hermes/readme/CONTEXT.md b/ai_oca_bridge_hermes/readme/CONTEXT.md new file mode 100644 index 00000000..4d2e6754 --- /dev/null +++ b/ai_oca_bridge_hermes/readme/CONTEXT.md @@ -0,0 +1,52 @@ +## Why This Module? + +The AI revolution is changing how users interact with software. Odoo users should be able to ask questions, get help, and automate tasks using natural language - directly inside the interface they already use daily. + +## Architecture Rationale + +### Queue-Based Design (Not Real-Time) +We chose polling over WebSockets for maximum compatibility: +- Works behind firewalls and proxies +- No WebSocket server needed +- Simple to debug and monitor +- Compatible with all Odoo deployments + +### Standalone Gateway Script +The `scripts/hermes_odoo_gateway.py` script runs outside Odoo: +- No need to install Hermes inside Odoo's Python environment +- AI processing happens in a separate process +- Can be restarted independently +- Supports multiple Odoo instances + +### Extension Points +The module is intentionally minimal. Extra features are added via child modules: +- `ai_oca_bridge_hermes_support` - Support ticket escalation for instance +- Custom handlers via the gateway script's handler system + +### Escalation Concept +The module includes a minimal escalation hook (`_action_escalate`) so the AI can signal when human intervention is needed. The base module does not implement any specific escalation backend — child modules can override this hook to create tickets, notify teams, or route to another Hermes instance. + +## Security Model + +- Webhook tokens are auto-generated and stored securely +- AI users are regular Odoo users with limited permissions +- Gateway script runs with its own credentials +- No direct database access from the gateway + +## Multi-Tenancy + +A single Hermes instance can serve multiple Odoo databases: +- Each database has its own gateway configuration +- Session keys include database hash for isolation +- Messages are queued per-gateway + +## MCP Compatibility + +This module is compatible with the `ai_oca_mcp` module (Model Context Protocol). If `ai_oca_mcp` is installed, tools defined via `ai.tool` are automatically exposed through the MCP endpoint. The Hermes gateway can then call Odoo tools either via our native API or through MCP — both approaches work side by side. + +## Future Directions + +- Native Hermes gateway adapter (inside Hermes process) +- WebSocket support for real-time communication +- Voice/video integration +- Multi-modal AI (images, documents) diff --git a/ai_oca_bridge_hermes/readme/CONTRIBUTORS.md b/ai_oca_bridge_hermes/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..86acb69b --- /dev/null +++ b/ai_oca_bridge_hermes/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +* Raphaël Valyi diff --git a/ai_oca_bridge_hermes/readme/DESCRIPTION.md b/ai_oca_bridge_hermes/readme/DESCRIPTION.md new file mode 100644 index 00000000..0ee7315e --- /dev/null +++ b/ai_oca_bridge_hermes/readme/DESCRIPTION.md @@ -0,0 +1,12 @@ +This module integrates Odoo Discuss with Hermes AI Agent, allowing users to chat +with AI assistants directly within Odoo's messaging interface. + +**Features:** + +- Queue-based message processing with state tracking (pending/processing/done/error) +- Secure webhook authentication via tokens +- Auto-detection of DM chats with AI users +- Typing indicators while AI processes responses +- Rate limiting on poll endpoint +- Extensible architecture for custom handlers (escalation, support, etc.) +- Standalone gateway script for easy deployment diff --git a/ai_oca_bridge_hermes/readme/ROADMAP.md b/ai_oca_bridge_hermes/readme/ROADMAP.md new file mode 100644 index 00000000..2a8ea8ec --- /dev/null +++ b/ai_oca_bridge_hermes/readme/ROADMAP.md @@ -0,0 +1,6 @@ +- WebSocket support for real-time communication (currently uses polling) +- Native Hermes gateway adapter (currently uses standalone script) +- Multi-tenant routing for shared Hermes instances +- Support for file attachments and images +- Voice message integration +- Interactive approval buttons in Discuss messages (e.g., "Approve/Reject" actions for sensitive operations) — nice to have for escalation workflows diff --git a/ai_oca_bridge_hermes/readme/USAGE.md b/ai_oca_bridge_hermes/readme/USAGE.md new file mode 100644 index 00000000..a0f22d07 --- /dev/null +++ b/ai_oca_bridge_hermes/readme/USAGE.md @@ -0,0 +1,36 @@ +## Configuration + +1. Go to *AI Bridge > Hermes > Gateways* and create a new gateway +2. Select an AI User (must have an AI Bridge configured) +3. Copy the webhook token to your Hermes gateway script configuration +4. Optionally select specific channels to monitor (leave empty for all) + +## Running the Gateway Script + +```bash +# Basic echo mode (for testing) +python hermes_odoo_gateway.py \ + --odoo-url http://localhost:8069 \ + --webhook-token YOUR_TOKEN \ + --poll-interval 5 + +# With AIAgent (full Hermes with tools) +python hermes_odoo_gateway.py \ + --odoo-url http://localhost:8069 \ + --webhook-token YOUR_TOKEN \ + --use-agent \ + --model k2p6 \ + --provider kimi-coding +``` + +## Usage + +Once configured, simply send a message in a monitored channel or DM the AI user. +Hermes will poll for messages, process them with the AI, and post responses back. + +## Extending + +To add custom message handlers, create a child module and override: +- `hermes_webhook._action_escalate()` for escalation logic +- `hermes_message_queue` models for custom state handling +- Gateway script handlers for custom AI processing diff --git a/ai_oca_bridge_hermes/scripts/hermes_odoo_gateway.py b/ai_oca_bridge_hermes/scripts/hermes_odoo_gateway.py new file mode 100755 index 00000000..8811acdd --- /dev/null +++ b/ai_oca_bridge_hermes/scripts/hermes_odoo_gateway.py @@ -0,0 +1,394 @@ +#!/usr/bin/env python3 +""" +Hermes Odoo Gateway - Standalone client with AIAgent integration. + +This script connects a running Hermes AIAgent to an Odoo instance +with the ai_oca_bridge_hermes module installed. + +Usage: + # Standalone mode (for testing) + python hermes_odoo_gateway.py \ + --odoo-url http://localhost:8069 \ + --webhook-token YOUR_TOKEN \ + --poll-interval 5 + + # With AIAgent (full Hermes with tools) + python hermes_odoo_gateway.py \ + --odoo-url http://localhost:8069 \ + --webhook-token YOUR_TOKEN \ + --use-agent \ + --model kimi-k2.6 \ + --provider kimi-coding + +Author: Akretion (2026) +License: AGPL-3.0 +""" + +import argparse +import asyncio +import html +import logging +import os +import re +import sys + +import httpx + +# Setup logging FIRST (before any logger usage) +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) +logger = logging.getLogger("hermes_odoo") + +# Load Hermes .env before anything else +HERMES_DOTENV = os.path.expanduser("~/.hermes/.env") +if os.path.exists(HERMES_DOTENV): + import dotenv + + # Load .env but don't override existing env vars (user's shell takes precedence) + dotenv.load_dotenv(HERMES_DOTENV, override=False) + logger.debug(f"Loaded env from {HERMES_DOTENV}") + +# Add Hermes agent to path +HERMES_AGENT_DIR = os.path.expanduser("~/.hermes/hermes-agent") +if HERMES_AGENT_DIR not in sys.path: + sys.path.insert(0, HERMES_AGENT_DIR) + + +def get_ai_agent(session_key: str, **kwargs): + """Create an AIAgent instance for a session. + + This imports Hermes AIAgent and creates a configured instance. + """ + try: + from run_agent import AIAgent + except ImportError as e: + logger.error(f"Cannot import AIAgent: {e}") + logger.error(f"Make sure {HERMES_AGENT_DIR} is in your PYTHONPATH") + raise + + # Fix Kimi base URL: add /v1 suffix for OpenAI SDK compatibility + # (Hermes CLI uses Anthropic SDK which handles this differently) + base_url = os.environ.get("KIMI_BASE_URL", "") + if base_url and base_url.rstrip("/").endswith("/coding"): + os.environ["KIMI_BASE_URL"] = base_url.rstrip("/") + "/v1" + logger.debug( + f"Fixed KIMI_BASE_URL for OpenAI SDK: {os.environ['KIMI_BASE_URL']}" + ) + + # Build agent with sensible defaults for Odoo integration + logger.info( + "Creating AIAgent with session_id=%s, model=%s, provider=%s", + session_key, + kwargs.get("model", ""), + kwargs.get("provider", None), + ) + agent = AIAgent( + session_id=session_key, + platform="odoo", + # Inherit model/provider from Hermes config if not specified + model=kwargs.get("model", ""), + provider=kwargs.get("provider", None), + # Limit iterations for safety + max_iterations=kwargs.get("max_iterations", 30), + # Enable useful toolsets, disable dangerous ones + enabled_toolsets=kwargs.get("enabled_toolsets", None), + disabled_toolsets=kwargs.get("disabled_toolsets", None) or ["computer_use"], + # Don't be too verbose + quiet_mode=True, + # Skip loading context files to speed up + skip_context_files=True, + # Load memory for cross-session continuity + skip_memory=False, + ) + logger.info("AIAgent created successfully") + return agent + + +class OdooGatewayClient: + """Client that polls Odoo for messages and forwards responses.""" + + def __init__( + self, + odoo_url: str, + webhook_token: str, + poll_interval: int = 5, + timeout: int = 30, + use_agent: bool = False, + agent_kwargs: dict = None, + ): + self.odoo_url = odoo_url.rstrip("/") + self.webhook_token = webhook_token + self.poll_interval = poll_interval + self.timeout = timeout + self.running = False + self._client: httpx.AsyncClient | None = None + self._use_agent = use_agent + self._agent_kwargs = agent_kwargs or {} + # Cache AIAgent instances per session + self._agents: dict = {} + + async def __aenter__(self): + self._client = httpx.AsyncClient(timeout=self.timeout) + return self + + async def __aexit__(self, *args): + if self._client: + await self._client.aclose() + + async def start(self): + """Main polling loop.""" + self.running = True + logger.info(f"Starting Odoo gateway client for {self.odoo_url}") + logger.info(f"Polling every {self.poll_interval} seconds") + if self._use_agent: + logger.info( + "AIAgent mode enabled - Hermes will process messages with tools" + ) + else: + logger.info( + "Echo mode - messages will be echoed back (use --use-agent for AI)" + ) + + while self.running: + try: + messages = await self._poll_messages() + for msg in messages: + await self._process_message(msg) + await asyncio.sleep(self.poll_interval) + except Exception as e: + logger.error(f"Poll error: {e}") + await asyncio.sleep(self.poll_interval * 2) + + async def stop(self): + """Stop the polling loop.""" + self.running = False + logger.info("Stopping Odoo gateway client") + + async def _poll_messages(self) -> list: + """Fetch pending messages from Odoo.""" + url = f"{self.odoo_url}/hermes/poll" + headers = {"Authorization": f"Bearer {self.webhook_token}"} + + try: + resp = await self._client.post(url, headers=headers, json={}) + resp.raise_for_status() + data = resp.json() + # Handle Odoo JSON-RPC response structure + if isinstance(data, dict): + if "error" in data: + logger.warning(f"Odoo poll error: {data['error']}") + return [] + # JSON-RPC wraps result in "result" key + result = data.get("result", {}) + if isinstance(result, dict): + return result.get("messages", []) + return result if isinstance(result, list) else [] + return [] + except httpx.HTTPStatusError as e: + logger.error(f"HTTP error polling Odoo: {e.response.status_code}") + return [] + except Exception as e: + logger.error(f"Error polling Odoo: {e}") + return [] + + async def _process_message(self, msg: dict): + """Process a single message from Odoo.""" + channel_id = msg.get("channel_id") + body = msg.get("body", "") + author_name = msg.get("author_name", "User") + db_hash = msg.get("db_hash", "unknown") + + # Build session key + session_key = f"odoo:{db_hash}:channel_{channel_id}" + + # Strip HTML from body + text = self._strip_html(body) + if not text: + return + + logger.info(f"Processing message from {author_name} in channel {channel_id}") + + # Get or create AIAgent for this session + if self._use_agent: + response = await self._get_agent_response(session_key, text, author_name) + else: + # Echo mode for testing + response = f"Echo: {text}" + + # Send response back to Odoo + self._send_response(channel_id, response) + + async def _get_agent_response( + self, session_key: str, text: str, author_name: str + ) -> str: + """Get response from Hermes AIAgent. + + Creates or reuses an AIAgent per session for conversation continuity. + """ + # Get or create agent for this session + if session_key not in self._agents: + try: + self._agents[session_key] = get_ai_agent( + session_key, **self._agent_kwargs + ) + except Exception as e: + logger.error(f"Failed to create AIAgent: {e}") + return f"Sorry, I couldn't initialize my AI brain. Error: {e}" + + # Build user message with author context + user_message = f"[{author_name}] {text}" + + agent = self._agents.get(session_key) + if not agent: + return "Error: AI agent not available" + + # Run agent (sync method, run in thread pool) + try: + loop = asyncio.get_event_loop() + response = await loop.run_in_executor( + None, # default executor + lambda: agent.chat(user_message), + ) + return response + except Exception as e: + logger.exception("Agent error:") + return f"I encountered an error while processing your message: {e}" + + def _send_response(self, channel_id: int, response: str): + """Send response back to Odoo channel.""" + url = f"{self.odoo_url}/hermes/webhook/{self.webhook_token}" + payload = { + "channel_id": channel_id, + "body": response, + } + + try: + resp = self._client.post(url, json=payload) + resp.raise_for_status() + data = resp.json() + # Handle Odoo JSON-RPC response structure + if isinstance(data, dict): + if "error" in data: + logger.warning(f"Odoo webhook error: {data['error']}") + return + result = data.get("result", {}) + if isinstance(result, dict) and result.get("status") == "ok": + logger.info(f"Response sent to channel {channel_id}") + else: + logger.warning(f"Odoo webhook returned: {data}") + else: + logger.warning(f"Odoo webhook returned unexpected: {data}") + except Exception as e: + logger.error(f"Failed to send response: {e}") + + @staticmethod + def _strip_html(html_text: str) -> str: + """Strip HTML tags and convert entities to plain text.""" + if not html_text: + return "" + text = re.sub(r"<[^>]+>", "", html_text) + text = html.unescape(text) + return text.strip() + + +async def main(): + parser = argparse.ArgumentParser(description="Hermes Odoo Gateway Client") + parser.add_argument( + "--odoo-url", + default="http://localhost:8069", + help="Odoo instance base URL", + ) + parser.add_argument( + "--webhook-token", + required=True, + help="Webhook token from Odoo Hermes Gateway configuration", + ) + parser.add_argument( + "--poll-interval", + type=int, + default=5, + help="Polling interval in seconds (default: 5)", + ) + parser.add_argument( + "--timeout", + type=int, + default=30, + help="HTTP timeout in seconds (default: 30)", + ) + parser.add_argument( + "--use-agent", + action="store_true", + help="Use Hermes AIAgent instead of echo mode", + ) + parser.add_argument( + "--model", + default="", + help="Model name for AIAgent (e.g., kimi-k2.6, claude-sonnet-4)", + ) + parser.add_argument( + "--provider", + default=None, + help="Provider for AIAgent (e.g., kimi-coding, anthropic)", + ) + parser.add_argument( + "--max-iterations", + type=int, + default=30, + help="Max AIAgent iterations (default: 30)", + ) + parser.add_argument( + "--enabled-toolsets", + default=None, + help="Comma-separated toolsets to enable (e.g., web,terminal,file)", + ) + parser.add_argument( + "--disabled-toolsets", + default="computer_use", + help="Comma-separated toolsets to disable (default: computer_use)", + ) + parser.add_argument( + "--verbose", + "-v", + action="store_true", + help="Enable verbose logging", + ) + + args = parser.parse_args() + + if args.verbose: + logging.getLogger().setLevel(logging.DEBUG) + + # Build agent kwargs + agent_kwargs = { + "model": args.model or "k2p6", + "provider": args.provider + or "kimi-coding", # Maps to kimi-for-coding internally + "max_iterations": args.max_iterations, + } + if args.enabled_toolsets: + agent_kwargs["enabled_toolsets"] = args.enabled_toolsets.split(",") + if args.disabled_toolsets: + agent_kwargs["disabled_toolsets"] = args.disabled_toolsets.split(",") + + client = OdooGatewayClient( + odoo_url=args.odoo_url, + webhook_token=args.webhook_token, + poll_interval=args.poll_interval, + timeout=args.timeout, + use_agent=args.use_agent, + agent_kwargs=agent_kwargs, + ) + + async with client: + try: + await client.start() + except KeyboardInterrupt: + logger.info("Interrupted by user") + finally: + await client.stop() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/ai_oca_bridge_hermes/security/ir.model.access.csv b/ai_oca_bridge_hermes/security/ir.model.access.csv new file mode 100644 index 00000000..04728eb0 --- /dev/null +++ b/ai_oca_bridge_hermes/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_hermes_gateway_user,hermes.gateway user,model_hermes_gateway,base.group_user,1,0,0,0 +access_hermes_gateway_manager,hermes.gateway manager,model_hermes_gateway,base.group_system,1,1,1,1 +access_hermes_message_queue_user,hermes.message.queue user,model_hermes_message_queue,base.group_user,1,0,0,0 +access_hermes_message_queue_manager,hermes.message.queue manager,model_hermes_message_queue,base.group_system,1,1,1,1 diff --git a/ai_oca_bridge_hermes/security/security.xml b/ai_oca_bridge_hermes/security/security.xml new file mode 100644 index 00000000..99e43d76 --- /dev/null +++ b/ai_oca_bridge_hermes/security/security.xml @@ -0,0 +1,12 @@ + + + + + Hermes Gateway multi-company + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + diff --git a/ai_oca_bridge_hermes/static/description/icon.png b/ai_oca_bridge_hermes/static/description/icon.png new file mode 100644 index 00000000..4adee3cf Binary files /dev/null and b/ai_oca_bridge_hermes/static/description/icon.png differ diff --git a/ai_oca_bridge_hermes/static/description/index.html b/ai_oca_bridge_hermes/static/description/index.html new file mode 100644 index 00000000..904fa0e1 --- /dev/null +++ b/ai_oca_bridge_hermes/static/description/index.html @@ -0,0 +1,603 @@ + + + + + +AI OCA Bridge Hermes + + + +
+

AI OCA Bridge Hermes

+ + +

Beta License: AGPL-3 OCA/ai Translate me on Weblate Try me on Runboat

+

This module integrates Odoo Discuss with Hermes AI Agent, allowing users +to chat with AI assistants directly within Odoo’s messaging interface.

+

Features:

+
    +
  • Queue-based message processing with state tracking +(pending/processing/done/error)
  • +
  • Secure webhook authentication via tokens
  • +
  • Auto-detection of DM chats with AI users
  • +
  • Typing indicators while AI processes responses
  • +
  • Rate limiting on poll endpoint
  • +
  • Extensible architecture for custom handlers (escalation, support, +etc.)
  • +
  • Standalone gateway script for easy deployment
  • +
+

Table of contents

+ +
+

Use Cases / Context

+
+

Why This Module?

+

The AI revolution is changing how users interact with software. Odoo +users should be able to ask questions, get help, and automate tasks +using natural language - directly inside the interface they already use +daily.

+
+
+

Architecture Rationale

+
+

Queue-Based Design (Not Real-Time)

+

We chose polling over WebSockets for maximum compatibility:

+
    +
  • Works behind firewalls and proxies
  • +
  • No WebSocket server needed
  • +
  • Simple to debug and monitor
  • +
  • Compatible with all Odoo deployments
  • +
+
+
+

Standalone Gateway Script

+

The scripts/hermes_odoo_gateway.py script runs outside Odoo:

+
    +
  • No need to install Hermes inside Odoo’s Python environment
  • +
  • AI processing happens in a separate process
  • +
  • Can be restarted independently
  • +
  • Supports multiple Odoo instances
  • +
+
+
+

Extension Points

+

The module is intentionally minimal. Extra features are added via child +modules:

+
    +
  • ai_oca_bridge_hermes_support - Support ticket escalation for +instance
  • +
  • Custom handlers via the gateway script’s handler system
  • +
+
+
+

Escalation Concept

+

The module includes a minimal escalation hook (_action_escalate) so +the AI can signal when human intervention is needed. The base module +does not implement any specific escalation backend — child modules can +override this hook to create tickets, notify teams, or route to another +Hermes instance.

+
+
+
+

Security Model

+
    +
  • Webhook tokens are auto-generated and stored securely
  • +
  • AI users are regular Odoo users with limited permissions
  • +
  • Gateway script runs with its own credentials
  • +
  • No direct database access from the gateway
  • +
+
+
+

Multi-Tenancy

+

A single Hermes instance can serve multiple Odoo databases:

+
    +
  • Each database has its own gateway configuration
  • +
  • Session keys include database hash for isolation
  • +
  • Messages are queued per-gateway
  • +
+
+
+

MCP Compatibility

+

This module is compatible with the ai_oca_mcp module (Model Context +Protocol). If ai_oca_mcp is installed, tools defined via ai.tool +are automatically exposed through the MCP endpoint. The Hermes gateway +can then call Odoo tools either via our native API or through MCP — both +approaches work side by side.

+
+
+

Future Directions

+
    +
  • Native Hermes gateway adapter (inside Hermes process)
  • +
  • WebSocket support for real-time communication
  • +
  • Voice/video integration
  • +
  • Multi-modal AI (images, documents)
  • +
+
+
+
+

Usage

+
+

Configuration

+
    +
  1. Go to AI Bridge > Hermes > Gateways and create a new gateway
  2. +
  3. Select an AI User (must have an AI Bridge configured)
  4. +
  5. Copy the webhook token to your Hermes gateway script configuration
  6. +
  7. Optionally select specific channels to monitor (leave empty for all)
  8. +
+
+
+

Running the Gateway Script

+
+# Basic echo mode (for testing)
+python hermes_odoo_gateway.py \
+    --odoo-url http://localhost:8069 \
+    --webhook-token YOUR_TOKEN \
+    --poll-interval 5
+
+# With AIAgent (full Hermes with tools)
+python hermes_odoo_gateway.py \
+    --odoo-url http://localhost:8069 \
+    --webhook-token YOUR_TOKEN \
+    --use-agent \
+    --model k2p6 \
+    --provider kimi-coding
+
+
+
+

Usage

+

Once configured, simply send a message in a monitored channel or DM the +AI user. Hermes will poll for messages, process them with the AI, and +post responses back.

+
+
+

Extending

+

To add custom message handlers, create a child module and override:

+
    +
  • hermes_webhook._action_escalate() for escalation logic
  • +
  • hermes_message_queue models for custom state handling
  • +
  • Gateway script handlers for custom AI processing
  • +
+
+
+
+

Known issues / Roadmap

+
    +
  • WebSocket support for real-time communication (currently uses polling)
  • +
  • Native Hermes gateway adapter (currently uses standalone script)
  • +
  • Multi-tenant routing for shared Hermes instances
  • +
  • Support for file attachments and images
  • +
  • Voice message integration
  • +
  • Interactive approval buttons in Discuss messages (e.g., +“Approve/Reject” actions for sensitive operations) — nice to have for +escalation workflows
  • +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Akretion
  • +
+
+ +
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/ai project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/ai_oca_bridge_hermes/tests/__init__.py b/ai_oca_bridge_hermes/tests/__init__.py new file mode 100644 index 00000000..0e4ae9d9 --- /dev/null +++ b/ai_oca_bridge_hermes/tests/__init__.py @@ -0,0 +1 @@ +from . import test_hermes_gateway diff --git a/ai_oca_bridge_hermes/tests/test_hermes_gateway.py b/ai_oca_bridge_hermes/tests/test_hermes_gateway.py new file mode 100644 index 00000000..2260fe2f --- /dev/null +++ b/ai_oca_bridge_hermes/tests/test_hermes_gateway.py @@ -0,0 +1,221 @@ +# Copyright 2026-TODAY Akretion - Raphael Valyi +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests.common import TransactionCase + + +class TestHermesGateway(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.AiBridge = cls.env["ai.bridge"] + cls.HermesGateway = cls.env["hermes.gateway"] + cls.HermesQueue = cls.env["hermes.message.queue"] + cls.DiscussChannel = cls.env["discuss.channel"] + cls.ResUsers = cls.env["res.users"] + cls.ResPartner = cls.env["res.partner"] + + # Create test AI user - use existing partner + cls.ai_partner = cls.env.ref("base.partner_admin") + cls.ai_user = cls.ResUsers.create( + { + "name": "Test AI", + "login": "test_ai", + "partner_id": cls.ai_partner.id, + } + ) + # Create AI bridge for the user + cls.ai_bridge = cls.AiBridge.create( + { + "name": "Test AI Bridge", + "usage": "hermes", + } + ) + cls.ai_user.ai_bridge_id = cls.ai_bridge.id + + # Create gateway + cls.gateway = cls.HermesGateway.create( + { + "name": "Test Gateway", + "ai_user_id": cls.ai_user.id, + } + ) + + # Create test channel + cls.channel = cls.DiscussChannel.create( + { + "name": "Test Channel", + "channel_type": "channel", + } + ) + + def test_gateway_creation(self): + """Test gateway is created with auto-generated token.""" + self.assertTrue(self.gateway.webhook_token) + self.assertEqual(len(self.gateway.webhook_token), 64) # 32 bytes hex + self.assertEqual(self.gateway.state, "draft") + + def test_gateway_regenerate_token(self): + """Test token regeneration.""" + old_token = self.gateway.webhook_token + self.gateway.action_regenerate_token() + self.assertNotEqual(self.gateway.webhook_token, old_token) + self.assertEqual(len(self.gateway.webhook_token), 64) + + def test_gateway_test_connection_no_url(self): + """Test connection without Hermes URL shows warning.""" + result = self.gateway.action_test_connection() + self.assertEqual(result["type"], "ir.actions.client") + self.assertEqual(result["tag"], "display_notification") + self.assertEqual(result["params"]["type"], "warning") + + def test_queue_message_creation(self): + """Test message queue creation.""" + msg = self.HermesQueue.create( + { + "channel_id": self.channel.id, + "body": "

Test message

", + "gateway_id": self.gateway.id, + } + ) + self.assertEqual(msg.state, "pending") + self.assertEqual(msg.message_type, "user") + self.assertFalse(msg.is_escalated) + + def test_queue_message_retry(self): + """Test retrying failed messages.""" + msg = self.HermesQueue.create( + { + "channel_id": self.channel.id, + "body": "

Test

", + "gateway_id": self.gateway.id, + "state": "error", + "error_message": "Test error", + } + ) + msg.action_retry() + self.assertEqual(msg.state, "pending") + self.assertFalse(msg.error_message) + + def test_queue_cleanup_old_messages(self): + """Test cleanup of old messages.""" + # Create old done message + old_msg = self.HermesQueue.create( + { + "channel_id": self.channel.id, + "body": "

Old

", + "gateway_id": self.gateway.id, + "state": "done", + } + ) + # Manually set create_date to old + old_msg.write({"create_date": "2020-01-01 00:00:00"}) + + # Run cleanup + self.HermesQueue._cleanup_old_messages() + + # Message should be deleted + self.assertFalse(old_msg.exists()) + + def test_discuss_channel_get_gateway(self): + """Test finding gateway for channel.""" + # Channel without gateway + channel_no_gateway = self.DiscussChannel.create( + { + "name": "No Gateway", + "channel_type": "channel", + } + ) + self.assertFalse(channel_no_gateway._get_hermes_gateway()) + + # Channel with explicit gateway + self.channel.hermes_gateway_id = self.gateway.id + found = self.channel._get_hermes_gateway() + self.assertEqual(found, self.gateway) + + def test_discuss_channel_message_post_queues(self): + """Test that posting a message queues it for Hermes.""" + self.channel.hermes_gateway_id = self.gateway.id + + # Post as regular user (admin) + self.channel.with_user(self.env.user.id).message_post( + body="

Hello Hermes

", + message_type="comment", + ) + + # Check queue - search by channel only since body might be wrapped + queued = self.HermesQueue.search( + [ + ("channel_id", "=", self.channel.id), + ] + ) + self.assertTrue(queued) + self.assertEqual(queued.state, "pending") + + def test_discuss_channel_ai_user_message_no_queue(self): + """Test that AI user messages don't get queued (prevent loops).""" + self.channel.hermes_gateway_id = self.gateway.id + + # Post as AI user + self.channel.with_user(self.ai_user.id).message_post( + body="

AI response

", + message_type="comment", + ) + + # Should not be queued + queued = self.HermesQueue.search( + [ + ("channel_id", "=", self.channel.id), + ("body", "=", "

AI response

"), + ] + ) + self.assertFalse(queued) + + def test_discuss_channel_auto_respect_disabled(self): + """Test that auto-respect disabled prevents queuing.""" + self.channel.hermes_gateway_id = self.gateway.id + self.channel.hermes_auto_respond = False + + self.channel.message_post( + body="

Should not queue

", + message_type="comment", + ) + + queued = self.HermesQueue.search( + [ + ("channel_id", "=", self.channel.id), + ("body", "=", "

Should not queue

"), + ] + ) + self.assertFalse(queued) + + def test_webhook_format_body(self): + """Test HTML formatting in webhook.""" + from ..controllers.hermes_webhook import HermesWebhook + + webhook = HermesWebhook() + + # Plain text + result = webhook._format_body("Hello world") + self.assertIn("Hello world", result) + self.assertTrue(result.startswith("")) + + # HTML input (should strip tags) + result = webhook._format_body("

Hello

") + self.assertIn("Hello", result) + self.assertNotIn("

", result) + + # URL conversion + result = webhook._format_body("Visit https://example.com") + self.assertIn(' + + + + discuss.channel + + + + + + + + diff --git a/ai_oca_bridge_hermes/views/hermes_gateway_views.xml b/ai_oca_bridge_hermes/views/hermes_gateway_views.xml new file mode 100644 index 00000000..8b397f27 --- /dev/null +++ b/ai_oca_bridge_hermes/views/hermes_gateway_views.xml @@ -0,0 +1,110 @@ + + + + + + hermes.gateway + + + + + + + + + + + + + + hermes.gateway + +

+
+
+ +
+

+ +

+
+ + + + + + + + + + + + + + +

Leave empty to monitor all channels where the AI user is a member.

+
+ + + + +
+
+ +
+ + + + + + + hermes.gateway + + + + + + + + + + + + + Hermes Gateways + hermes.gateway + list,form + + diff --git a/ai_oca_bridge_hermes/views/menu.xml b/ai_oca_bridge_hermes/views/menu.xml new file mode 100644 index 00000000..579957df --- /dev/null +++ b/ai_oca_bridge_hermes/views/menu.xml @@ -0,0 +1,18 @@ + + + + + +