From 1fc73efbd7b84cfb6278e2ec5e4a6718750d0aeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Valyi?= Date: Sun, 31 May 2026 12:21:07 -0300 Subject: [PATCH 1/3] [ADD] ai_oca_bridge_hermes: new module --- ai_oca_bridge_hermes/README.rst | 234 +++++++ ai_oca_bridge_hermes/__init__.py | 2 + ai_oca_bridge_hermes/__manifest__.py | 30 + ai_oca_bridge_hermes/controllers/__init__.py | 3 + .../controllers/hermes_poll.py | 109 ++++ .../controllers/hermes_tools.py | 178 ++++++ .../controllers/hermes_webhook.py | 170 +++++ .../data/hermes_tools_data.xml | 51 ++ ai_oca_bridge_hermes/data/ir_cron.xml | 14 + ai_oca_bridge_hermes/models/__init__.py | 6 + ai_oca_bridge_hermes/models/ai_bridge.py | 62 ++ .../models/discuss_channel.py | 104 +++ ai_oca_bridge_hermes/models/hermes_gateway.py | 110 ++++ .../models/hermes_message_queue.py | 65 ++ .../models/hermes_tools_mock.py | 30 + ai_oca_bridge_hermes/models/res_users.py | 14 + ai_oca_bridge_hermes/pyproject.toml | 3 + ai_oca_bridge_hermes/readme/CONTEXT.md | 52 ++ ai_oca_bridge_hermes/readme/CONTRIBUTORS.md | 1 + ai_oca_bridge_hermes/readme/DESCRIPTION.md | 12 + ai_oca_bridge_hermes/readme/ROADMAP.md | 6 + ai_oca_bridge_hermes/readme/USAGE.md | 36 ++ .../scripts/hermes_odoo_gateway.py | 394 ++++++++++++ .../security/ir.model.access.csv | 5 + ai_oca_bridge_hermes/security/security.xml | 12 + .../static/description/icon.png | Bin 0 -> 72370 bytes .../static/description/index.html | 603 ++++++++++++++++++ ai_oca_bridge_hermes/tests/__init__.py | 1 + .../tests/test_hermes_gateway.py | 221 +++++++ .../views/discuss_channel_views.xml | 14 + .../views/hermes_gateway_views.xml | 110 ++++ ai_oca_bridge_hermes/views/menu.xml | 18 + 32 files changed, 2670 insertions(+) create mode 100644 ai_oca_bridge_hermes/README.rst create mode 100644 ai_oca_bridge_hermes/__init__.py create mode 100644 ai_oca_bridge_hermes/__manifest__.py create mode 100644 ai_oca_bridge_hermes/controllers/__init__.py create mode 100644 ai_oca_bridge_hermes/controllers/hermes_poll.py create mode 100644 ai_oca_bridge_hermes/controllers/hermes_tools.py create mode 100644 ai_oca_bridge_hermes/controllers/hermes_webhook.py create mode 100644 ai_oca_bridge_hermes/data/hermes_tools_data.xml create mode 100644 ai_oca_bridge_hermes/data/ir_cron.xml create mode 100644 ai_oca_bridge_hermes/models/__init__.py create mode 100644 ai_oca_bridge_hermes/models/ai_bridge.py create mode 100644 ai_oca_bridge_hermes/models/discuss_channel.py create mode 100644 ai_oca_bridge_hermes/models/hermes_gateway.py create mode 100644 ai_oca_bridge_hermes/models/hermes_message_queue.py create mode 100644 ai_oca_bridge_hermes/models/hermes_tools_mock.py create mode 100644 ai_oca_bridge_hermes/models/res_users.py create mode 100644 ai_oca_bridge_hermes/pyproject.toml create mode 100644 ai_oca_bridge_hermes/readme/CONTEXT.md create mode 100644 ai_oca_bridge_hermes/readme/CONTRIBUTORS.md create mode 100644 ai_oca_bridge_hermes/readme/DESCRIPTION.md create mode 100644 ai_oca_bridge_hermes/readme/ROADMAP.md create mode 100644 ai_oca_bridge_hermes/readme/USAGE.md create mode 100755 ai_oca_bridge_hermes/scripts/hermes_odoo_gateway.py create mode 100644 ai_oca_bridge_hermes/security/ir.model.access.csv create mode 100644 ai_oca_bridge_hermes/security/security.xml create mode 100644 ai_oca_bridge_hermes/static/description/icon.png create mode 100644 ai_oca_bridge_hermes/static/description/index.html create mode 100644 ai_oca_bridge_hermes/tests/__init__.py create mode 100644 ai_oca_bridge_hermes/tests/test_hermes_gateway.py create mode 100644 ai_oca_bridge_hermes/views/discuss_channel_views.xml create mode 100644 ai_oca_bridge_hermes/views/hermes_gateway_views.xml create mode 100644 ai_oca_bridge_hermes/views/menu.xml 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..58355ebd --- /dev/null +++ b/ai_oca_bridge_hermes/controllers/hermes_webhook.py @@ -0,0 +1,170 @@ +# 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/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..c38098a3 --- /dev/null +++ b/ai_oca_bridge_hermes/models/discuss_channel.py @@ -0,0 +1,104 @@ +# 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 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 + + # Check if gateway monitors specific channels (only for non-DM) + if ( + gateway.channel_ids + and self.channel_type != "chat" + and self not in gateway.channel_ids + ): + return message + + # Check auto-respond is enabled + if not self.hermes_auto_respond: + 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 0000000000000000000000000000000000000000..4adee3cffbbe51372c56582e1a879ec08b5e0d94 GIT binary patch literal 72370 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 + + + + +AI OCA Bridge Hermes + + + +

+ + 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 @@ + + + + + + From da4f03c86ee027e9d1d040c4bd9e0304302bd5d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Valyi?= Date: Tue, 2 Jun 2026 19:42:17 -0300 Subject: [PATCH 2/3] [IMP] ai_oca_bridge_hermes: smart mention-based routing for Hermes responses Add _is_ai_mentioned and _should_queue_for_hermes methods to control when Hermes responds in Discuss channels: - DM (chat): always respond (1-to-1 conversation) - Group/channel with @mention: always respond - Group/channel without mention: only if auto-respond enabled AND channel explicitly monitored by the gateway This prevents Hermes from spamming group channels while still allowing users to invoke it explicitly with @Hermes. --- .../models/discuss_channel.py | 51 +++++++++++++++---- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/ai_oca_bridge_hermes/models/discuss_channel.py b/ai_oca_bridge_hermes/models/discuss_channel.py index c38098a3..7a42c410 100644 --- a/ai_oca_bridge_hermes/models/discuss_channel.py +++ b/ai_oca_bridge_hermes/models/discuss_channel.py @@ -46,6 +46,45 @@ def _get_hermes_gateway(self): 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) @@ -63,16 +102,8 @@ def message_post(self, **kwargs): if not gateway.active or not gateway.ai_user_id: return message - # Check if gateway monitors specific channels (only for non-DM) - if ( - gateway.channel_ids - and self.channel_type != "chat" - and self not in gateway.channel_ids - ): - return message - - # Check auto-respond is enabled - if not self.hermes_auto_respond: + # Decide whether to queue this message + if not self._should_queue_for_hermes(message, gateway): return message # Queue the message for Hermes From 130235c14344ddc9b22bf49536da5fee6e9a685e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Valyi?= Date: Tue, 2 Jun 2026 20:21:01 -0300 Subject: [PATCH 3/3] [IMP] ai_oca_bridge_hermes: add /hermes/notify endpoint with web_notify soft dependency Add hermes_notify endpoint to send proactive web notifications to users. Uses web_notify module (OCA/web) as soft dependency: - If installed: displays toast notifications with action buttons - If not installed: falls back to DM message Supports notification types: success, warning, danger, info, default. Allows sticky notifications and optional action to open records. --- .../controllers/hermes_webhook.py | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/ai_oca_bridge_hermes/controllers/hermes_webhook.py b/ai_oca_bridge_hermes/controllers/hermes_webhook.py index 58355ebd..a63ce7ec 100644 --- a/ai_oca_bridge_hermes/controllers/hermes_webhook.py +++ b/ai_oca_bridge_hermes/controllers/hermes_webhook.py @@ -16,6 +16,145 @@ 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 )
+

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.

+
+
+