From 732c5bb7cc35af14c47d4364b422c16e4bb594ce Mon Sep 17 00:00:00 2001 From: Nikhil Kothari Date: Sat, 5 Apr 2025 23:50:27 +0530 Subject: [PATCH 01/36] fix: lower case repo name for docker build --- .github/workflows/build.yml | 4 ++-- README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0f0cfcfab..c8d7d20fb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -57,8 +57,8 @@ jobs: context: builds file: builds/images/layered/Containerfile tags: > - ghcr.io/${{ github.repository }}:${{ github.ref_name }}, - ghcr.io/${{ github.repository }}:${{ env.IMAGE_TAG }} + ghcr.io/the-commit-company/raven:${{ github.ref_name }}, + ghcr.io/the-commit-company/raven:${{ env.IMAGE_TAG }} build-args: | "FRAPPE_BRANCH=${{ env.FRAPPE_BRANCH }}" "APPS_JSON_BASE64=${{ env.APPS_JSON_BASE64 }}" diff --git a/README.md b/README.md index 8b15bf695..41d42cf4e 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ wget https://frappe.io/easy-install.py python3 ./easy-install.py deploy \ --project=learning_prod_setup \ --email=your_email.example.com \ - --image=ghcr.io/The-Commit-Company/raven \ + --image=ghcr.io/the-commit-company/raven \ --version=stable \ --app=raven \ --sitename subdomain.domain.tld From 23098133fb0e1cb4ef80a0def89bfc24721e53d4 Mon Sep 17 00:00:00 2001 From: Prathamesh Kurunkar <59260326+prathameshkurunkar7@users.noreply.github.com> Date: Thu, 18 Dec 2025 23:20:45 +0530 Subject: [PATCH 02/36] Merge pull request from The-Commit-Company/develop (#1944) * chore: set up translations (#1927) * fix: add scope to manifest in Vite configuration (#1925) * fix: disable Variables tab for functions with auto-generated params (#1910) * fix: Validate both users are Raven Users in create_direct_message_channel API (#1901) * fix: Open chat in new tab for AI agents (#1911) * fix: allow get_raven_user to match user_id against user or bot (#1935) * fix: sync full_name from User to Raven User on update (#1909) * fix: update in-memory value for total_votes and removed extra loop over options (#1919) * fix: update in-memory value for total_votes and removed extra loop over options * chore: pre-commit * fix: use query builder in update_poll_votes to maintain compatibility accross frappe versions * chore: pre-commit --------- Co-authored-by: Nikhil Kothari * fix(notification): DMs not receiving notifications (#1942) * chore: bump version to 2.6.6 (#1943) --------- Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com> Co-authored-by: UmakanthKaspa Co-authored-by: Aditya Patil <46787266+TITANiumRox@users.noreply.github.com> Co-authored-by: Diptanil Saha Co-authored-by: Nikhil Kothari --- babel_extractors.csv | 2 + frontend/package.json | 4 +- .../settings/ai/functions/FunctionForm.tsx | 23 +- frontend/src/pages/settings/AI/ViewBot.tsx | 22 +- frontend/vite.config.ts | 1 + package.json | 4 +- raven/__init__.py | 2 +- raven/api/raven_channel.py | 11 +- raven/hooks.py | 5 + raven/locale/main.pot | 2487 +++++++++++++++++ raven/notification.py | 31 +- raven/package.json | 4 +- raven/raven/doctype/raven_user/raven_user.py | 12 +- .../raven_poll_vote/raven_poll_vote.py | 16 +- raven/utils.py | 8 +- 15 files changed, 2596 insertions(+), 36 deletions(-) create mode 100644 babel_extractors.csv create mode 100644 raven/locale/main.pot diff --git a/babel_extractors.csv b/babel_extractors.csv new file mode 100644 index 000000000..b1070a27d --- /dev/null +++ b/babel_extractors.csv @@ -0,0 +1,2 @@ +**.tsx,frappe.gettext.extractors.html_template.extract +**.ts,frappe.gettext.extractors.html_template.extract \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index dce5e2dcc..3d195f18c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,7 +2,7 @@ "name": "raven-web", "private": true, "license": "AGPL-3.0-only", - "version": "2.6.5", + "version": "2.6.6", "type": "module", "scripts": { "dev": "vite", @@ -79,4 +79,4 @@ "@types/turndown": "^5.0.4", "typescript": "^5.3.3" } -} \ No newline at end of file +} diff --git a/frontend/src/components/feature/settings/ai/functions/FunctionForm.tsx b/frontend/src/components/feature/settings/ai/functions/FunctionForm.tsx index e5302babb..d6759dd1d 100644 --- a/frontend/src/components/feature/settings/ai/functions/FunctionForm.tsx +++ b/frontend/src/components/feature/settings/ai/functions/FunctionForm.tsx @@ -25,8 +25,27 @@ const FunctionForm = ({ isEdit }: { isEdit?: boolean }) => { return ( - Details - Variables + + Details + + + Variables + diff --git a/frontend/src/pages/settings/AI/ViewBot.tsx b/frontend/src/pages/settings/AI/ViewBot.tsx index 493cd393b..aa7b55d30 100644 --- a/frontend/src/pages/settings/AI/ViewBot.tsx +++ b/frontend/src/pages/settings/AI/ViewBot.tsx @@ -16,7 +16,7 @@ import { useAtomValue } from "jotai" import { useContext, useEffect } from "react" import { FormProvider, useForm } from "react-hook-form" import { FiExternalLink } from "react-icons/fi" -import { useNavigate, useParams } from "react-router-dom" +import { useParams } from "react-router-dom" import { toast } from "sonner" type Props = {} @@ -102,19 +102,27 @@ const OpenChatButton = ({ bot }: { bot: RavenBot }) => { const { call } = useContext(FrappeContext) as FrappeConfig - const navigate = useNavigate() - const currentWorkspace = useAtomValue(lastWorkspaceAtom) const openChat = () => { call.post("raven.api.raven_channel.create_direct_message_channel", { user_id: bot.raven_user }).then((res) => { - if (currentWorkspace) { - navigate(`/${currentWorkspace}/${res.message}`) - } else { - navigate(`/channel/${res.message}`) + const chatPath = currentWorkspace + ? `/${currentWorkspace}/${res.message}` + : `/channel/${res.message}` + + let basePath = `${import.meta.env.VITE_BASE_NAME}` + if (!window.location.origin.endsWith("/")) { + basePath = "/" + basePath } + + const fullUrl = `${window.location.origin}${basePath}${chatPath}` + + window.open(fullUrl, '_blank') + }).catch((error) => { + toast.error('Failed to create chat channel') + console.error(error) }) } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index d8f31a14d..d0e93f714 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -18,6 +18,7 @@ export default defineConfig(({ command, mode }) => { manifest: { name: "Raven", start_url: `/${env.VITE_BASE_NAME}`, + scope: `/${env.VITE_BASE_NAME}/`, short_name: "Raven", description: "Simple, work messaging tool.", display: "standalone", diff --git a/package.json b/package.json index a2f44ebb7..2677bdc42 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "raven", - "version": "2.6.5", + "version": "2.6.6", "description": "Messaging Application", "workspaces": [ "frontend", @@ -24,4 +24,4 @@ "turbo": "^2.3.3" }, "dependencies": {} -} \ No newline at end of file +} diff --git a/raven/__init__.py b/raven/__init__.py index 7293d0698..1f00604ca 100644 --- a/raven/__init__.py +++ b/raven/__init__.py @@ -1,4 +1,4 @@ -__version__ = "2.6.5" +__version__ = "2.6.6" from raven.raven_integrations.doctype.raven_incoming_webhook.raven_incoming_webhook import ( # noqa handle_incoming_webhook as webhook, diff --git a/raven/api/raven_channel.py b/raven/api/raven_channel.py index 70a36e0dd..1705c79a5 100644 --- a/raven/api/raven_channel.py +++ b/raven/api/raven_channel.py @@ -3,7 +3,7 @@ from frappe.query_builder import Order from raven.api.raven_users import get_current_raven_user -from raven.utils import get_channel_members, is_channel_member, track_channel_visit +from raven.utils import get_channel_members, get_raven_user, is_channel_member, track_channel_visit @frappe.whitelist() @@ -149,6 +149,15 @@ def create_direct_message_channel(user_id): 3. Check if the user_id is the current user and set is_self_message accordingly """ # TODO: this logic might break if the user_id changes + + # Validate both users are Raven Users + if not get_raven_user(frappe.session.user): + frappe.throw(_("You need to be a Raven User to send DMs.")) + + if user_id != frappe.session.user: + if not get_raven_user(user_id): + frappe.throw(_("The user you are trying to message is not a Raven User.")) + channel_name = frappe.db.get_value( "Raven Channel", filters={ diff --git a/raven/hooks.py b/raven/hooks.py index c28cda94f..05b03e64b 100644 --- a/raven/hooks.py +++ b/raven/hooks.py @@ -272,3 +272,8 @@ export_python_type_annotations = True raven_document_link_override = "raven.api.document_link.get_new_app_document_links" + +# Translation +# ------------ +# List of apps whose translatable strings should be excluded from this app's translations. +ignore_translatable_strings_from = ["frappe"] diff --git a/raven/locale/main.pot b/raven/locale/main.pot new file mode 100644 index 000000000..c3a359d5a --- /dev/null +++ b/raven/locale/main.pot @@ -0,0 +1,2487 @@ +# Translations template for Raven. +# Copyright (C) 2025 The Commit Company (Algocode Technologies Pvt. Ltd.) +# This file is distributed under the same license as the Raven project. +# FIRST AUTHOR , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: Raven VERSION\n" +"Report-Msgid-Bugs-To: support@thecommit.company\n" +"POT-Creation-Date: 2025-12-01 18:26+0053\n" +"PO-Revision-Date: 2025-12-01 18:26+0053\n" +"Last-Translator: support@thecommit.company\n" +"Language-Team: support@thecommit.company\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.16.0\n" + +#. Description of the 'Enable Code Interpreter' (Check) field in DocType 'Raven +#. Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "" +" Enable this if you want the bot to be able to process files like Excel sheets or data from Insights.\n" +"
\n" +" OpenAI Assistants run code in a sandboxed environment (on OpenAI servers) to do this." +msgstr "" + +#. Description of the Onboarding Step 'Add users' +#: raven/raven/onboarding_step/adding_users_to_raven/adding_users_to_raven.json +msgid "" +"## Adding Users\n" +"\n" +"To access Raven, users need to have the **Raven User** role.\n" +"\n" +"Go to the \"Users\" list and add the role to the users you want on Raven." +msgstr "" + +#. Description of the Onboarding Step 'Understanding Channels' +#: raven/raven/onboarding_step/introduction_to_raven/introduction_to_raven.json +msgid "" +"## Channels\n" +"\n" +"Raven helps you and your team communicate with each other.\n" +"\n" +"Conversation happen in **channels**. \n" +"\n" +"All users who have access to Raven can view and send messages in **Open** channels. \n" +"\n" +"**Public** channels are visible to everyone, but only members can take send messages in them. Any user can join public channels. \n" +"\n" +"**Private** channels are only visible to members and new members can only be added by the channel admin - which is the creator of the channel. \n" +"\n" +"You could also send messages to other users privately via **Direct messages** - including yourself!\n" +"\n" +"A user could create as many channels and add as many users as they want to in them. " +msgstr "" + +#. Description of the Onboarding Step 'Review Settings' +#: raven/raven/onboarding_step/review_raven_settings/review_raven_settings.json +msgid "" +"## Raven Settings\n" +"\n" +"\n" +"**Automatically adding users to Raven**\n" +"\n" +"To make it easy for you, the Raven User role is assigned to any user who has access to Desk. If you wish to turn this off, please head to Raven Settings." +msgstr "" + +#. Description of the Onboarding Step 'It's time. Send a Raven.' +#: raven/raven/onboarding_step/access_the_web_app/access_the_web_app.json +msgid "" +"### Access the web and mobile apps\n" +"\n" +"\n" +"Raven is available at **/raven** for both web and mobile. On mobile, Raven can be installed as a PWA." +msgstr "" + +#. Content of the 'html_xuuw' (HTML) field in DocType 'Raven User' +#: raven/raven/doctype/raven_user/raven_user.json +msgid "

To disable the user from accessing Raven, go to \"Users\" and remove the \"Raven User\" role.

" +msgstr "" + +#. Header text in the Raven Workspace +#: raven/raven/workspace/raven/raven.json +msgid "Raven" +msgstr "" + +#: raven/raven_channel_management/doctype/raven_channel/raven_channel.py:197 +msgid "A channel with this name already exists in this workspace." +msgstr "" + +#. Label of the ai_section (Section Break) field in DocType 'Raven Settings' +#. Label of a Card Break in the Raven Workspace +#. Label of the ai_tab (Tab Break) field in DocType 'Raven Bot' +#. Label of the ai_tab (Tab Break) field in DocType 'Raven Channel' +#: raven/raven/doctype/raven_settings/raven_settings.json +#: raven/raven/workspace/raven/raven.json +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +#: raven/raven_channel_management/doctype/raven_channel/raven_channel.json +msgid "AI" +msgstr "" + +#: raven/ai/openai_client.py:14 +msgid "AI Integration is not enabled" +msgstr "" + +#. Label of the action_name (Data) field in DocType 'Raven Message Action' +#: raven/raven_integrations/doctype/raven_message_action/raven_message_action.json +msgid "Action Name" +msgstr "" + +#. Label of an action in the Onboarding Step 'Add users' +#: raven/raven/onboarding_step/adding_users_to_raven/adding_users_to_raven.json +msgid "Add Users to Raven" +msgstr "" + +#: frontend/src/components/feature/channel-details/edit-channel-description/EditChannelDescriptionModal.tsx:43 +#: frontend/src/components/feature/channel-details/edit-channel-description/EditChannelDescriptionModal.tsx:51 +msgid "Add description" +msgstr "" + +#. Title of an Onboarding Step +#: raven/raven/onboarding_step/adding_users_to_raven/adding_users_to_raven.json +msgid "Add users" +msgstr "" + +#. Label of the allow_bot_to_write_documents (Check) field in DocType 'Raven +#. Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "Allow Bot to Write Documents" +msgstr "" + +#. Label of the allow_notifications (Check) field in DocType 'Raven Channel +#. Member' +#: raven/raven_channel_management/doctype/raven_channel_member/raven_channel_member.json +msgid "Allow notifications" +msgstr "" + +#. Description of the 'Top P' (Float) field in DocType 'Raven Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "" +"An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered.\n" +"\n" +"We generally recommend altering this or temperature but not both." +msgstr "" + +#: frontend/src/pages/settings/Appearance.tsx:54 +msgid "Appearance" +msgstr "" + +#: frontend/src/components/feature/userSettings/UploadImage/DeleteImageModal.tsx:34 +msgid "Are you sure you want to remove this image?" +msgstr "" + +#. Option for the 'Type' (Select) field in DocType 'Raven AI Function' +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json +msgid "Attach File to Document" +msgstr "" + +#. Label of the attendance_and_leaves_section (Section Break) field in DocType +#. 'Raven Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "Attendance and Leaves" +msgstr "" + +#. Label of the auto_create_department_channel (Check) field in DocType 'Raven +#. Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "Automatically Create a Channel for each Department" +msgstr "" + +#. Label of the auto_add_system_users (Check) field in DocType 'Raven Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "Automatically add system users to Raven" +msgstr "" + +#: frontend/src/pages/settings/Integrations/FrappeHR.tsx:114 +msgid "Automatically create channels for departments" +msgstr "" + +#. Label of the availability_status (Select) field in DocType 'Raven User' +#: frontend/src/components/feature/userSettings/UserProfile/UserProfile.tsx:129 +#: raven/raven/doctype/raven_user/raven_user.json +msgid "Availability Status" +msgstr "" + +#. Option for the 'Availability Status' (Select) field in DocType 'Raven User' +#: frontend/src/components/feature/userSettings/AvailabilityStatus/SetUserAvailabilityMenu.tsx:64 +#: frontend/src/components/feature/userSettings/AvailabilityStatus/SetUserAvailabilityMenu.tsx:72 +#: raven/raven/doctype/raven_user/raven_user.json +msgid "Available" +msgstr "" + +#. Option for the 'Availability Status' (Select) field in DocType 'Raven User' +#: frontend/src/components/feature/userSettings/AvailabilityStatus/SetUserAvailabilityMenu.tsx:66 +#: raven/raven/doctype/raven_user/raven_user.json +msgid "Away" +msgstr "" + +#. Label of the blurhash (Small Text) field in DocType 'Raven Message' +#: raven/raven_messaging/doctype/raven_message/raven_message.json +msgid "Blurhash" +msgstr "" + +#. Label of the bot_functions (Table) field in DocType 'Raven Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "Bot Functions" +msgstr "" + +#. Label of the bot_name (Data) field in DocType 'Raven Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "Bot Name" +msgstr "" + +#: raven/raven/doctype/raven_user/raven_user.py:48 +msgid "Bot is mandatory" +msgstr "" + +#: raven/ai/agents_integration.py:383 +msgid "Bot model is not configured" +msgstr "" + +#. Label of a Link in the Raven Workspace +#: raven/raven/workspace/raven/raven.json +msgid "Bots" +msgstr "" + +#. Label of the cron_expression (Data) field in DocType 'Raven Scheduler Event' +#: raven/raven_integrations/doctype/raven_scheduler_event/raven_scheduler_event.json +msgid "CRON Expression" +msgstr "" + +#. Description of the 'Message' (Code) field in DocType 'Raven Document +#. Notification' +#: raven/raven_integrations/doctype/raven_document_notification/raven_document_notification.json +msgid "Can be HTML/Markdown/Plain Text. Support Jinja tags" +msgstr "" + +#. Label of the can_only_join_via_invite (Check) field in DocType 'Raven +#. Workspace' +#: raven/raven/doctype/raven_workspace/raven_workspace.json +msgid "Can only join via invite?" +msgstr "" + +#. Option for the 'Type' (Select) field in DocType 'Raven AI Function' +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json +msgid "Cancel Document" +msgstr "" + +#. Option for the 'Webhook Trigger' (Select) field in DocType 'Raven Webhook' +#: raven/raven_integrations/doctype/raven_webhook/raven_webhook.json +msgid "Channel Created" +msgstr "" + +#. Option for the 'Webhook Trigger' (Select) field in DocType 'Raven Webhook' +#: raven/raven_integrations/doctype/raven_webhook/raven_webhook.json +msgid "Channel Deleted" +msgstr "" + +#. Label of the channel_description (Small Text) field in DocType 'Raven +#. Channel' +#: raven/raven_channel_management/doctype/raven_channel/raven_channel.json +msgid "Channel Description" +msgstr "" + +#. Label of the channel_id (Link) field in DocType 'Raven Pinned Channels' +#. Label of the channel_id (Link) field in DocType 'Raven Incoming Webhook' +#. Label of the channel_id (Link) field in DocType 'Raven Message' +#. Label of the channel_id (Link) field in DocType 'Raven Message Reaction' +#: raven/raven/doctype/raven_pinned_channels/raven_pinned_channels.json +#: raven/raven_integrations/doctype/raven_incoming_webhook/raven_incoming_webhook.json +#: raven/raven_messaging/doctype/raven_message/raven_message.json +#: raven/raven_messaging/doctype/raven_message_reaction/raven_message_reaction.json +msgid "Channel ID" +msgstr "" + +#. Option for the 'Webhook Trigger' (Select) field in DocType 'Raven Webhook' +#: raven/raven_integrations/doctype/raven_webhook/raven_webhook.json +msgid "Channel Member Added" +msgstr "" + +#. Option for the 'Webhook Trigger' (Select) field in DocType 'Raven Webhook' +#: raven/raven_integrations/doctype/raven_webhook/raven_webhook.json +msgid "Channel Member Deleted" +msgstr "" + +#. Label of the channel_name (Data) field in DocType 'Raven Channel' +#: raven/raven_channel_management/doctype/raven_channel/raven_channel.json +msgid "Channel Name" +msgstr "" + +#. Label of the channel_type (Select) field in DocType 'Raven Document +#. Notification Recipients' +#. Option for the 'Conditions On' (Select) field in DocType 'Raven Webhook' +#. Label of the channel_type (Select) field in DocType 'Raven Webhook' +#: raven/raven_integrations/doctype/raven_document_notification_recipients/raven_document_notification_recipients.json +#: raven/raven_integrations/doctype/raven_webhook/raven_webhook.json +msgid "Channel Type" +msgstr "" + +#: raven/raven_integrations/doctype/raven_webhook/raven_webhook.py:221 +msgid "Channel Type cannot be triggered on other doctypes" +msgstr "" + +#: raven/raven_integrations/doctype/raven_webhook/raven_webhook.py:202 +msgid "Channel cannot be triggered on User" +msgstr "" + +#: frontend/src/components/feature/channels/CreateChannelModal.tsx:130 +msgid "Channel created" +msgstr "" + +#: frontend/src/components/feature/channel-details/edit-channel-description/EditChannelDescriptionModal.tsx:48 +msgid "Channel description" +msgstr "" + +#: frontend/src/components/feature/channel-details/edit-channel-description/EditChannelDescriptionModal.tsx:34 +msgid "Channel description updated" +msgstr "" + +#: frontend/src/components/feature/channels/CreateChannelModal.tsx:201 +msgid "Channel name can only contain letters, numbers and hyphens." +msgstr "" + +#: frontend/src/components/feature/channels/CreateChannelModal.tsx:195 +msgid "Channel name cannot be less than {0} characters." +msgstr "" + +#: frontend/src/components/feature/channels/CreateChannelModal.tsx:191 +msgid "Channel name cannot be more than {0} characters." +msgstr "" + +#: raven/raven_integrations/doctype/raven_document_notification/raven_document_notification.py:80 +msgid "Channel {0} does not exist in Raven." +msgstr "" + +#. Label of a Link in the Raven Workspace +#: frontend/src/components/feature/channels/ChannelList.tsx:51 +#: raven/raven/workspace/raven/raven.json +msgid "Channels" +msgstr "" + +#: frontend/src/components/feature/channels/CreateChannelModal.tsx:171 +msgid "Channels are where your team communicates. They are best when organized around a topic - #development, for example." +msgstr "" + +#. Label of the chat_style_section (Section Break) field in DocType 'Raven +#. User' +#: raven/raven/doctype/raven_user/raven_user.json +msgid "Chat Layout" +msgstr "" + +#. Label of the chat_style (Select) field in DocType 'Raven User' +#: raven/raven/doctype/raven_user/raven_user.json +msgid "Chat Style" +msgstr "" + +#. Option for the 'Type' (Select) field in DocType 'Raven Message Action +#. Fields' +#: raven/raven_integrations/doctype/raven_message_action_fields/raven_message_action_fields.json +msgid "Checkbox" +msgstr "" + +#. Label of the child_table_name (Data) field in DocType 'Raven AI Function +#. Params' +#: raven/raven_ai/doctype/raven_ai_function_params/raven_ai_function_params.json +msgid "Child Table Name" +msgstr "" + +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.py:118 +msgid "Child table {0} is not a valid doctype" +msgstr "" + +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.py:112 +msgid "Child table {0} not found in {1}" +msgstr "" + +#: frontend/src/components/feature/hr/CompanyWorkspaceMapping.tsx:28 +msgid "Choose workspaces based on companies" +msgstr "" + +#. Label of the company (Data) field in DocType 'Raven HR Company Workspace' +#: frontend/src/components/feature/hr/CompanyWorkspaceMapping.tsx:37 +#: raven/raven_integrations/doctype/raven_hr_company_workspace/raven_hr_company_workspace.json +msgid "Company" +msgstr "" + +#. Label of the company_workspace_mapping (Table) field in DocType 'Raven +#. Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "Company Workspace Mapping" +msgstr "" + +#: frontend/src/components/feature/hr/CompanyWorkspaceMapping.tsx:54 +msgid "Company is required" +msgstr "" + +#. Label of the conditions_on (Select) field in DocType 'Raven Webhook' +#: raven/raven_integrations/doctype/raven_webhook/raven_webhook.json +msgid "Conditions On" +msgstr "" + +#: frontend/src/pages/settings/Appearance.tsx:55 +msgid "Configure how you want the app to look." +msgstr "" + +#: frontend/src/pages/settings/PushNotifications.tsx:83 +msgid "Configure the push notification service here." +msgstr "" + +#: frontend/src/pages/settings/Preferences.tsx:26 +msgid "Configure your preferences." +msgstr "" + +#: frontend/src/pages/settings/Integrations/FrappeHR.tsx:84 +msgid "Connect your HR system to Raven to sync employee data and send notifications." +msgstr "" + +#: raven/raven_bot/doctype/raven_bot/raven_bot.py:173 +msgid "Connection to OpenAI API failed. Please check your Organization ID and API Key for any extra spaces. Error: {0}" +msgstr "" + +#: frontend/src/components/feature/direct-messages/DirectMessageList.tsx:134 +msgid "Could not create channel" +msgstr "" + +#. Option for the 'Type' (Select) field in DocType 'Raven AI Function' +#. Option for the 'Action' (Select) field in DocType 'Raven Message Action' +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json +#: raven/raven_integrations/doctype/raven_message_action/raven_message_action.json +msgid "Create Document" +msgstr "" + +#. Option for the 'Type' (Select) field in DocType 'Raven AI Function' +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json +msgid "Create Multiple Documents" +msgstr "" + +#: frontend/src/components/feature/channels/CreateChannelModal.tsx:145 +msgid "Create a private channel" +msgstr "" + +#: frontend/src/components/feature/channels/CreateChannelModal.tsx:157 +msgid "Create a public channel" +msgstr "" + +#: frontend/src/components/feature/channels/CreateChannelModal.tsx:151 +msgid "Create an open channel" +msgstr "" + +#. Option for the 'Type' (Select) field in DocType 'Raven AI Function' +#. Option for the 'Action' (Select) field in DocType 'Raven Message Action' +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json +#: raven/raven_integrations/doctype/raven_message_action/raven_message_action.json +msgid "Custom Function" +msgstr "" + +#. Label of the custom_function_path (Small Text) field in DocType 'Raven +#. Message Action' +#: raven/raven_integrations/doctype/raven_message_action/raven_message_action.json +msgid "Custom Function Path" +msgstr "" + +#. Label of the custom_status (Data) field in DocType 'Raven User' +#: frontend/src/components/feature/userSettings/UserProfile/UserProfile.tsx:160 +#: raven/raven/doctype/raven_user/raven_user.json +msgid "Custom Status" +msgstr "" + +#. Option for the 'Send to' (Select) field in DocType 'Raven Scheduler Event' +#. Label of the dm (Link) field in DocType 'Raven Scheduler Event' +#. Option for the 'Channel Type' (Select) field in DocType 'Raven Webhook' +#: raven/raven_integrations/doctype/raven_scheduler_event/raven_scheduler_event.json +#: raven/raven_integrations/doctype/raven_webhook/raven_webhook.json +msgid "DM" +msgstr "" + +#. Option for the 'Event Frequency' (Select) field in DocType 'Raven Scheduler +#. Event' +#: raven/raven_integrations/doctype/raven_scheduler_event/raven_scheduler_event.json +msgid "Date of the month" +msgstr "" + +#. Label of the debug_mode (Check) field in DocType 'Raven Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "Debug Mode" +msgstr "" + +#. Label of the default_value_type (Select) field in DocType 'Raven Message +#. Action Fields' +#: raven/raven_integrations/doctype/raven_message_action_fields/raven_message_action_fields.json +msgid "Default Value Type" +msgstr "" + +#. Label of the default_values_section (Section Break) field in DocType 'Raven +#. Message Action Fields' +#: raven/raven_integrations/doctype/raven_message_action_fields/raven_message_action_fields.json +msgid "Default Values" +msgstr "" + +#. Option for the 'Type' (Select) field in DocType 'Raven AI Function' +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json +msgid "Delete Document" +msgstr "" + +#. Option for the 'Type' (Select) field in DocType 'Raven AI Function' +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json +msgid "Delete Multiple Documents" +msgstr "" + +#. Label of the department_channel_type (Select) field in DocType 'Raven +#. Settings' +#: frontend/src/pages/settings/Integrations/FrappeHR.tsx:124 +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "Department Channel Type" +msgstr "" + +#. Label of the device_information (Data) field in DocType 'Raven Push Token' +#: raven/raven/doctype/raven_push_token/raven_push_token.json +msgid "Device Information" +msgstr "" + +#. Label of the dialog_properties_tab (Tab Break) field in DocType 'Raven +#. Message Action' +#: raven/raven_integrations/doctype/raven_message_action/raven_message_action.json +msgid "Dialog Properties" +msgstr "" + +#: raven/raven_messaging/doctype/raven_message/raven_message.py:166 +msgid "Direct modification of message_reactions is not allowed. Use the Reactions API." +msgstr "" + +#: frontend/src/components/feature/userSettings/PushNotifications/PushNotificationToggle.tsx:71 +msgid "Disable Notifications" +msgstr "" + +#: frontend/src/pages/settings/MobileApp.tsx:35 +msgid "Do more with Raven on your mobile." +msgstr "" + +#. Label of the do_not_ask_ai (Check) field in DocType 'Raven AI Function +#. Params' +#: raven/raven_ai/doctype/raven_ai_function_params/raven_ai_function_params.json +msgid "Do not ask AI to fill this variable" +msgstr "" + +#. Label of the do_not_attach_doc (Check) field in DocType 'Raven Document +#. Notification' +#: raven/raven_integrations/doctype/raven_document_notification/raven_document_notification.json +msgid "Do not attach document with message" +msgstr "" + +#. Option for the 'Availability Status' (Select) field in DocType 'Raven User' +#: frontend/src/components/feature/userSettings/AvailabilityStatus/SetUserAvailabilityMenu.tsx:68 +#: raven/raven/doctype/raven_user/raven_user.json +msgid "Do not disturb" +msgstr "" + +#. Label of a Card Break in the Raven Workspace +#: raven/raven/workspace/raven/raven.json +msgid "DocTypes" +msgstr "" + +#. Label of a Link in the Raven Workspace +#: raven/raven/workspace/raven/raven.json +msgid "Document Notifications" +msgstr "" + +#. Label of the document_parsing_tab (Tab Break) field in DocType 'Raven Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "Document Parsing" +msgstr "" + +#: raven/raven_integrations/doctype/raven_message_action/raven_message_action.py:44 +msgid "Document Type is required." +msgstr "" + +#: frontend/src/components/feature/userSettings/UploadImage/FileUploadBox.tsx:96 +msgid "Drag and drop your file here or" +msgstr "" + +#. Label of the dynamic_instructions (Check) field in DocType 'Raven Bot +#. Instruction Template' +#. Label of the dynamic_instructions (Check) field in DocType 'Raven Bot' +#: raven/raven_ai/doctype/raven_bot_instruction_template/raven_bot_instruction_template.json +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "Dynamic Instructions" +msgstr "" + +#. Description of the 'Dynamic Instructions' (Check) field in DocType 'Raven +#. Bot Instruction Template' +#. Description of the 'Dynamic Instructions' (Check) field in DocType 'Raven +#. Bot' +#: raven/raven_ai/doctype/raven_bot_instruction_template/raven_bot_instruction_template.json +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "Dynamic Instructions allow you to embed Jinja tags in your instruction to the bot. Hence the instruction would be different based on the user who is calling the bot or the data in your system. These instructions are computed every time the bot is called. Check this if you want to embed things like Employee ID, Company Name etc in your instructions dynamically" +msgstr "" + +#: frontend/src/components/feature/channel-details/edit-channel-description/EditChannelDescriptionModal.tsx:43 +msgid "Edit description" +msgstr "" + +#. Label of the emoji_name (Data) field in DocType 'Raven Custom Emoji' +#: raven/raven_messaging/doctype/raven_custom_emoji/raven_custom_emoji.json +msgid "Emoji Name" +msgstr "" + +#. Label of the enable_ai_integration (Check) field in DocType 'Raven Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "Enable AI Integration" +msgstr "" + +#. Label of the enable_code_interpreter (Check) field in DocType 'Raven Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "Enable Code Interpreter" +msgstr "" + +#. Label of the enable_file_search (Check) field in DocType 'Raven Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "Enable File Search" +msgstr "" + +#. Label of the enable_google_apis (Check) field in DocType 'Raven Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "Enable Google APIs" +msgstr "" + +#. Label of the enable_local_llm (Check) field in DocType 'Raven Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "Enable Local LLM" +msgstr "" + +#: frontend/src/components/feature/userSettings/PushNotifications/PushNotificationToggle.tsx:71 +msgid "Enable Notifications" +msgstr "" + +#. Label of the enable_openai_services (Check) field in DocType 'Raven +#. Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "Enable OpenAI Services" +msgstr "" + +#. Label of the enable_video_calling_via_livekit (Check) field in DocType +#. 'Raven Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "Enable Video Calling via LiveKit" +msgstr "" + +#. Description of the 'Enable File Search' (Check) field in DocType 'Raven Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "" +"Enable this if you want the bot to be able to read PDF files and scan them.\n" +"\n" +"File search enables the assistant with knowledge from files that you upload. Once a file is uploaded, the assistant automatically decides when to retrieve content based on user requests." +msgstr "" + +#: frontend/src/components/feature/userSettings/PushNotifications/PushNotificationToggle.tsx:59 +msgid "Enabling..." +msgstr "" + +#. Label of the environment (Select) field in DocType 'Raven Push Token' +#: raven/raven/doctype/raven_push_token/raven_push_token.json +msgid "Environment" +msgstr "" + +#: raven/raven_integrations/doctype/raven_document_notification/raven_document_notification.py:251 +#: raven/raven_integrations/doctype/raven_document_notification/raven_document_notification.py:262 +msgid "Error in Raven Notification" +msgstr "" + +#: raven/api/reactions.py:61 +msgid "Error reacting to message {0}" +msgstr "" + +#: raven/public/js/timeline_button.js:310 +msgid "Error sending message" +msgstr "" + +#: raven/raven_integrations/doctype/raven_document_notification/raven_document_notification.py:248 +msgid "Error while evaluating Raven Notification {0}. Please fix your template." +msgstr "" + +#. Option for the 'Event Frequency' (Select) field in DocType 'Raven Scheduler +#. Event' +#: raven/raven_integrations/doctype/raven_scheduler_event/raven_scheduler_event.json +msgid "Every Day" +msgstr "" + +#. Option for the 'Event Frequency' (Select) field in DocType 'Raven Scheduler +#. Event' +#: raven/raven_integrations/doctype/raven_scheduler_event/raven_scheduler_event.json +msgid "Every Day of the week" +msgstr "" + +#. Label of the fcm_token (Small Text) field in DocType 'Raven Push Token' +#: raven/raven/doctype/raven_push_token/raven_push_token.json +msgid "FCM Token" +msgstr "" + +#: raven/ai/google_ai.py:283 +msgid "Failed to create document processor" +msgstr "" + +#: raven/ai/google_ai.py:271 +msgid "Failed to create processor" +msgstr "" + +#: raven/ai/google_ai.py:338 +msgid "Failed to delete document processor" +msgstr "" + +#: raven/api/support_request.py:45 +msgid "Failed to submit the ticket" +msgstr "" + +#: raven/raven_integrations/doctype/raven_document_notification/raven_document_notification.py:95 +msgid "Field {0} does not exist in {1}." +msgstr "" + +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.py:123 +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.py:129 +#: raven/raven_integrations/doctype/raven_message_action/raven_message_action.py:75 +msgid "Field {0} not found in {1}" +msgstr "" + +#. Label of the file_sources (Table) field in DocType 'Raven Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "File Sources" +msgstr "" + +#. Label of the file_thumbnail (Attach) field in DocType 'Raven Message' +#: raven/raven_messaging/doctype/raven_message/raven_message.json +msgid "File Thumbnail" +msgstr "" + +#: raven/ai/functions.py:172 +msgid "File not found" +msgstr "" + +#: frontend/src/components/feature/userSettings/UploadImage/FileUploadBox.tsx:37 +msgid "File size is larger than the required size." +msgstr "" + +#. Description of the 'Model' (Data) field in DocType 'Raven Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "For OpenAI: gpt-4o, gpt-4, etc. For Local LLM: use model name from /v1/models endpoint" +msgstr "" + +#. Option for the 'Push Notification Service' (Select) field in DocType 'Raven +#. Settings' +#: frontend/src/pages/settings/PushNotifications.tsx:133 +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "Frappe Cloud" +msgstr "" + +#. Label of the frappe_hr_tab (Tab Break) field in DocType 'Raven Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "Frappe HR" +msgstr "" + +#. Label of the function_definition (JSON) field in DocType 'Raven AI Function' +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json +msgid "Function Definition" +msgstr "" + +#. Label of the function_name (Data) field in DocType 'Raven AI Function' +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json +msgid "Function Name" +msgstr "" + +#. Label of the function_path (Small Text) field in DocType 'Raven AI Function' +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json +msgid "Function Path" +msgstr "" + +#: raven/raven_integrations/doctype/raven_message_action/raven_message_action.py:47 +msgid "Function Path is required." +msgstr "" + +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.py:470 +msgid "Function name cannot be one of the core functions. Please choose a different name." +msgstr "" + +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.py:495 +msgid "Function not found" +msgstr "" + +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.py:445 +msgid "Function path is required for Custom Functions" +msgstr "" + +#: raven/api/message_actions.py:92 +msgid "Function {0} not found" +msgstr "" + +#: raven/raven_integrations/doctype/raven_message_action/raven_message_action.py:56 +msgid "Function {0} not found." +msgstr "" + +#. Label of a Link in the Raven Workspace +#: raven/raven/workspace/raven/raven.json +msgid "Functions" +msgstr "" + +#. Option for the 'Type' (Select) field in DocType 'Raven AI Function' +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json +msgid "Get Amended Document" +msgstr "" + +#. Option for the 'Type' (Select) field in DocType 'Raven AI Function' +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json +msgid "Get Document" +msgstr "" + +#. Option for the 'Type' (Select) field in DocType 'Raven AI Function' +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json +msgid "Get List" +msgstr "" + +#. Option for the 'Type' (Select) field in DocType 'Raven AI Function' +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json +msgid "Get Multiple Documents" +msgstr "" + +#. Option for the 'Type' (Select) field in DocType 'Raven AI Function' +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json +msgid "Get Report Result" +msgstr "" + +#. Option for the 'Type' (Select) field in DocType 'Raven AI Function' +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json +msgid "Get Value" +msgstr "" + +#. Label of an action in the Onboarding Step 'It's time. Send a Raven.' +#. Label of an action in the Onboarding Step 'Understanding Channels' +#: raven/raven/onboarding_step/access_the_web_app/access_the_web_app.json +#: raven/raven/onboarding_step/introduction_to_raven/introduction_to_raven.json +msgid "Go to Raven" +msgstr "" + +#: raven/ai/google_ai.py:245 raven/ai/google_ai.py:295 +msgid "Google APIs are not enabled. Please enable them in the Raven Settings." +msgstr "" + +#: raven/api/events.py:25 +msgid "Google Calendar not found for the current user" +msgstr "" + +#. Label of the google_document_processor_id (Data) field in DocType 'Raven +#. Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "Google Document Processor ID" +msgstr "" + +#. Label of the google_processor_location (Select) field in DocType 'Raven +#. Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "Google Processor Location" +msgstr "" + +#. Label of the google_project_id (Data) field in DocType 'Raven Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "Google Project ID" +msgstr "" + +#. Label of the google_service_account_json_key (Password) field in DocType +#. 'Raven Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "Google Service Account JSON Key" +msgstr "" + +#. Label of the google_vision_and_document_ai_section (Section Break) field in +#. DocType 'Raven Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "Google Vision and Document AI" +msgstr "" + +#: frontend/src/pages/settings/Integrations/FrappeHR.tsx:83 +msgid "HR" +msgstr "" + +#: frontend/src/pages/settings/Integrations/FrappeHR.tsx:93 +msgid "HR is not installed on this site." +msgstr "" + +#. Label of the helper_text (Data) field in DocType 'Raven Message Action +#. Fields' +#: raven/raven_integrations/doctype/raven_message_action_fields/raven_message_action_fields.json +msgid "Helper Text" +msgstr "" + +#. Label of the hide_link_preview (Check) field in DocType 'Raven Message' +#: raven/raven_messaging/doctype/raven_message/raven_message.json +msgid "Hide link preview" +msgstr "" + +#. Description of the 'Raven Bot' (Link) field in DocType 'Raven Bot AI Prompt' +#: raven/raven_ai/doctype/raven_bot_ai_prompt/raven_bot_ai_prompt.json +msgid "If added, this prompt will only be shown when interacting with the bot" +msgstr "" + +#: frontend/src/pages/settings/Integrations/FrappeHR.tsx:118 +msgid "If checked, a channel will be created for each department. Employees in the department will be synced with channel members." +msgstr "" + +#. Description of the 'Automatically Create a Channel for each Department' +#. (Check) field in DocType 'Raven Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "If checked, a channel will be created in Raven for each department and employees will be synced with Raven Users." +msgstr "" + +#. Description of the 'Pass parameters as JSON' (Check) field in DocType 'Raven +#. AI Function' +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json +msgid "If checked, the params will be passed as a JSON object instead of named parameters" +msgstr "" + +#. Description of the 'Is Global' (Check) field in DocType 'Raven Bot AI +#. Prompt' +#: raven/raven_ai/doctype/raven_bot_ai_prompt/raven_bot_ai_prompt.json +msgid "If checked, this prompt will be available to all users on Raven" +msgstr "" + +#: frontend/src/pages/settings/Integrations/FrappeHR.tsx:174 +msgid "If checked, users on Raven are notified if another user is on leave." +msgstr "" + +#. Description of the 'Debug Mode' (Check) field in DocType 'Raven Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "If enabled, stack traces of errors will be sent as messages by the bot " +msgstr "" + +#. Description of the 'Do not attach document with message' (Check) field in +#. DocType 'Raven Document Notification' +#: raven/raven_integrations/doctype/raven_document_notification/raven_document_notification.json +msgid "If enabled, the message won't have a document preview" +msgstr "" + +#. Description of the 'OpenAI Project ID' (Data) field in DocType 'Raven +#. Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "If not set, the integration will use the default project" +msgstr "" + +#. Description of the 'Only allow admins to create channels in the workspace' +#. (Check) field in DocType 'Raven Workspace' +#: raven/raven/doctype/raven_workspace/raven_workspace.json +msgid "If unchecked, any workspace member can create a channel" +msgstr "" + +#: frontend/src/components/feature/userSettings/UploadImage/ImageUploader.tsx:37 +msgid "Image uploaded successfully." +msgstr "" + +#. Label of the instruction (Long Text) field in DocType 'Raven Bot Instruction +#. Template' +#. Label of the instruction (Long Text) field in DocType 'Raven Bot' +#: raven/raven_ai/doctype/raven_bot_instruction_template/raven_bot_instruction_template.json +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "Instruction" +msgstr "" + +#. Label of a Link in the Raven Workspace +#: raven/raven/workspace/raven/raven.json +msgid "Instruction Templates" +msgstr "" + +#: raven/api/raven_users.py:21 raven/api/raven_users.py:50 +msgid "Insufficient permissions. Please contact your administrator." +msgstr "" + +#: raven/raven_integrations/doctype/raven_webhook/raven_webhook.py:219 +msgid "Invalid Channel Type" +msgstr "" + +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.py:451 +msgid "Invalid JSON in params" +msgstr "" + +#: raven/raven_messaging/doctype/raven_poll_vote/raven_poll_vote.py:37 +msgid "Invalid option selected." +msgstr "" + +#: raven/raven_integrations/doctype/raven_document_notification/raven_document_notification.py:147 +msgid "Invalid recipient variable type: {0}" +msgstr "" + +#. Option for the 'Availability Status' (Select) field in DocType 'Raven User' +#: frontend/src/components/feature/userSettings/AvailabilityStatus/SetUserAvailabilityMenu.tsx:70 +#: raven/raven/doctype/raven_user/raven_user.json +msgid "Invisible" +msgstr "" + +#. Label of the is_ai_bot (Check) field in DocType 'Raven Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "Is AI Bot?" +msgstr "" + +#. Label of the is_ai_thread (Check) field in DocType 'Raven Channel' +#: raven/raven_channel_management/doctype/raven_channel/raven_channel.json +msgid "Is AI Thread" +msgstr "" + +#. Label of the is_admin (Check) field in DocType 'Raven Workspace Member' +#. Label of the is_admin (Check) field in DocType 'Raven Channel Member' +#: raven/raven/doctype/raven_workspace_member/raven_workspace_member.json +#: raven/raven_channel_management/doctype/raven_channel_member/raven_channel_member.json +msgid "Is Admin" +msgstr "" + +#. Label of the is_anonymous (Check) field in DocType 'Raven Poll' +#: raven/raven_messaging/doctype/raven_poll/raven_poll.json +msgid "Is Anonymous" +msgstr "" + +#. Label of the is_archived (Check) field in DocType 'Raven Channel' +#: raven/raven_channel_management/doctype/raven_channel/raven_channel.json +msgid "Is Archived" +msgstr "" + +#. Label of the is_bot_message (Check) field in DocType 'Raven Message' +#: raven/raven_messaging/doctype/raven_message/raven_message.json +msgid "Is Bot Message" +msgstr "" + +#. Label of the is_dm_thread (Check) field in DocType 'Raven Channel' +#: raven/raven_channel_management/doctype/raven_channel/raven_channel.json +msgid "Is DM Thread" +msgstr "" + +#. Label of the is_direct_message (Check) field in DocType 'Raven Channel' +#: raven/raven_channel_management/doctype/raven_channel/raven_channel.json +msgid "Is Direct Message" +msgstr "" + +#. Label of the is_disabled (Check) field in DocType 'Raven Poll' +#: raven/raven_messaging/doctype/raven_poll/raven_poll.json +msgid "Is Disabled" +msgstr "" + +#. Label of the is_edited (Check) field in DocType 'Raven Message' +#: raven/raven_messaging/doctype/raven_message/raven_message.json +msgid "Is Edited" +msgstr "" + +#. Label of the is_forwarded (Check) field in DocType 'Raven Message' +#: raven/raven_messaging/doctype/raven_message/raven_message.json +msgid "Is Forwarded" +msgstr "" + +#. Label of the is_multi_choice (Check) field in DocType 'Raven Poll' +#: raven/raven_messaging/doctype/raven_poll/raven_poll.json +msgid "Is Multi Choice" +msgstr "" + +#. Label of the is_reply (Check) field in DocType 'Raven Message' +#: raven/raven_messaging/doctype/raven_message/raven_message.json +msgid "Is Reply" +msgstr "" + +#. Label of the is_required (Check) field in DocType 'Raven Message Action +#. Fields' +#: raven/raven_integrations/doctype/raven_message_action_fields/raven_message_action_fields.json +msgid "Is Required?" +msgstr "" + +#. Label of the is_self_message (Check) field in DocType 'Raven Channel' +#: raven/raven_channel_management/doctype/raven_channel/raven_channel.json +msgid "Is Self Message" +msgstr "" + +#. Label of the is_synced (Check) field in DocType 'Raven Channel' +#. Label of the is_synced (Check) field in DocType 'Raven Channel Member' +#: raven/raven_channel_management/doctype/raven_channel/raven_channel.json +#: raven/raven_channel_management/doctype/raven_channel_member/raven_channel_member.json +msgid "Is Synced" +msgstr "" + +#. Label of the is_thread (Check) field in DocType 'Raven Channel' +#. Label of the is_thread (Check) field in DocType 'Raven Message' +#: raven/raven_channel_management/doctype/raven_channel/raven_channel.json +#: raven/raven_messaging/doctype/raven_message/raven_message.json +msgid "Is Thread" +msgstr "" + +#. Title of an Onboarding Step +#: raven/raven/onboarding_step/access_the_web_app/access_the_web_app.json +msgid "It's time. Send a Raven." +msgstr "" + +#. Label of the keywords (Data) field in DocType 'Raven Custom Emoji' +#: raven/raven_messaging/doctype/raven_custom_emoji/raven_custom_emoji.json +msgid "Keywords" +msgstr "" + +#. Option for the 'Local LLM Provider' (Select) field in DocType 'Raven +#. Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "LM Studio" +msgstr "" + +#. Label of the last_mention_viewed_on (Datetime) field in DocType 'Raven User' +#: raven/raven/doctype/raven_user/raven_user.json +msgid "Last Mention Viewed On" +msgstr "" + +#. Label of the last_message_details (JSON) field in DocType 'Raven Channel' +#: raven/raven_channel_management/doctype/raven_channel/raven_channel.json +msgid "Last Message Details" +msgstr "" + +#. Label of the last_message_timestamp (Datetime) field in DocType 'Raven +#. Channel' +#: raven/raven_channel_management/doctype/raven_channel/raven_channel.json +msgid "Last Message Timestamp" +msgstr "" + +#. Label of the last_visit (Datetime) field in DocType 'Raven Channel Member' +#: raven/raven_channel_management/doctype/raven_channel_member/raven_channel_member.json +msgid "Last Visit" +msgstr "" + +#. Option for the 'Chat Style' (Select) field in DocType 'Raven User' +#: raven/raven/doctype/raven_user/raven_user.json +msgid "Left-Right" +msgstr "" + +#. Title of the Module Onboarding 'Raven' +#: raven/raven/module_onboarding/raven/raven.json +msgid "Let's setup Raven" +msgstr "" + +#. Label of the link_doctype (Link) field in DocType 'Raven Message' +#: raven/raven_messaging/doctype/raven_message/raven_message.json +msgid "Link Doctype" +msgstr "" + +#. Label of the link_document (Dynamic Link) field in DocType 'Raven Message' +#: raven/raven_messaging/doctype/raven_message/raven_message.json +msgid "Link Document" +msgstr "" + +#. Description of the 'Company' (Data) field in DocType 'Raven HR Company +#. Workspace' +#: raven/raven_integrations/doctype/raven_hr_company_workspace/raven_hr_company_workspace.json +msgid "Link to the company" +msgstr "" + +#. Label of the linked_doctype (Link) field in DocType 'Raven Channel' +#. Label of the linked_doctype (Link) field in DocType 'Raven Channel Member' +#: raven/raven_channel_management/doctype/raven_channel/raven_channel.json +#: raven/raven_channel_management/doctype/raven_channel_member/raven_channel_member.json +msgid "Linked DocType" +msgstr "" + +#. Label of the linked_document (Dynamic Link) field in DocType 'Raven Channel' +#. Label of the linked_document (Dynamic Link) field in DocType 'Raven Channel +#. Member' +#: raven/raven_channel_management/doctype/raven_channel/raven_channel.json +#: raven/raven_channel_management/doctype/raven_channel_member/raven_channel_member.json +msgid "Linked Document" +msgstr "" + +#: raven/raven_messaging/doctype/raven_message/raven_message.py:177 +msgid "Linked message should be in the same channel" +msgstr "" + +#. Description of the 'Notification' (Data) field in DocType 'Raven Message' +#: raven/raven_messaging/doctype/raven_message/raven_message.json +msgid "Linked to the notification that triggered this message" +msgstr "" + +#. Label of the livekit_api_key (Data) field in DocType 'Raven Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "LiveKit API Key" +msgstr "" + +#. Label of the livekit_api_secret (Password) field in DocType 'Raven Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "LiveKit API Secret" +msgstr "" + +#. Label of the livekit_settings_section (Section Break) field in DocType +#. 'Raven Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "LiveKit Settings" +msgstr "" + +#. Label of the livekit_url (Data) field in DocType 'Raven Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "LiveKit URL" +msgstr "" + +#. Option for the 'Model Provider' (Select) field in DocType 'Raven Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "Local LLM" +msgstr "" + +#. Label of the local_llm_api_url (Data) field in DocType 'Raven Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "Local LLM API URL" +msgstr "" + +#: raven/ai/agents_integration.py:58 +msgid "Local LLM API URL is not configured in Raven Settings" +msgstr "" + +#. Label of the local_llm_provider (Select) field in DocType 'Raven Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "Local LLM Provider" +msgstr "" + +#. Option for the 'Local LLM Provider' (Select) field in DocType 'Raven +#. Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "LocalAI" +msgstr "" + +#: frontend/src/components/layout/Sidebar/SidebarFooter.tsx:70 +msgid "Log Out" +msgstr "" + +#. Label of the logo (Attach Image) field in DocType 'Raven Workspace' +#: raven/raven/doctype/raven_workspace/raven_workspace.json +msgid "Logo" +msgstr "" + +#: frontend/src/components/feature/userSettings/UserProfile/UserProfile.tsx:83 +msgid "Manage your Raven profile" +msgstr "" + +#: frontend/src/components/feature/userSettings/UploadImage/FileUploadBox.tsx:109 +msgid "Maximum file size: {0}MB" +msgstr "" + +#. Label of a Link in the Raven Workspace +#: raven/raven/workspace/raven/raven.json +msgid "Message Actions" +msgstr "" + +#. Label of the message_content_tab (Tab Break) field in DocType 'Raven +#. Document Notification' +#: raven/raven_integrations/doctype/raven_document_notification/raven_document_notification.json +msgid "Message Content" +msgstr "" + +#. Option for the 'Webhook Trigger' (Select) field in DocType 'Raven Webhook' +#: raven/raven_integrations/doctype/raven_webhook/raven_webhook.json +msgid "Message Deleted" +msgstr "" + +#. Option for the 'Webhook Trigger' (Select) field in DocType 'Raven Webhook' +#: raven/raven_integrations/doctype/raven_webhook/raven_webhook.json +msgid "Message Edited" +msgstr "" + +#. Option for the 'Default Value Type' (Select) field in DocType 'Raven Message +#. Action Fields' +#: raven/raven_integrations/doctype/raven_message_action_fields/raven_message_action_fields.json +msgid "Message Field" +msgstr "" + +#. Option for the 'Webhook Trigger' (Select) field in DocType 'Raven Webhook' +#: raven/raven_integrations/doctype/raven_webhook/raven_webhook.json +msgid "Message Reacted On" +msgstr "" + +#: raven/raven_integrations/doctype/raven_webhook/raven_webhook.py:196 +msgid "Message Reaction cannot be triggered on Channel" +msgstr "" + +#. Label of a Link in the Raven Workspace +#. Label of the message_reactions (JSON) field in DocType 'Raven Message' +#: raven/raven/workspace/raven/raven.json +#: raven/raven_messaging/doctype/raven_message/raven_message.json +msgid "Message Reactions" +msgstr "" + +#: raven/api/raven_channel.py:235 +msgid "Message does not exist in this channel" +msgstr "" + +#: raven/public/js/timeline_button.js:304 +msgid "Message sent" +msgstr "" + +#: frontend/src/pages/settings/MobileApp.tsx:34 +msgid "Mobile App" +msgstr "" + +#. Label of the model (Data) field in DocType 'Raven Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "Model" +msgstr "" + +#. Label of the model_provider (Select) field in DocType 'Raven Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "Model Provider" +msgstr "" + +#: frontend/src/components/feature/userSettings/UserProfile/UserProfile.tsx:113 +msgid "Name cannot be more than {0} characters." +msgstr "" + +#: frontend/src/components/feature/userSettings/UserProfile/UserProfile.tsx:115 +msgid "Name is required" +msgstr "" + +#. Option for the 'Send Alert On' (Select) field in DocType 'Raven Document +#. Notification' +#: raven/raven_integrations/doctype/raven_document_notification/raven_document_notification.json +msgid "New Document" +msgstr "" + +#: raven/public/js/timeline_button.js:224 +msgid "No channels found" +msgstr "" + +#: frontend/src/components/feature/channels/ChannelList.tsx:67 +msgid "No channels in this workspace." +msgstr "" + +#. Label of the notification_name (Data) field in DocType 'Raven Document +#. Notification' +#: raven/raven_integrations/doctype/raven_document_notification/raven_document_notification.json +msgid "Notification Name" +msgstr "" + +#: raven/raven_integrations/doctype/raven_document_notification/raven_document_notification.py:66 +msgid "Notification for an event on {0} is not allowed." +msgstr "" + +#. Option for the 'Local LLM Provider' (Select) field in DocType 'Raven +#. Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "Ollama" +msgstr "" + +#. Label of the only_admins_can_create_channels (Check) field in DocType 'Raven +#. Workspace' +#: raven/raven/doctype/raven_workspace/raven_workspace.json +msgid "Only allow admins to create channels in the workspace" +msgstr "" + +#. Description of the 'Reasoning Effort' (Select) field in DocType 'Raven Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "Only applicable for OpenAI o-series models" +msgstr "" + +#: raven/api/raven_poll.py:200 +msgid "Only the poll owner can close the poll" +msgstr "" + +#: frontend/src/components/layout/Sidebar/SidebarHeader.tsx:62 +#: frontend/src/components/layout/Sidebar/SidebarHeader.tsx:83 +msgid "Open command menu" +msgstr "" + +#. Option for the 'Model Provider' (Select) field in DocType 'Raven Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "OpenAI" +msgstr "" + +#. Label of the openai_api_key (Password) field in DocType 'Raven Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "OpenAI API Key" +msgstr "" + +#: raven/ai/agents_integration.py:79 +msgid "OpenAI API key is not configured in Raven Settings" +msgstr "" + +#. Label of the openai_assistant_id (Data) field in DocType 'Raven Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "OpenAI Assistant ID" +msgstr "" + +#. Option for the 'Local LLM Provider' (Select) field in DocType 'Raven +#. Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "OpenAI Compatible" +msgstr "" + +#. Label of the openai_compatible_api_key (Password) field in DocType 'Raven +#. Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "OpenAI Compatible API Key" +msgstr "" + +#. Label of the openai_file_id (Data) field in DocType 'Raven AI File Source' +#: raven/raven_ai/doctype/raven_ai_file_source/raven_ai_file_source.json +msgid "OpenAI File ID" +msgstr "" + +#. Label of the openai_organisation_id (Data) field in DocType 'Raven Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "OpenAI Organisation ID" +msgstr "" + +#. Label of the openai_project_id (Data) field in DocType 'Raven Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "OpenAI Project ID" +msgstr "" + +#. Label of the openai_thread_id (Data) field in DocType 'Raven Channel' +#: raven/raven_channel_management/doctype/raven_channel/raven_channel.json +msgid "OpenAI Thread ID" +msgstr "" + +#. Label of the openai_vector_store_id (Data) field in DocType 'Raven Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "OpenAI Vector Store ID" +msgstr "" + +#. Label of the option (Small Text) field in DocType 'Raven Poll Option' +#. Label of the option (Data) field in DocType 'Raven Poll Vote' +#: raven/raven_messaging/doctype/raven_poll_option/raven_poll_option.json +#: raven/raven_messaging/doctype/raven_poll_vote/raven_poll_vote.json +msgid "Option" +msgstr "" + +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.py:139 +msgid "Option {0} is not valid for field {1} in {2}" +msgstr "" + +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.py:133 +msgid "Options are required for select fields" +msgstr "" + +#. Label of the parameters_section (Section Break) field in DocType 'Raven AI +#. Function' +#. Label of the parameters (Table) field in DocType 'Raven AI Function' +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json +msgid "Parameters" +msgstr "" + +#. Label of the params (JSON) field in DocType 'Raven AI Function' +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json +msgid "Params" +msgstr "" + +#. Label of the pass_parameters_as_json (Check) field in DocType 'Raven AI +#. Function' +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json +msgid "Pass parameters as JSON" +msgstr "" + +#: frontend/src/components/feature/channels/ChannelList.tsx:162 +msgid "Pin" +msgstr "" + +#: frontend/src/components/layout/Sidebar/PinnedChannels.tsx:45 +msgid "Pinned" +msgstr "" + +#. Label of the pinned_channels_section (Section Break) field in DocType 'Raven +#. User' +#: raven/raven/doctype/raven_user/raven_user.json +msgid "Pinned Channels" +msgstr "" + +#. Label of the pinned_messages (Table) field in DocType 'Raven Channel' +#: raven/raven_channel_management/doctype/raven_channel/raven_channel.json +msgid "Pinned Messages" +msgstr "" + +#. Label of the pinned_messages_string (Small Text) field in DocType 'Raven +#. Channel' +#: raven/raven_channel_management/doctype/raven_channel/raven_channel.json +msgid "Pinned Messages String" +msgstr "" + +#: frontend/src/components/feature/channels/CreateChannelModal.tsx:188 +msgid "Please add a channel name" +msgstr "" + +#: raven/raven/doctype/raven_settings/raven_settings.py:81 +msgid "Please add the Google Service Account JSON Key" +msgstr "" + +#: raven/raven/doctype/raven_settings/raven_settings.py:79 +msgid "Please enter the Google Project ID" +msgstr "" + +#: raven/raven/doctype/raven_settings/raven_settings.py:68 +msgid "Please enter the Push Notification API Key" +msgstr "" + +#: raven/raven/doctype/raven_settings/raven_settings.py:70 +msgid "Please enter the Push Notification API Secret" +msgstr "" + +#: raven/raven/doctype/raven_settings/raven_settings.py:66 +msgid "Please enter the Push Notification Server URL" +msgstr "" + +#: raven/raven/doctype/raven_settings/raven_settings.py:57 +msgid "Please map the companies to the workspace before enabling this feature." +msgstr "" + +#: raven/raven_bot/doctype/raven_bot/raven_bot.py:59 +msgid "Please provide an instruction for this AI Agent." +msgstr "" + +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.py:96 +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.py:489 +msgid "Please select a DocType for this function." +msgstr "" + +#: raven/raven_bot/doctype/raven_bot/raven_bot.py:62 +msgid "Please select a Document Processor for this bot." +msgstr "" + +#: raven/raven/doctype/raven_settings/raven_settings.py:83 +msgid "Please select the Google Processor Location" +msgstr "" + +#. Option for the 'Message Type' (Select) field in DocType 'Raven Message' +#. Label of the section_break_poll (Section Break) field in DocType 'Raven Poll +#. Vote' +#. Label of the poll_id (Link) field in DocType 'Raven Poll Vote' +#: raven/raven_messaging/doctype/raven_message/raven_message.json +#: raven/raven_messaging/doctype/raven_poll_vote/raven_poll_vote.json +msgid "Poll" +msgstr "" + +#. Label of the poll_id (Link) field in DocType 'Raven Message' +#: raven/raven_messaging/doctype/raven_message/raven_message.json +msgid "Poll ID" +msgstr "" + +#: raven/raven_messaging/doctype/raven_message/raven_message.py:184 +msgid "Poll ID is mandatory for a poll message" +msgstr "" + +#. Label of the section_break_poll_option (Section Break) field in DocType +#. 'Raven Poll Option' +#: raven/raven_messaging/doctype/raven_poll_option/raven_poll_option.json +msgid "Poll Option" +msgstr "" + +#. Label of the section_break_poll_options (Section Break) field in DocType +#. 'Raven Poll' +#: raven/raven_messaging/doctype/raven_poll/raven_poll.json +msgid "Poll Options" +msgstr "" + +#. Label of the section_break_poll_question (Section Break) field in DocType +#. 'Raven Poll' +#: raven/raven_messaging/doctype/raven_poll/raven_poll.json +msgid "Poll Question" +msgstr "" + +#. Label of the section_break_poll_settings (Section Break) field in DocType +#. 'Raven Poll' +#: raven/raven_messaging/doctype/raven_poll/raven_poll.json +msgid "Poll Settings" +msgstr "" + +#. Label of the section_break_poll_votes (Section Break) field in DocType +#. 'Raven Poll' +#: raven/raven_messaging/doctype/raven_poll/raven_poll.json +msgid "Poll Votes" +msgstr "" + +#: frontend/src/pages/settings/Preferences.tsx:25 +msgid "Preferences" +msgstr "" + +#: frontend/src/components/feature/userSettings/UserProfile/UserProfile.tsx:65 +msgid "Profile update failed" +msgstr "" + +#: frontend/src/components/feature/userSettings/UserProfile/UserProfile.tsx:62 +msgid "Profile updated" +msgstr "" + +#. Label of the prompt (Small Text) field in DocType 'Raven Bot AI Prompt' +#: raven/raven_ai/doctype/raven_bot_ai_prompt/raven_bot_ai_prompt.json +msgid "Prompt" +msgstr "" + +#. Label of the push_notification_api_key (Data) field in DocType 'Raven +#. Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "Push Notification API Key" +msgstr "" + +#. Label of the push_notification_api_secret (Password) field in DocType 'Raven +#. Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "Push Notification API Secret" +msgstr "" + +#. Label of the push_notification_server_url (Data) field in DocType 'Raven +#. Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "Push Notification Server URL" +msgstr "" + +#. Label of the push_notification_service (Select) field in DocType 'Raven +#. Settings' +#: frontend/src/pages/settings/PushNotifications.tsx:110 +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "Push Notification Service" +msgstr "" + +#: raven/api/notification.py:52 +msgid "Push notification service is not set to Raven Cloud." +msgstr "" + +#: raven/api/notification.py:74 +msgid "Push notifications are not supported in the current framework version" +msgstr "" + +#: frontend/src/components/feature/userSettings/PushNotifications/PushNotificationToggle.tsx:27 +msgid "Push notifications disabled" +msgstr "" + +#: frontend/src/components/feature/userSettings/PushNotifications/PushNotificationToggle.tsx:58 +msgid "Push notifications enabled" +msgstr "" + +#. Label of the question (Small Text) field in DocType 'Raven Poll' +#: raven/raven_messaging/doctype/raven_poll/raven_poll.json +msgid "Question" +msgstr "" + +#. Option for the 'Push Notification Service' (Select) field in DocType 'Raven +#. Settings' +#. Name of a Workspace +#: raven/raven/doctype/raven_settings/raven_settings.json +#: raven/raven/workspace/raven/raven.json +msgid "Raven" +msgstr "" + +#. Name of a DocType +#: raven/raven_ai/doctype/raven_ai_bot_files/raven_ai_bot_files.json +msgid "Raven AI Bot Files" +msgstr "" + +#. Name of a DocType +#: raven/raven_ai/doctype/raven_ai_file_source/raven_ai_file_source.json +msgid "Raven AI File Source" +msgstr "" + +#. Name of a DocType +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json +msgid "Raven AI Function" +msgstr "" + +#. Name of a DocType +#: raven/raven_ai/doctype/raven_ai_function_params/raven_ai_function_params.json +msgid "Raven AI Function Params" +msgstr "" + +#. Name of a role +#: raven/raven/doctype/raven_settings/raven_settings.json +#: raven/raven/doctype/raven_workspace/raven_workspace.json +#: raven/raven/doctype/raven_workspace_member/raven_workspace_member.json +#: raven/raven_ai/doctype/raven_ai_file_source/raven_ai_file_source.json +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json +#: raven/raven_ai/doctype/raven_bot_ai_prompt/raven_bot_ai_prompt.json +#: raven/raven_ai/doctype/raven_bot_instruction_template/raven_bot_instruction_template.json +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +#: raven/raven_integrations/doctype/raven_document_notification/raven_document_notification.json +#: raven/raven_integrations/doctype/raven_incoming_webhook/raven_incoming_webhook.json +#: raven/raven_integrations/doctype/raven_message_action/raven_message_action.json +#: raven/raven_messaging/doctype/raven_custom_emoji/raven_custom_emoji.json +msgid "Raven Admin" +msgstr "" + +#. Label of the raven_bot (Link) field in DocType 'Raven Bot AI Prompt' +#. Name of a DocType +#: raven/raven_ai/doctype/raven_bot_ai_prompt/raven_bot_ai_prompt.json +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "Raven Bot" +msgstr "" + +#. Name of a DocType +#: raven/raven_ai/doctype/raven_bot_ai_prompt/raven_bot_ai_prompt.json +msgid "Raven Bot AI Prompt" +msgstr "" + +#. Name of a DocType +#: raven/raven_ai/doctype/raven_bot_functions/raven_bot_functions.json +msgid "Raven Bot Functions" +msgstr "" + +#. Name of a DocType +#: raven/raven_ai/doctype/raven_bot_instruction_template/raven_bot_instruction_template.json +msgid "Raven Bot Instruction Template" +msgstr "" + +#. Name of a DocType +#: raven/raven_channel_management/doctype/raven_channel/raven_channel.json +msgid "Raven Channel" +msgstr "" + +#. Name of a DocType +#: raven/raven_channel_management/doctype/raven_channel_member/raven_channel_member.json +msgid "Raven Channel Member" +msgstr "" + +#: frontend/src/pages/settings/PushNotifications.tsx:130 +msgid "Raven Cloud" +msgstr "" + +#. Name of a DocType +#: raven/raven_messaging/doctype/raven_custom_emoji/raven_custom_emoji.json +msgid "Raven Custom Emoji" +msgstr "" + +#. Name of a DocType +#: raven/raven_integrations/doctype/raven_document_notification/raven_document_notification.json +msgid "Raven Document Notification" +msgstr "" + +#. Name of a DocType +#: raven/raven_integrations/doctype/raven_document_notification_recipients/raven_document_notification_recipients.json +msgid "Raven Document Notification Recipients" +msgstr "" + +#. Name of a DocType +#: raven/raven_integrations/doctype/raven_hr_company_workspace/raven_hr_company_workspace.json +msgid "Raven HR Company Workspace" +msgstr "" + +#. Name of a DocType +#: raven/raven_integrations/doctype/raven_incoming_webhook/raven_incoming_webhook.json +msgid "Raven Incoming Webhook" +msgstr "" + +#. Name of a DocType +#: raven/raven_messaging/doctype/raven_mention/raven_mention.json +msgid "Raven Mention" +msgstr "" + +#. Name of a DocType +#: raven/raven_messaging/doctype/raven_message/raven_message.json +msgid "Raven Message" +msgstr "" + +#. Name of a DocType +#: raven/raven_integrations/doctype/raven_message_action/raven_message_action.json +msgid "Raven Message Action" +msgstr "" + +#. Name of a DocType +#: raven/raven_integrations/doctype/raven_message_action_fields/raven_message_action_fields.json +msgid "Raven Message Action Fields" +msgstr "" + +#. Name of a DocType +#: raven/raven_messaging/doctype/raven_message_reaction/raven_message_reaction.json +msgid "Raven Message Reaction" +msgstr "" + +#. Label of the raven_mobile_tab (Tab Break) field in DocType 'Raven Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "Raven Mobile" +msgstr "" + +#. Name of a DocType +#: raven/raven/doctype/raven_pinned_channels/raven_pinned_channels.json +msgid "Raven Pinned Channels" +msgstr "" + +#. Name of a DocType +#: raven/raven/doctype/raven_pinned_messages/raven_pinned_messages.json +msgid "Raven Pinned Messages" +msgstr "" + +#. Name of a DocType +#: raven/raven_messaging/doctype/raven_poll/raven_poll.json +msgid "Raven Poll" +msgstr "" + +#. Name of a DocType +#: raven/raven_messaging/doctype/raven_poll_option/raven_poll_option.json +msgid "Raven Poll Option" +msgstr "" + +#. Name of a DocType +#: raven/raven_messaging/doctype/raven_poll_vote/raven_poll_vote.json +msgid "Raven Poll Vote" +msgstr "" + +#. Name of a DocType +#: raven/raven/doctype/raven_push_token/raven_push_token.json +msgid "Raven Push Token" +msgstr "" + +#. Name of a DocType +#: raven/raven_integrations/doctype/raven_scheduler_event/raven_scheduler_event.json +msgid "Raven Scheduler Event" +msgstr "" + +#. Name of a DocType +#. Label of a Link in the Raven Workspace +#: raven/raven/doctype/raven_settings/raven_settings.json +#: raven/raven/workspace/raven/raven.json +msgid "Raven Settings" +msgstr "" + +#. Name of a role +#. Name of a DocType +#. Label of the raven_user (Link) field in DocType 'Raven Bot' +#: raven/raven/doctype/raven_push_token/raven_push_token.json +#: raven/raven/doctype/raven_user/raven_user.json +#: raven/raven/doctype/raven_workspace/raven_workspace.json +#: raven/raven/doctype/raven_workspace_member/raven_workspace_member.json +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +#: raven/raven_channel_management/doctype/raven_channel/raven_channel.json +#: raven/raven_channel_management/doctype/raven_channel_member/raven_channel_member.json +#: raven/raven_integrations/doctype/raven_message_action/raven_message_action.json +#: raven/raven_messaging/doctype/raven_custom_emoji/raven_custom_emoji.json +#: raven/raven_messaging/doctype/raven_message/raven_message.json +#: raven/raven_messaging/doctype/raven_message_reaction/raven_message_reaction.json +#: raven/raven_messaging/doctype/raven_poll/raven_poll.json +#: raven/raven_messaging/doctype/raven_poll_vote/raven_poll_vote.json +msgid "Raven User" +msgstr "" + +#: raven/raven_integrations/doctype/raven_webhook/raven_webhook.py:198 +msgid "Raven User cannot be triggered on Channel" +msgstr "" + +#. Label of a Link in the Raven Workspace +#: raven/raven/workspace/raven/raven.json +msgid "Raven Users" +msgstr "" + +#. Name of a DocType +#: raven/raven_integrations/doctype/raven_webhook/raven_webhook.json +msgid "Raven Webhook" +msgstr "" + +#. Name of a DocType +#. Label of the raven_workspace (Link) field in DocType 'Raven HR Company +#. Workspace' +#: raven/raven/doctype/raven_workspace/raven_workspace.json +#: raven/raven_integrations/doctype/raven_hr_company_workspace/raven_hr_company_workspace.json +msgid "Raven Workspace" +msgstr "" + +#. Name of a DocType +#: raven/raven/doctype/raven_workspace_member/raven_workspace_member.json +msgid "Raven Workspace Member" +msgstr "" + +#. Success message of the Module Onboarding 'Raven' +#: raven/raven/module_onboarding/raven/raven.json +msgid "Raven is setup!" +msgstr "" + +#. Label of the reaction (Data) field in DocType 'Raven Message Reaction' +#: raven/raven_messaging/doctype/raven_message_reaction/raven_message_reaction.json +msgid "Reaction" +msgstr "" + +#. Label of the reaction_escaped (Data) field in DocType 'Raven Message +#. Reaction' +#: raven/raven_messaging/doctype/raven_message_reaction/raven_message_reaction.json +msgid "Reaction Escaped" +msgstr "" + +#. Label of the reasoning_effort (Select) field in DocType 'Raven Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "Reasoning Effort" +msgstr "" + +#: frontend/src/components/feature/userSettings/UploadImage/DeleteImageModal.tsx:30 +msgid "Remove Image" +msgstr "" + +#: frontend/src/components/feature/channels/ChannelList.tsx:154 +msgid "Remove Pin" +msgstr "" + +#: frontend/src/components/feature/userSettings/UploadImage/DeleteImageModal.tsx:46 +msgid "Removing" +msgstr "" + +#. Label of the replied_message_details (JSON) field in DocType 'Raven Message' +#: raven/raven_messaging/doctype/raven_message/raven_message.json +msgid "Replied Message Details" +msgstr "" + +#. Label of the linked_message (Link) field in DocType 'Raven Message' +#: raven/raven_messaging/doctype/raven_message/raven_message.json +msgid "Replied Message ID" +msgstr "" + +#. Label of the required (Check) field in DocType 'Raven AI Function Params' +#: raven/raven_ai/doctype/raven_ai_function_params/raven_ai_function_params.json +msgid "Required" +msgstr "" + +#. Label of the requires_write_permissions (Check) field in DocType 'Raven AI +#. Function' +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json +msgid "Requires Write Permissions" +msgstr "" + +#. Title of an Onboarding Step +#: raven/raven/onboarding_step/review_raven_settings/review_raven_settings.json +msgid "Review Settings" +msgstr "" + +#. Label of a Link in the Raven Workspace +#: raven/raven/workspace/raven/raven.json +msgid "Saved Prompts" +msgstr "" + +#. Label of the scheduler_event_id (Link) field in DocType 'Raven Scheduler +#. Event' +#: raven/raven_integrations/doctype/raven_scheduler_event/raven_scheduler_event.json +msgid "Scheduler Event ID" +msgstr "" + +#. Option for the 'Channel Type' (Select) field in DocType 'Raven Webhook' +#: raven/raven_integrations/doctype/raven_webhook/raven_webhook.json +msgid "Self Message" +msgstr "" + +#. Option for the 'Type' (Select) field in DocType 'Raven AI Function' +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json +msgid "Send Message" +msgstr "" + +#: raven/public/js/timeline_button.js:167 +#: raven/public/js/timeline_button.js:222 +#: raven/public/js/timeline_button.js:322 +msgid "Send a Raven" +msgstr "" + +#. Label of the send_to (Select) field in DocType 'Raven Scheduler Event' +#: raven/raven_integrations/doctype/raven_scheduler_event/raven_scheduler_event.json +msgid "Send to" +msgstr "" + +#: raven/raven_integrations/doctype/raven_message_action/raven_message_action.py:50 +msgid "Server Script is required." +msgstr "" + +#: raven/raven_integrations/doctype/raven_message_action/raven_message_action.py:64 +msgid "Server Script must be of type API." +msgstr "" + +#: raven/api/message_actions.py:97 +msgid "Server Script {0} is disabled" +msgstr "" + +#: raven/raven_integrations/doctype/raven_message_action/raven_message_action.py:66 +msgid "Server Script {0} is disabled." +msgstr "" + +#: frontend/src/components/feature/userSettings/UserProfile/UserProfile.tsx:133 +msgid "Set Availability" +msgstr "" + +#: frontend/src/components/feature/userSettings/CustomStatus/SetCustomStatusContent.tsx:46 +msgid "Set a custom status" +msgstr "" + +#: frontend/src/components/layout/Sidebar/SidebarFooter.tsx:65 +msgid "Set custom status" +msgstr "" + +#: frontend/src/pages/settings/Integrations/FrappeHR.tsx:55 +msgid "Settings updated" +msgstr "" + +#: frontend/src/components/feature/userSettings/UserProfile/UserProfile.tsx:161 +msgid "Share what you are up to" +msgstr "" + +#: frontend/src/components/feature/userSettings/CustomStatus/SetCustomStatusContent.tsx:53 +msgid "Share what you're up to" +msgstr "" + +#. Label of the show_raven_on_desk (Check) field in DocType 'Raven Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "Show Raven on Desk" +msgstr "" + +#. Label of the show_if_a_user_is_on_leave (Check) field in DocType 'Raven +#. Settings' +#: frontend/src/pages/settings/Integrations/FrappeHR.tsx:170 +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "Show if a user is on leave" +msgstr "" + +#. Description of the 'Title' (Data) field in DocType 'Raven Message Action' +#: raven/raven_integrations/doctype/raven_message_action/raven_message_action.json +msgid "Shown on the dialog" +msgstr "" + +#. Option for the 'Chat Style' (Select) field in DocType 'Raven User' +#: raven/raven/doctype/raven_user/raven_user.json +msgid "Simple" +msgstr "" + +#. Subtitle of the Module Onboarding 'Raven' +#: raven/raven/module_onboarding/raven/raven.json +msgid "Simple, work messaging tool" +msgstr "" + +#. Option for the 'Variable Type' (Select) field in DocType 'Raven Document +#. Notification Recipients' +#. Option for the 'Default Value Type' (Select) field in DocType 'Raven Message +#. Action Fields' +#: raven/raven_integrations/doctype/raven_document_notification_recipients/raven_document_notification_recipients.json +#: raven/raven_integrations/doctype/raven_message_action_fields/raven_message_action_fields.json +msgid "Static" +msgstr "" + +#: frontend/src/components/feature/userSettings/CustomStatus/SetCustomStatusContent.tsx:64 +msgid "Status cannot be more than {0} characters." +msgstr "" + +#: frontend/src/components/feature/userSettings/UserProfile/UserProfile.tsx:172 +msgid "Status cannot be more than {} characters." +msgstr "" + +#. Label of the strict (Check) field in DocType 'Raven AI Function' +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json +msgid "Strict" +msgstr "" + +#. Option for the 'Type' (Select) field in DocType 'Raven AI Function' +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json +msgid "Submit Document" +msgstr "" + +#: frontend/src/components/feature/userSettings/UploadImage/FileUploadBox.tsx:81 +msgctxt ".jpg" +msgid "Supported formats: {0}, {1}, {2}" +msgstr "" + +#. Label of the temperature (Float) field in DocType 'Raven Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "Temperature" +msgstr "" + +#. Label of the template_name (Data) field in DocType 'Raven Bot Instruction +#. Template' +#: raven/raven_ai/doctype/raven_bot_instruction_template/raven_bot_instruction_template.json +msgid "Template Name" +msgstr "" + +#. Label of the tenor_api_key (Data) field in DocType 'Raven Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "Tenor API Key" +msgstr "" + +#: raven/raven_bot/doctype/raven_bot/raven_bot.py:239 +msgid "The code interpreter tool is not available for the model {0}, hence it has been disabled." +msgstr "" + +#: raven/raven_bot/doctype/raven_bot/raven_bot.py:247 +msgid "The file search tool is not available for the model {0}, hence it has been disabled." +msgstr "" + +#: frontend/src/components/feature/userSettings/PushNotifications/PushNotificationToggle.tsx:30 +msgid "There was an error" +msgstr "" + +#: raven/raven_integrations/doctype/raven_document_notification/raven_document_notification.py:56 +msgid "There was an error while evaluating the condition. Please fix your template." +msgstr "" + +#: frontend/src/pages/settings/Integrations/FrappeHR.tsx:57 +msgid "There was an error." +msgstr "" + +#. Description of the 'Bot' (Link) field in DocType 'Raven Scheduler Event' +#: raven/raven_integrations/doctype/raven_scheduler_event/raven_scheduler_event.json +msgid "This Bot will be used to send the message." +msgstr "" + +#: frontend/src/components/feature/channel-details/edit-channel-description/EditChannelDescriptionModal.tsx:55 +msgid "This is how people will know what this channel is about." +msgstr "" + +#. Description of the 'Is Thread' (Check) field in DocType 'Raven Message' +#: raven/raven_messaging/doctype/raven_message/raven_message.json +msgid "This message starts a thread" +msgstr "" + +#: raven/www/raven.py:84 +msgid "This method is only meant for developer mode" +msgstr "" + +#: raven/api/raven_poll.py:204 +msgid "This poll is already closed" +msgstr "" + +#: raven/api/raven_poll.py:158 +msgid "This poll is anonymous. You do not have permission to access the votes." +msgstr "" + +#: raven/api/raven_poll.py:100 +msgid "This poll is closed and no longer accepting votes" +msgstr "" + +#: raven/api/raven_poll.py:134 +msgid "This poll is closed and you can no longer retract your vote" +msgstr "" + +#: raven/raven_messaging/doctype/raven_poll_vote/raven_poll_vote.py:27 +msgid "This poll is closed." +msgstr "" + +#. Label of the thread_bot (Link) field in DocType 'Raven Channel' +#: raven/raven_channel_management/doctype/raven_channel/raven_channel.json +msgid "Thread Bot" +msgstr "" + +#. Label of the thumbnail_height (Data) field in DocType 'Raven Message' +#: raven/raven_messaging/doctype/raven_message/raven_message.json +msgid "Thumbnail Height" +msgstr "" + +#. Label of the thumbnail_width (Data) field in DocType 'Raven Message' +#: raven/raven_messaging/doctype/raven_message/raven_message.json +msgid "Thumbnail Width" +msgstr "" + +#: frontend/src/components/layout/Sidebar/SidebarHeader.tsx:112 +msgid "Toggle theme" +msgstr "" + +#. Label of the top_p (Float) field in DocType 'Raven Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "Top P" +msgstr "" + +#. Label of the total_votes (Int) field in DocType 'Raven Poll' +#: raven/raven_messaging/doctype/raven_poll/raven_poll.json +msgid "Total Votes" +msgstr "" + +#. Label of the trigger_webhook_on_condition (Check) field in DocType 'Raven +#. Webhook' +#: raven/raven_integrations/doctype/raven_webhook/raven_webhook.json +msgid "Trigger Webhook on Condition" +msgstr "" + +#: frontend/src/components/feature/userSettings/UploadImage/FileUploadBox.tsx:34 +msgid "Uh Oh! {0} exceeded the maximum file size required." +msgstr "" + +#. Title of an Onboarding Step +#: raven/raven/onboarding_step/introduction_to_raven/introduction_to_raven.json +msgid "Understanding Channels" +msgstr "" + +#. Option for the 'Type' (Select) field in DocType 'Raven AI Function' +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json +msgid "Update Document" +msgstr "" + +#. Option for the 'Type' (Select) field in DocType 'Raven AI Function' +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json +msgid "Update Multiple Documents" +msgstr "" + +#: frontend/src/components/feature/userSettings/AvailabilityStatus/SetUserAvailabilityMenu.tsx:22 +msgid "Updated!" +msgstr "" + +#: frontend/src/components/feature/userSettings/UploadImage/UploadImageModal.tsx:68 +msgid "Uploading" +msgstr "" + +#. Label of the use_google_document_parser (Check) field in DocType 'Raven Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "Use Google Document/Vision AI to parse documents" +msgstr "" + +#. Description of the 'Enable Google APIs' (Check) field in DocType 'Raven +#. Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "Useful for extracting information from documents before sending it to agents" +msgstr "" + +#. Option for the 'Webhook Trigger' (Select) field in DocType 'Raven Webhook' +#: raven/raven_integrations/doctype/raven_webhook/raven_webhook.json +msgid "User Added" +msgstr "" + +#. Option for the 'Webhook Trigger' (Select) field in DocType 'Raven Webhook' +#: raven/raven_integrations/doctype/raven_webhook/raven_webhook.json +msgid "User Deleted" +msgstr "" + +#. Label of the user_status_section (Section Break) field in DocType 'Raven +#. User' +#: raven/raven/doctype/raven_user/raven_user.json +msgid "User Status" +msgstr "" + +#: raven/api/raven_users.py:141 raven/api/raven_users.py:144 +msgid "User has a role profile set. Please set the role to Raven User manually." +msgstr "" + +#: raven/raven/doctype/raven_workspace_member/raven_workspace_member.py:32 +msgid "User is already a member of the workspace" +msgstr "" + +#: raven/raven/doctype/raven_user/raven_user.py:51 +msgid "User is mandatory" +msgstr "" + +#: raven/api/raven_channel_member.py:15 +msgid "User is not a member of this channel" +msgstr "" + +#: frontend/src/components/feature/userSettings/CustomStatus/SetCustomStatusContent.tsx:32 +msgid "User status updated" +msgstr "" + +#: raven/raven_integrations/doctype/raven_document_notification/raven_document_notification.py:74 +msgid "User {0} does not exist in Raven." +msgstr "" + +#. Label of the vapid_public_key (Data) field in DocType 'Raven Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "VAPID Public Key" +msgstr "" + +#. Label of the variable_type (Select) field in DocType 'Raven Document +#. Notification Recipients' +#: raven/raven_integrations/doctype/raven_document_notification_recipients/raven_document_notification_recipients.json +msgid "Variable Type" +msgstr "" + +#. Label of the video_calling_tab (Tab Break) field in DocType 'Raven Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "Video Calling" +msgstr "" + +#. Label of an action in the Onboarding Step 'Review Settings' +#: raven/raven/onboarding_step/review_raven_settings/review_raven_settings.json +msgid "View Raven Settings" +msgstr "" + +#: frontend/src/components/layout/Sidebar/MentionsButton.tsx:42 +msgid "View mentions" +msgstr "" + +#. Label of the section_break_vote (Section Break) field in DocType 'Raven Poll +#. Vote' +#: raven/raven_messaging/doctype/raven_poll_vote/raven_poll_vote.json +msgid "Vote" +msgstr "" + +#. Label of the votes (Int) field in DocType 'Raven Poll Option' +#: raven/raven_messaging/doctype/raven_poll_option/raven_poll_option.json +msgid "Votes" +msgstr "" + +#. Option for the 'Environment' (Select) field in DocType 'Raven Push Token' +#: raven/raven/doctype/raven_push_token/raven_push_token.json +msgid "Web" +msgstr "" + +#: raven/raven_integrations/doctype/raven_webhook/raven_webhook.py:61 +msgid "Webhook Data keys should be unique" +msgstr "" + +#: raven/raven_integrations/doctype/raven_webhook/raven_webhook.py:63 +msgid "Webhook Headers keys should be unique" +msgstr "" + +#: raven/raven_integrations/doctype/raven_webhook/raven_webhook.py:53 +msgid "Webhook name already exists" +msgstr "" + +#. Description of the 'Temperature' (Float) field in DocType 'Raven Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic." +msgstr "" + +#: frontend/src/components/feature/channels/CreateChannelModal.tsx:152 +msgid "When a channel is set to open, everyone is a member." +msgstr "" + +#: frontend/src/components/feature/channels/CreateChannelModal.tsx:146 +msgid "When a channel is set to private, it can only be viewed or joined by invitation." +msgstr "" + +#: frontend/src/components/feature/channels/CreateChannelModal.tsx:158 +msgid "When a channel is set to public, anyone can join the channel and read messages, but only members can post messages." +msgstr "" + +#: frontend/src/components/feature/workspaces/AddWorkspaceForm.tsx:189 +#: frontend/src/components/feature/workspaces/WorkspaceEditForm.tsx:63 +msgid "When a workspace is set to private, it can only be viewed or joined by invitation.\\nWhen a workspace is set to public, anyone can join the workspace and view it's channels." +msgstr "" + +#. Description of the 'Use Google Document/Vision AI to parse documents' +#. (Check) field in DocType 'Raven Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "When images or PDFs are uploaded to the agent, Raven will automatically call Google Cloud APIs to parse the document and send it's results to the agent." +msgstr "" + +#. Label of a Link in the Raven Workspace +#: raven/raven/workspace/raven/raven.json +msgid "Workspace Members" +msgstr "" + +#. Label of the workspace_name (Data) field in DocType 'Raven Workspace' +#: raven/raven/doctype/raven_workspace/raven_workspace.json +msgid "Workspace Name" +msgstr "" + +#: frontend/src/components/feature/hr/CompanyWorkspaceMapping.tsx:102 +msgid "Workspace is required" +msgstr "" + +#: raven/raven_channel_management/doctype/raven_channel_member/raven_channel_member.py:67 +msgid "You are already a member of this channel" +msgstr "" + +#: raven/api/raven_channel.py:227 +msgid "You are not a member of this channel" +msgstr "" + +#: raven/api/workspaces.py:67 +msgid "You are not a member of this workspace." +msgstr "" + +#: raven/api/workspaces.py:145 +msgid "You are not an admin of this workspace." +msgstr "" + +#: raven/raven_messaging/doctype/raven_poll_vote/raven_poll_vote.py:53 +msgid "You can only vote for yourself." +msgstr "" + +#. Description of the 'Instruction' (Long Text) field in DocType 'Raven Bot +#. Instruction Template' +#. Description of the 'Instruction' (Long Text) field in DocType 'Raven Bot' +#: raven/raven_ai/doctype/raven_bot_instruction_template/raven_bot_instruction_template.json +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "You can use Jinja variables here to customize the instruction to the bot at run time if dynamic instructions are enabled." +msgstr "" + +#: raven/raven_channel_management/doctype/raven_channel/raven_channel.py:149 +msgid "You cannot change the name of a direct message channel" +msgstr "" + +#: raven/raven/doctype/raven_workspace_member/raven_workspace_member.py:47 +#: raven/raven/doctype/raven_workspace_member/raven_workspace_member.py:73 +msgid "You cannot delete the last admin of the workspace. Please assign another user as the admin, or delete the workspace instead." +msgstr "" + +#: raven/api/raven_users.py:18 raven/api/raven_users.py:47 +msgid "You do not have a Raven User role. Please contact your administrator to add your user profile as a Raven User." +msgstr "" + +#: raven/api/workspaces.py:14 +msgid "You do not have access to Raven." +msgstr "" + +#: raven/api/chat_stream.py:19 raven/api/chat_stream.py:123 +#: raven/api/chat_stream.py:209 raven/api/raven_poll.py:19 +msgid "You do not have permission to access this channel" +msgstr "" + +#: raven/api/raven_poll.py:68 raven/api/raven_poll.py:92 +msgid "You do not have permission to access this message" +msgstr "" + +#: raven/api/raven_poll.py:152 +msgid "You do not have permission to access this poll" +msgstr "" + +#: raven/api/reactions.py:19 +msgid "You do not have permission to react to this message" +msgstr "" + +#: raven/raven_channel_management/doctype/raven_channel/raven_channel.py:172 +msgid "You don't have permission to archive/unarchive this channel" +msgstr "" + +#: raven/raven_channel_management/doctype/raven_channel_member/raven_channel_member.py:48 +#: raven/raven_channel_management/doctype/raven_channel_member/raven_channel_member.py:55 +msgid "You don't have permission to assign admins to this channel. Please ask another admin to do this." +msgstr "" + +#: raven/raven_channel_management/doctype/raven_channel/raven_channel.py:189 +msgid "You don't have permission to modify this channel" +msgstr "" + +#: raven/api/raven_message.py:54 raven/api/raven_message.py:242 +msgid "You don't have permission to view this channel" +msgstr "" + +#: raven/raven_messaging/doctype/raven_poll_vote/raven_poll_vote.py:48 +msgid "You have already voted for this option." +msgstr "" + +#: raven/api/raven_poll.py:141 +msgid "You have not voted for any option in this poll." +msgstr "" + +#: frontend/src/pages/settings/MobileApp.tsx:40 +msgid "You need to be a System Manager to manage the mobile app configuration." +msgstr "" + +#: frontend/src/pages/settings/PushNotifications.tsx:92 +msgid "You need to be a System Manager to manage the push notification service." +msgstr "" + +#: raven/raven_ai/doctype/raven_ai_function/raven_ai_function.py:379 +msgid "You need to provide a default value for required parameters that are not asked by the AI." +msgstr "" + +#: raven/raven_channel_management/doctype/raven_channel/raven_channel.py:156 +msgid "You need to specify a workspace for this channel" +msgstr "" + +#. Option for the 'Type' (Select) field in DocType 'Raven AI Function Params' +#: raven/raven_ai/doctype/raven_ai_function_params/raven_ai_function_params.json +msgid "boolean" +msgstr "" + +#: frontend/src/components/feature/userSettings/UploadImage/FileUploadBox.tsx:99 +msgid "choose file" +msgstr "" + +#. Option for the 'Google Processor Location' (Select) field in DocType 'Raven +#. Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "eu" +msgstr "" + +#. Option for the 'Type' (Select) field in DocType 'Raven AI Function Params' +#: raven/raven_ai/doctype/raven_ai_function_params/raven_ai_function_params.json +msgid "float" +msgstr "" + +#. Option for the 'Reasoning Effort' (Select) field in DocType 'Raven Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "high" +msgstr "" + +#. Option for the 'Type' (Select) field in DocType 'Raven AI Function Params' +#: raven/raven_ai/doctype/raven_ai_function_params/raven_ai_function_params.json +msgid "integer" +msgstr "" + +#. Option for the 'Reasoning Effort' (Select) field in DocType 'Raven Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "low" +msgstr "" + +#. Option for the 'Reasoning Effort' (Select) field in DocType 'Raven Bot' +#: raven/raven_bot/doctype/raven_bot/raven_bot.json +msgid "medium" +msgstr "" + +#. Option for the 'Type' (Select) field in DocType 'Raven AI Function Params' +#: raven/raven_ai/doctype/raven_ai_function_params/raven_ai_function_params.json +msgid "number" +msgstr "" + +#: frontend/src/components/feature/channels/CreateChannelModal.tsx:227 +msgid "optional" +msgstr "" + +#. Option for the 'Type' (Select) field in DocType 'Raven AI Function Params' +#: raven/raven_ai/doctype/raven_ai_function_params/raven_ai_function_params.json +msgid "string" +msgstr "" + +#. Option for the 'Google Processor Location' (Select) field in DocType 'Raven +#. Settings' +#: raven/raven/doctype/raven_settings/raven_settings.json +msgid "us" +msgstr "" + diff --git a/raven/notification.py b/raven/notification.py index d57534b1c..901ad6a78 100644 --- a/raven/notification.py +++ b/raven/notification.py @@ -43,14 +43,18 @@ def send_push_notification_via_raven_cloud(message, raven_settings): return try: - channel_members = get_channel_members(message.channel_id) users = [] # Loop over the channel members and add the users who have subscribed to push notifications for member in channel_members.values(): - if member.get("allow_notifications"): + is_dm_or_dm_thread = channel_doc.is_direct_message or channel_doc.is_dm_thread + # if the channel is a DM or DM thread, then we should add all the users to the list + # by default. The allow notifications field would be used in future when we expose + # this setting in the UI to provide an option to the user to opt-out of push + # notifications for a certain DM user + if is_dm_or_dm_thread or member.get("allow_notifications") == 1: users.append(member.get("user_id")) if not users: @@ -72,7 +76,9 @@ def send_push_notification_via_raven_cloud(message, raven_settings): replied_users = [] final_users = [] - # If this is a bot message, then we should not filter out the push tokens of the message owner since we need to send the notification to the owner as well (it's coming from the bot) + # If this is a bot message, then we should not filter out the push tokens of the + # message owner since we need to send the notification to the owner as well + # (it's coming from the bot) if not message.is_bot_message: # Filter out the push tokens of the message owner users = [user for user in users if user != message.owner] @@ -172,7 +178,10 @@ def send_push_notification_via_raven_cloud(message, raven_settings): messages.append( { "users": final_users, - "notification": {"title": f"{message_owner}{channel_name}", "body": truncated_content}, + "notification": { + "title": f"{message_owner}{channel_name}", + "body": truncated_content, + }, "data": data, "tag": message.channel_id, "click_action": url, @@ -231,7 +240,12 @@ def send_notification_to_user(user_id, title, message, data=None, user_image_pat if data.get("channel_id"): link = frappe.utils.get_url() + "/raven/channel/" + data.get("channel_id", "") push_notification.send_notification_to_user( - user_id=user_id, title=title, body=message, icon=icon_url, data=data, link=link + user_id=user_id, + title=title, + body=message, + icon=icon_url, + data=data, + link=link, ) except ImportError: # push notifications are not supported in the current framework version @@ -262,7 +276,12 @@ def send_notification_to_topic(channel_id, title, message, data=None, user_image if data.get("channel_id"): link = frappe.utils.get_url() + "/raven/channel/" + data.get("channel_id", "") push_notification.send_notification_to_topic( - topic_name=channel_id, title=title, body=message, icon=icon_url, data=data, link=link + topic_name=channel_id, + title=title, + body=message, + icon=icon_url, + data=data, + link=link, ) except ImportError: # push notifications are not supported in the current framework version diff --git a/raven/package.json b/raven/package.json index 3b550ef07..3cdc8ac57 100644 --- a/raven/package.json +++ b/raven/package.json @@ -1,6 +1,6 @@ { "name": "raven-app", - "version": "2.6.5", + "version": "2.6.6", "description": "", "main": "index.js", "scripts": { @@ -24,4 +24,4 @@ "swr": "^2.2.5", "tippy.js": "^6.3.7" } -} \ No newline at end of file +} diff --git a/raven/raven/doctype/raven_user/raven_user.py b/raven/raven/doctype/raven_user/raven_user.py index 7dc19f227..d7414588b 100644 --- a/raven/raven/doctype/raven_user/raven_user.py +++ b/raven/raven/doctype/raven_user/raven_user.py @@ -123,14 +123,14 @@ def add_user_to_raven(doc, method): if has_raven_role: raven_user = frappe.get_doc("Raven User", {"user": doc.name}) - if not doc.full_name: - raven_user.full_name = doc.first_name + raven_user.full_name = doc.full_name or doc.first_name + raven_user.first_name = doc.first_name raven_user.enabled = doc.enabled raven_user.save(ignore_permissions=True) else: raven_user = frappe.get_doc("Raven User", {"user": doc.name}) - if not doc.full_name: - raven_user.full_name = doc.first_name + raven_user.full_name = doc.full_name or doc.first_name + raven_user.first_name = doc.first_name raven_user.enabled = 0 raven_user.save(ignore_permissions=True) else: @@ -147,8 +147,8 @@ def add_user_to_raven(doc, method): # Create a Raven User record for the user. raven_user = frappe.new_doc("Raven User") raven_user.user = doc.name - if not doc.full_name: - raven_user.full_name = doc.first_name + raven_user.full_name = doc.full_name or doc.first_name + raven_user.first_name = doc.first_name raven_user.enabled = doc.enabled raven_user.insert(ignore_permissions=True) diff --git a/raven/raven_messaging/doctype/raven_poll_vote/raven_poll_vote.py b/raven/raven_messaging/doctype/raven_poll_vote/raven_poll_vote.py index 52363d661..37e42db30 100644 --- a/raven/raven_messaging/doctype/raven_poll_vote/raven_poll_vote.py +++ b/raven/raven_messaging/doctype/raven_poll_vote/raven_poll_vote.py @@ -4,6 +4,7 @@ import frappe from frappe import _ from frappe.model.document import Document +from frappe.query_builder.functions import Count class RavenPollVote(Document): @@ -65,11 +66,14 @@ def update_poll_votes(poll_id): """ poll = frappe.get_doc("Raven Poll", poll_id, for_update=True) # get votes for each option - poll_votes = frappe.get_all( - "Raven Poll Vote", - filters={"poll_id": poll_id}, - fields=["option", "count(name) as votes"], - group_by="option", + poll_vote = frappe.qb.DocType("Raven Poll Vote") + + poll_votes = ( + frappe.qb.from_(poll_vote) + .select(poll_vote.option, Count(poll_vote.name).as_("votes")) + .where(poll_vote.poll_id == poll_id) + .groupby(poll_vote.option) + .run(as_dict=True) ) users = frappe.get_all( @@ -86,11 +90,11 @@ def update_poll_votes(poll_id): option.votes = vote.votes break - for option in poll.options: frappe.db.set_value( "Raven Poll Option", option.name, "votes", option.votes, update_modified=False ) frappe.db.set_value("Raven Poll", poll_id, "total_votes", total_votes, update_modified=False) + poll.total_votes = total_votes poll.notify_update() diff --git a/raven/utils.py b/raven/utils.py index 261083d66..81c5adf67 100644 --- a/raven/utils.py +++ b/raven/utils.py @@ -180,7 +180,13 @@ def get_raven_user(user_id: str) -> str: Get the Raven User ID of a user """ # TODO: Run this via cache - return frappe.db.get_value("Raven User", {"user": user_id}, "name") + filters = [["user", "=", user_id], "or", ["bot", "=", user_id]] + + query = frappe.qb.get_query("Raven User", filters=filters, limit=1) + + result = query.run(pluck=True) + + return result[0] if result else None def get_thread_reply_count(thread_id: str) -> int: From f43c6ad8e50ece9983ebcfcde1f6049cf477a79a Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Mon, 6 Apr 2026 09:26:17 +0000 Subject: [PATCH 03/36] feat: add required changes --- .../raven/doctype/raven_user/raven_user.json | 4 +-- raven/raven/doctype/raven_user/raven_user.py | 3 +- .../doctype/raven_channel/raven_channel.json | 32 +++++++++++++++++-- .../doctype/raven_channel/raven_channel.py | 8 +++-- .../doctype/raven_message/raven_message.json | 11 +++++-- .../doctype/raven_message/raven_message.py | 2 +- 6 files changed, 49 insertions(+), 11 deletions(-) diff --git a/raven/raven/doctype/raven_user/raven_user.json b/raven/raven/doctype/raven_user/raven_user.json index 6e11dffcc..1aaf800cf 100644 --- a/raven/raven/doctype/raven_user/raven_user.json +++ b/raven/raven/doctype/raven_user/raven_user.json @@ -77,7 +77,7 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Type", - "options": "User\nBot", + "options": "User\nBot\nCustomer\nCustomer Bot", "reqd": 1 }, { @@ -146,7 +146,7 @@ "grid_page_length": 50, "image_field": "user_image", "links": [], - "modified": "2026-02-01 14:43:05.717293", + "modified": "2026-04-06 16:25:09.089106", "modified_by": "Administrator", "module": "Raven", "name": "Raven User", diff --git a/raven/raven/doctype/raven_user/raven_user.py b/raven/raven/doctype/raven_user/raven_user.py index d7414588b..87c1185b8 100644 --- a/raven/raven/doctype/raven_user/raven_user.py +++ b/raven/raven/doctype/raven_user/raven_user.py @@ -14,7 +14,6 @@ class RavenUser(Document): if TYPE_CHECKING: from frappe.types import DF - from raven.raven.doctype.raven_pinned_channels.raven_pinned_channels import RavenPinnedChannels availability_status: DF.Literal["", "Available", "Away", "Do not disturb", "Invisible"] @@ -26,7 +25,7 @@ class RavenUser(Document): full_name: DF.Data last_mention_viewed_on: DF.Datetime | None pinned_channels: DF.Table[RavenPinnedChannels] - type: DF.Literal["User", "Bot"] + type: DF.Literal["User", "Bot", "Customer", "Customer Bot"] user: DF.Link | None user_image: DF.AttachImage | None # end: auto-generated types diff --git a/raven/raven_channel_management/doctype/raven_channel/raven_channel.json b/raven/raven_channel_management/doctype/raven_channel/raven_channel.json index e9f28db45..febad1009 100644 --- a/raven/raven_channel_management/doctype/raven_channel/raven_channel.json +++ b/raven/raven_channel_management/doctype/raven_channel/raven_channel.json @@ -21,12 +21,15 @@ "is_dm_thread", "column_break_puci", "is_self_message", + "is_customer", "column_break_ubts", "is_archived", "section_break_wlnt", "last_message_timestamp", "dm_user_1", "dm_user_2", + "customer_user", + "customer_provider", "column_break_eckt", "last_message_details", "section_break_acpc", @@ -223,6 +226,30 @@ "label": "DM User 2", "options": "Raven User", "read_only": 1 + }, + { + "default": "0", + "fieldname": "is_customer", + "fieldtype": "Check", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Is Customer", + "set_only_once": 1 + }, + { + "depends_on": "eval:doc.is_customer;", + "fieldname": "customer_user", + "fieldtype": "Link", + "label": "Customer", + "options": "Raven User", + "read_only": 1 + }, + { + "depends_on": "eval:doc.is_customer;", + "fieldname": "customer_provider", + "fieldtype": "Data", + "label": "Provider", + "read_only": 1 } ], "index_web_pages_for_search": 1, @@ -236,7 +263,7 @@ "link_fieldname": "channel_id" } ], - "modified": "2026-01-08 12:58:41.748067", + "modified": "2026-04-06 13:56:16.510331", "modified_by": "Administrator", "module": "Raven Channel Management", "name": "Raven Channel", @@ -268,10 +295,11 @@ "write": 1 } ], + "row_format": "Dynamic", "search_fields": "type", "show_title_field_in_link": 1, "sort_field": "modified", "sort_order": "DESC", "states": [], "title_field": "channel_name" -} \ No newline at end of file +} diff --git a/raven/raven_channel_management/doctype/raven_channel/raven_channel.py b/raven/raven_channel_management/doctype/raven_channel/raven_channel.py index 150ac0913..12d1ae214 100644 --- a/raven/raven_channel_management/doctype/raven_channel/raven_channel.py +++ b/raven/raven_channel_management/doctype/raven_channel/raven_channel.py @@ -16,15 +16,17 @@ class RavenChannel(Document): if TYPE_CHECKING: from frappe.types import DF - from raven.raven.doctype.raven_pinned_messages.raven_pinned_messages import RavenPinnedMessages channel_description: DF.SmallText | None channel_name: DF.Data + customer_provider: DF.Data | None + customer_user: DF.Link | None dm_user_1: DF.Link | None dm_user_2: DF.Link | None is_ai_thread: DF.Check is_archived: DF.Check + is_customer: DF.Check is_direct_message: DF.Check is_dm_thread: DF.Check is_self_message: DF.Check @@ -249,7 +251,9 @@ def add_members(self, members, is_admin=0): channel_member.insert(ignore_permissions=True) def autoname(self): - if self.is_direct_message == 0 and self.is_thread == 0: + if self.channel_name: + self.name = self.channel_name.strip().lower().replace(" ", "-") + elif self.is_direct_message == 0 and self.is_thread == 0: # Add workspace name to the channel name self.name = self.workspace + "-" + self.channel_name.strip().lower().replace(" ", "-") elif self.is_thread: diff --git a/raven/raven_messaging/doctype/raven_message/raven_message.json b/raven/raven_messaging/doctype/raven_message/raven_message.json index eb0d03012..b5c86c7b7 100644 --- a/raven/raven_messaging/doctype/raven_message/raven_message.json +++ b/raven/raven_messaging/doctype/raven_message/raven_message.json @@ -36,7 +36,8 @@ "is_bot_message", "bot", "hide_link_preview", - "notification" + "notification", + "is_customer_message" ], "fields": [ { @@ -208,11 +209,17 @@ "fieldname": "links", "fieldtype": "Small Text", "label": "Links" + }, + { + "default": "0", + "fieldname": "is_customer_message", + "fieldtype": "Check", + "label": "Is Customer Message" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2026-01-31 13:55:57.988161", + "modified": "2026-04-06 15:26:23.592738", "modified_by": "Administrator", "module": "Raven Messaging", "name": "Raven Message", diff --git a/raven/raven_messaging/doctype/raven_message/raven_message.py b/raven/raven_messaging/doctype/raven_message/raven_message.py index 0403b2814..0a3a1b489 100644 --- a/raven/raven_messaging/doctype/raven_message/raven_message.py +++ b/raven/raven_messaging/doctype/raven_message/raven_message.py @@ -34,7 +34,6 @@ class RavenMessage(Document): if TYPE_CHECKING: from frappe.types import DF - from raven.raven_messaging.doctype.raven_mention.raven_mention import RavenMention blurhash: DF.SmallText | None @@ -47,6 +46,7 @@ class RavenMessage(Document): image_height: DF.Data | None image_width: DF.Data | None is_bot_message: DF.Check + is_customer_message: DF.Check is_edited: DF.Check is_forwarded: DF.Check is_reply: DF.Check From bca15c6e07e657be24c4942dacb59a888f6758ec Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Thu, 9 Apr 2026 08:55:01 +0000 Subject: [PATCH 04/36] feat: add channel name fix --- .../doctype/raven_channel/raven_channel.json | 12 ++++++------ .../doctype/raven_channel/raven_channel.py | 11 +++++++---- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/raven/raven_channel_management/doctype/raven_channel/raven_channel.json b/raven/raven_channel_management/doctype/raven_channel/raven_channel.json index febad1009..02c2c80f9 100644 --- a/raven/raven_channel_management/doctype/raven_channel/raven_channel.json +++ b/raven/raven_channel_management/doctype/raven_channel/raven_channel.json @@ -29,7 +29,7 @@ "dm_user_1", "dm_user_2", "customer_user", - "customer_provider", + "chat_integration", "column_break_eckt", "last_message_details", "section_break_acpc", @@ -246,10 +246,10 @@ }, { "depends_on": "eval:doc.is_customer;", - "fieldname": "customer_provider", - "fieldtype": "Data", - "label": "Provider", - "read_only": 1 + "fieldname": "chat_integration", + "fieldtype": "Link", + "label": "Chat Integration", + "options": "Chat Integration" } ], "index_web_pages_for_search": 1, @@ -263,7 +263,7 @@ "link_fieldname": "channel_id" } ], - "modified": "2026-04-06 13:56:16.510331", + "modified": "2026-04-07 19:21:59.920739", "modified_by": "Administrator", "module": "Raven Channel Management", "name": "Raven Channel", diff --git a/raven/raven_channel_management/doctype/raven_channel/raven_channel.py b/raven/raven_channel_management/doctype/raven_channel/raven_channel.py index 12d1ae214..f9bc7716c 100644 --- a/raven/raven_channel_management/doctype/raven_channel/raven_channel.py +++ b/raven/raven_channel_management/doctype/raven_channel/raven_channel.py @@ -16,11 +16,14 @@ class RavenChannel(Document): if TYPE_CHECKING: from frappe.types import DF - from raven.raven.doctype.raven_pinned_messages.raven_pinned_messages import RavenPinnedMessages + + from raven.raven.doctype.raven_pinned_messages.raven_pinned_messages import ( + RavenPinnedMessages, + ) channel_description: DF.SmallText | None channel_name: DF.Data - customer_provider: DF.Data | None + chat_integration: DF.Link | None customer_user: DF.Link | None dm_user_1: DF.Link | None dm_user_2: DF.Link | None @@ -251,8 +254,8 @@ def add_members(self, members, is_admin=0): channel_member.insert(ignore_permissions=True) def autoname(self): - if self.channel_name: - self.name = self.channel_name.strip().lower().replace(" ", "-") + if self.id: + self.name = self.id elif self.is_direct_message == 0 and self.is_thread == 0: # Add workspace name to the channel name self.name = self.workspace + "-" + self.channel_name.strip().lower().replace(" ", "-") From 185c7cf645ce3dea4a7683da5de05280dc996dc6 Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Sun, 19 Apr 2026 08:23:03 +0000 Subject: [PATCH 05/36] feat: add omni channel chat --- raven/modules.txt | 3 +- raven/omni_channel_chat/__init__.py | 0 raven/omni_channel_chat/api/__init__.py | 0 raven/omni_channel_chat/api/webhooks.py | 64 +++++++ raven/omni_channel_chat/doctype/__init__.py | 0 .../omni_channel_chat_provider/__init__.py | 0 .../omni_channel_chat_provider.js | 8 + .../omni_channel_chat_provider.json | 123 +++++++++++++ .../omni_channel_chat_provider.py | 52 ++++++ .../provider/__init__.py | 3 + .../provider/base_provider.py | 56 ++++++ .../provider/facebook_provider.py | 106 +++++++++++ .../provider/line_provider.py | 114 ++++++++++++ .../test_omni_channel_chat_provider.py | 22 +++ raven/omni_channel_chat/models/__init__.py | 0 .../models/message_objects.py | 5 + raven/omni_channel_chat/models/user.py | 5 + .../models/webhook_events.py | 13 ++ raven/omni_channel_chat/webhook_handler.py | 172 ++++++++++++++++++ .../doctype/raven_channel/raven_channel.json | 10 +- .../doctype/raven_channel/raven_channel.py | 7 +- .../doctype/raven_message/raven_message.py | 90 ++++++++- 22 files changed, 832 insertions(+), 21 deletions(-) create mode 100644 raven/omni_channel_chat/__init__.py create mode 100644 raven/omni_channel_chat/api/__init__.py create mode 100644 raven/omni_channel_chat/api/webhooks.py create mode 100644 raven/omni_channel_chat/doctype/__init__.py create mode 100644 raven/omni_channel_chat/doctype/omni_channel_chat_provider/__init__.py create mode 100644 raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.js create mode 100644 raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.json create mode 100644 raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.py create mode 100644 raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/__init__.py create mode 100644 raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py create mode 100644 raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py create mode 100644 raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py create mode 100644 raven/omni_channel_chat/doctype/omni_channel_chat_provider/test_omni_channel_chat_provider.py create mode 100644 raven/omni_channel_chat/models/__init__.py create mode 100644 raven/omni_channel_chat/models/message_objects.py create mode 100644 raven/omni_channel_chat/models/user.py create mode 100644 raven/omni_channel_chat/models/webhook_events.py create mode 100644 raven/omni_channel_chat/webhook_handler.py diff --git a/raven/modules.txt b/raven/modules.txt index 4e7a2a38a..62e9ad764 100644 --- a/raven/modules.txt +++ b/raven/modules.txt @@ -3,4 +3,5 @@ Raven Messaging Raven Channel Management Raven Bot Raven Integrations -Raven AI \ No newline at end of file +Raven AI +Omni-Channel Chat \ No newline at end of file diff --git a/raven/omni_channel_chat/__init__.py b/raven/omni_channel_chat/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/raven/omni_channel_chat/api/__init__.py b/raven/omni_channel_chat/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/raven/omni_channel_chat/api/webhooks.py b/raven/omni_channel_chat/api/webhooks.py new file mode 100644 index 000000000..ab1d04f18 --- /dev/null +++ b/raven/omni_channel_chat/api/webhooks.py @@ -0,0 +1,64 @@ +import asyncio + +import frappe +from werkzeug.wrappers import Response + +from raven.omni_channel_chat.doctype.omni_channel_chat_provider.omni_channel_chat_provider import ( + get_omni_channel_chat, +) + + +def extract_slug() -> str: + """Extract the trailing path segment (slug) from the current request URL. + + Strips a trailing slash if present, then returns the last path component. + Used by webhook handlers to identify which Omni Channel Chat Provider configuration + should process the incoming request. + + Returns: + str: The slug portion of the request path. + + Example: + For a request to `/api/method/raven.omni_channel_chat.api.webhooks.line/g9ju6k0e8r`, + this returns `g9ju6k0e8r`. + """ + request = frappe.local.request + return request.path.rstrip("/").rsplit("/", 1)[-1] + + +@frappe.whitelist(allow_guest=True, methods=["POST"]) +def line() -> dict: + slug = extract_slug() + request = frappe.local.request + body: bytes = request.get_data() + headers: dict = dict(request.headers) + + omni_channel_chat = get_omni_channel_chat(slug=slug) + provider = omni_channel_chat.get_provider() + asyncio.run(provider.handle_webhook(body=body, headers=headers)) + return {"status": "ok"} + + +@frappe.whitelist(allow_guest=True, methods=["GET", "POST"]) +def facebook() -> dict | Response | None: + slug = extract_slug() + request = frappe.local.request + + omni_channel_chat = get_omni_channel_chat(slug=slug) + provider = omni_channel_chat.get_provider() + + if request.method == "GET": + mode = frappe.form_dict.get("hub.mode") + verify_token = frappe.form_dict.get("hub.verify_token") + challenge = frappe.form_dict.get("hub.challenge", "0") + + if mode == "subscribe" and verify_token == provider._verify_token: + return Response(challenge, status=200, content_type="text/plain") + else: + frappe.throw("Verification failed", frappe.PermissionError) + + body: bytes = request.get_data() + headers: dict = dict(request.headers) + + asyncio.run(provider.handle_webhook(body=body, headers=headers)) + return {"status": "ok"} diff --git a/raven/omni_channel_chat/doctype/__init__.py b/raven/omni_channel_chat/doctype/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/__init__.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.js b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.js new file mode 100644 index 000000000..d436e8254 --- /dev/null +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.js @@ -0,0 +1,8 @@ +// Copyright (c) 2026, The Commit Company (Algocode Technologies Pvt. Ltd.) and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Omni Channel Chat Provider", { +// refresh(frm) { + +// }, +// }); diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.json b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.json new file mode 100644 index 000000000..77e8229d9 --- /dev/null +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.json @@ -0,0 +1,123 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-04-10 14:08:51.059594", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "provider", + "display_name", + "column_break_meix", + "raven_workspace", + "conf_line_sec", + "line_channel_secret", + "line_channel_access_token", + "conf_facebook_sec", + "fb_page_access_token", + "fb_verify_token", + "fb_app_secret" + ], + "fields": [ + { + "fieldname": "provider", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Provider", + "options": "\nline\nfacebook", + "reqd": 1 + }, + { + "fieldname": "display_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Display Name", + "reqd": 1 + }, + { + "fieldname": "column_break_meix", + "fieldtype": "Column Break" + }, + { + "description": "Incoming chat will be added to the specified workspace.", + "fieldname": "raven_workspace", + "fieldtype": "Link", + "label": "Raven Workspace", + "options": "Raven Workspace", + "reqd": 1 + }, + { + "depends_on": "eval:doc.provider==\"line\"", + "fieldname": "conf_line_sec", + "fieldtype": "Section Break", + "label": "Config - Line" + }, + { + "fieldname": "line_channel_secret", + "fieldtype": "Password", + "label": "Channel Secret", + "length": 512, + "mandatory_depends_on": "eval:doc.provider==\"line\"" + }, + { + "fieldname": "line_channel_access_token", + "fieldtype": "Password", + "label": "Channel Access Token", + "length": 512, + "mandatory_depends_on": "eval:doc.provider==\"line\"" + }, + { + "depends_on": "eval:doc.provider==\"facebook\"", + "fieldname": "conf_facebook_sec", + "fieldtype": "Section Break", + "label": "Config - Facebook" + }, + { + "fieldname": "fb_page_access_token", + "fieldtype": "Password", + "label": "Page Access Token", + "length": 512, + "mandatory_depends_on": "eval:doc.provider==\"facebook\"" + }, + { + "fieldname": "fb_verify_token", + "fieldtype": "Password", + "label": "Verify Token", + "length": 512, + "mandatory_depends_on": "eval:doc.provider==\"facebook\"" + }, + { + "fieldname": "fb_app_secret", + "fieldtype": "Password", + "label": "App Secret", + "length": 512, + "mandatory_depends_on": "eval:doc.provider==\"facebook\"" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-04-10 14:17:07.282091", + "modified_by": "Administrator", + "module": "Omni-Channel Chat", + "name": "Omni Channel Chat Provider", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.py new file mode 100644 index 000000000..a92bf55ad --- /dev/null +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.py @@ -0,0 +1,52 @@ +# Copyright (c) 2026, The Commit Company (Algocode Technologies Pvt. Ltd.) and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document + +from .provider import FacebookProvider, LineProvider, Provider + + +class OmniChannelChatProvider(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + display_name: DF.Data + fb_app_secret: DF.Password | None + fb_page_access_token: DF.Password | None + fb_verify_token: DF.Password | None + line_channel_access_token: DF.Password | None + line_channel_secret: DF.Password | None + provider: DF.Literal["", "line", "facebook"] + raven_workspace: DF.Link + # end: auto-generated types + + def decode_password_field(self): + for field in frappe.get_meta(self.doctype).fields: + if field.fieldtype == "Password": + value = self.get_password( + fieldname=field.fieldname, + raise_exception=False, + ) + self.set( + key=field.fieldname, + value=value, + ) + + def get_provider(self) -> Provider: + if self.provider == "line": + return LineProvider(config=self) + elif self.provider == "facebook": + return FacebookProvider(config=self) + else: + frappe.throw(_("Provider not implemented.")) + + +def get_omni_channel_chat(slug: str) -> OmniChannelChatProvider: + return frappe.get_doc("Omni Channel Chat Provider", slug) diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/__init__.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/__init__.py new file mode 100644 index 000000000..cea52469e --- /dev/null +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/__init__.py @@ -0,0 +1,3 @@ +from .base_provider import Provider as Provider +from .facebook_provider import FacebookProvider as FacebookProvider +from .line_provider import LineProvider as LineProvider diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py new file mode 100644 index 000000000..8b8f3ae1e --- /dev/null +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py @@ -0,0 +1,56 @@ +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any + +from raven.omni_channel_chat.webhook_handler import handle_incoming_webhook_message + +if TYPE_CHECKING: + from raven.omni_channel_chat.doctype.omni_channel_chat_provider.omni_channel_chat_provider import ( + OmniChannelChatProvider, + ) + + +class Provider[ProviderWebhookEvent, ProviderMessageObject](ABC): + provider_config: "OmniChannelChatProvider" + + def __init__(self, config: "OmniChannelChatProvider"): + self.provider_config = config + self.provider_config.decode_password_field() + + async def push_message_to_raven(self, messages: list[dict]) -> None: + for message in messages: + await handle_incoming_webhook_message( + provider=self, + message=message, + ) + + async def handle_webhook(self, body: bytes, headers: dict) -> None: + messages = await self.extract_messages(body=body, headers=headers) + await self.push_message_to_raven(messages=messages) + + @abstractmethod + async def get_user_info(self, user_id: str) -> dict: + """Fetch user info from the provider's platform.""" + + @abstractmethod + async def show_typing(self, user_id: str) -> None: + """Show a typing / loading indicator.""" + + @abstractmethod + async def send_reply(self, user_id: str, message: dict, context: Any) -> None: + """Send a chat response back within the webhook reply context.""" + + @abstractmethod + async def send_message(self, user_id: str, message: dict) -> None: + """Send an outbound message (push, not reply).""" + + @abstractmethod + async def event_mapper(self, event: ProviderWebhookEvent) -> dict | None: + """Map a provider-specific webhook event into a standardized event. Return None to skip.""" + + @abstractmethod + async def standardize_events(self, events: list[ProviderWebhookEvent]) -> list[dict]: + """Standardize a list of provider-specific webhook events into standardized events.""" + + @abstractmethod + async def extract_messages(self, body: bytes, headers: dict) -> list[dict]: + """Parse the raw webhook body into standardized messages.""" diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py new file mode 100644 index 000000000..e596de1c0 --- /dev/null +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py @@ -0,0 +1,106 @@ +import hashlib +import hmac +import json +from typing import Any + +import frappe +import httpx + +from raven.omni_channel_chat.doctype.omni_channel_chat_provider.provider import ( + Provider, +) + +# A "messaging event" dict from the Facebook webhook payload +FacebookMessagingEvent = dict[str, Any] + + +class FacebookProvider(Provider[FacebookMessagingEvent, dict]): + FB_API_URL = "https://graph.facebook.com/v22.0/me/messages" + + def __init__(self, config): + super().__init__(config=config) + self._page_access_token = self.provider_config.fb_page_access_token + self._app_secret = self.provider_config.fb_app_secret + self._verify_token = self.provider_config.fb_verify_token + + def _verify_signature(self, body: bytes, signature_header: str) -> bool: + if not signature_header.startswith("sha256="): + return False + expected = hmac.new(self._app_secret.encode(), body, hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, signature_header.removeprefix("sha256=")) + + async def get_user_info(self, user_id: str) -> dict: + async with httpx.AsyncClient() as client: + response = await client.get( + f"https://graph.facebook.com/{user_id}", + params={ + "fields": "name,picture", + "access_token": self._page_access_token, + }, + ) + response.raise_for_status() + data = response.json() + return { + "user_id": user_id, + "display_name": data.get("name"), + "picture_url": data.get("picture", {}).get("data", {}).get("url"), + } + + async def show_typing(self, user_id: str) -> None: + async with httpx.AsyncClient() as client: + await client.post( + self.FB_API_URL, + params={"access_token": self._page_access_token}, + json={"recipient": {"id": user_id}, "sender_action": "typing_on"}, + ) + + async def send_reply(self, user_id: str, message: dict, context: Any) -> None: + await self.send_message(user_id=user_id, message=message) + + async def send_message(self, user_id: str, message: dict) -> None: + async with httpx.AsyncClient() as client: + await client.post( + self.FB_API_URL, + params={"access_token": self._page_access_token}, + json={ + "recipient": {"id": user_id}, + "message": {"text": message["text"]}, + }, + ) + + async def event_mapper(self, event: FacebookMessagingEvent) -> dict | None: + message = event.get("message") + if message is None or "text" not in message: + return None + return { + "provider": self.provider_config.provider, + "user_id": event["sender"]["id"], + "message": {"type": "Text", "text": message["text"]}, + "message_metadata": {"mid": message.get("mid")}, + } + + async def standardize_events(self, events: list[FacebookMessagingEvent]) -> list[dict]: + std_events: list[dict] = [] + for event in events: + std_event = await self.event_mapper(event) + if std_event: + std_events.append(std_event) + return std_events + + async def extract_messages(self, body: bytes, headers: dict) -> list[dict]: + signature = headers.get("X-Hub-Signature-256", "") or headers.get( + "x-hub-signature-256", "" + ) + if not self._verify_signature(body, signature): + frappe.throw("Invalid Facebook signature", frappe.PermissionError) + + payload = json.loads(body) + if payload.get("object") != "page": + frappe.throw("Not a page event", frappe.ValidationError) + + messaging_events: list[FacebookMessagingEvent] = [ + messaging_event + for entry in payload.get("entry", []) + for messaging_event in entry.get("messaging", []) + ] + return await self.standardize_events(messaging_events) diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py new file mode 100644 index 000000000..4a07e48d1 --- /dev/null +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py @@ -0,0 +1,114 @@ +from typing import Any + +import frappe +from linebot.v3.exceptions import InvalidSignatureError +from linebot.v3.messaging import ( + ApiClient, + Configuration, + MessagingApi, + PushMessageRequest, + ReplyMessageRequest, + ShowLoadingAnimationRequest, + TextMessage, +) +from linebot.v3.messaging import ( + Sender as LineSender, +) +from linebot.v3.messaging import TextMessage as LineTextMessage +from linebot.v3.webhook import WebhookParser +from linebot.v3.webhooks import Event as LineEvent +from linebot.v3.webhooks import MessageEvent as LineMessageEvent +from linebot.v3.webhooks import TextMessageContent + +from . import Provider + + +class LineProvider(Provider[LineEvent, list[TextMessage]]): + config: Configuration + parser: WebhookParser + + def __init__(self, config): + super().__init__(config=config) + + self.config = Configuration( + access_token=self.provider_config.line_channel_access_token, + ) + self.parser = WebhookParser( + channel_secret=self.provider_config.line_channel_secret, + ) + + async def get_user_info(self, user_id: str) -> dict: + with ApiClient(self.config) as api_client: + profile = MessagingApi(api_client).get_profile(user_id) + return { + "user_id": profile.user_id, + "display_name": profile.display_name, + "picture_url": profile.picture_url, + } + + async def show_typing(self, user_id: str) -> None: + with ApiClient(self.config) as api_client: + MessagingApi(api_client).show_loading_animation( + ShowLoadingAnimationRequest(chatId=user_id, loadingSeconds=60) + ) + + async def send_reply(self, user_id: str, message: dict, context: Any) -> None: + reply_token = context + line_msg = LineTextMessage(text=message["text"]) + if message.get("sender"): + line_msg.sender = LineSender( + name=message["sender"].get("name"), + icon_url=message["sender"].get("icon_url"), + ) + with ApiClient(self.config) as api_client: + MessagingApi(api_client).reply_message( + ReplyMessageRequest(reply_token=reply_token, messages=[line_msg]) + ) + + async def send_message(self, user_id: str, message: dict) -> None: + line_msg = LineTextMessage(text=message["text"]) + if message.get("sender"): + line_msg.sender = LineSender( + name=message["sender"].get("name"), + icon_url=message["sender"].get("icon_url"), + ) + with ApiClient(self.config) as api_client: + MessagingApi(api_client).push_message( + PushMessageRequest( + to=user_id, + messages=[line_msg], + ) + ) + + async def event_mapper(self, event: LineEvent) -> dict | None: + if not isinstance(event, LineMessageEvent): + return None + + msg = event.message + if isinstance(msg, TextMessageContent): + return { + "provider": self.provider_config.provider, + "user_id": event.source.user_id, + "message": {"type": "Text", "text": msg.text}, + "message_metadata": { + "message_id": msg.id, + "reply_token": event.reply_token, + }, + } + return None + + async def standardize_events(self, events: list[LineEvent]) -> list[dict]: + std_events: list[dict] = [] + for event in events: + std_event = await self.event_mapper(event) + if std_event: + std_events.append(std_event) + return std_events + + async def extract_messages(self, body: bytes, headers: dict) -> list[dict]: + signature = headers.get("x-line-signature", "") or headers.get("X-Line-Signature", "") + try: + events = self.parser.parse(body=body.decode(), signature=signature, as_payload=False) + except InvalidSignatureError: + frappe.throw("Invalid LINE signature", frappe.PermissionError) + return await self.standardize_events(events) diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/test_omni_channel_chat_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/test_omni_channel_chat_provider.py new file mode 100644 index 000000000..f09997616 --- /dev/null +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/test_omni_channel_chat_provider.py @@ -0,0 +1,22 @@ +# Copyright (c) 2026, The Commit Company (Algocode Technologies Pvt. Ltd.) and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase + + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + + +class IntegrationTestOmniChannelChatProvider(IntegrationTestCase): + """ + Integration tests for OmniChannelChatProvider. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/raven/omni_channel_chat/models/__init__.py b/raven/omni_channel_chat/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/raven/omni_channel_chat/models/message_objects.py b/raven/omni_channel_chat/models/message_objects.py new file mode 100644 index 000000000..55caeca35 --- /dev/null +++ b/raven/omni_channel_chat/models/message_objects.py @@ -0,0 +1,5 @@ +from typing import Any + +# Plain dict type alias for outgoing message objects. +# Keys: text (str), sender (dict | None) with keys name (str | None), icon_url (str | None) +MessageObjects = dict[str, Any] diff --git a/raven/omni_channel_chat/models/user.py b/raven/omni_channel_chat/models/user.py new file mode 100644 index 000000000..bdeaf0729 --- /dev/null +++ b/raven/omni_channel_chat/models/user.py @@ -0,0 +1,5 @@ +from typing import Any + +# Plain dict type alias for user profile. +# Keys: user_id (str), display_name (str), picture_url (str | None) +UserProfile = dict[str, Any] diff --git a/raven/omni_channel_chat/models/webhook_events.py b/raven/omni_channel_chat/models/webhook_events.py new file mode 100644 index 000000000..215e39299 --- /dev/null +++ b/raven/omni_channel_chat/models/webhook_events.py @@ -0,0 +1,13 @@ +from typing import Any + +# Plain dict type aliases for standardized webhook events. +# These replace the previous Pydantic models. + +# {"type": str, "text": str} +StdTextMessage = dict[str, Any] + +# {"provider": str, "user_id": str, "message": StdTextMessage, "message_metadata": dict} +StdWebhookEvent = dict[str, Any] + +# Alias kept for backwards compatibility +StdMessageEvent = StdWebhookEvent diff --git a/raven/omni_channel_chat/webhook_handler.py b/raven/omni_channel_chat/webhook_handler.py new file mode 100644 index 000000000..ed23fa5c2 --- /dev/null +++ b/raven/omni_channel_chat/webhook_handler.py @@ -0,0 +1,172 @@ +from typing import TYPE_CHECKING + +import frappe + +if TYPE_CHECKING: + from frappe.core.doctype.user.user import User + + from raven.omni_channel_chat.doctype.omni_channel_chat_provider.omni_channel_chat_provider import ( + OmniChannelChatProvider, + ) + from raven.omni_channel_chat.doctype.omni_channel_chat_provider.provider import ( + Provider, + ) + from raven.raven.doctype.raven_user.raven_user import RavenUser + from raven.raven_channel_management.doctype.raven_channel.raven_channel import ( + RavenChannel, + ) + + +def get_customer_user( + *, + chat_integration: "OmniChannelChatProvider", + user_id: str, +) -> "User": + provider = chat_integration.provider + + user_pk = frappe.db.get_value( + doctype="User Social Login", + filters={ + "provider": provider, + "userid": user_id, + }, + fieldname="parent", + ) + + if user_pk: + user = frappe.get_doc("User", user_pk) + else: + username = frappe.generate_hash(length=10) + email = f"{username}@users.cafn.co" + + user = frappe.new_doc("User") + user.update( + { + "email": email, + "username": username, + "first_name": username, + "enabled": 1, + "user_type": "Website User", + } + ) + user.insert(ignore_permissions=True) + + user_social_login = frappe.new_doc("User Social Login") + user_social_login.update( + { + "provider": provider, + "userid": user_id, + "parent": user.name, + "parenttype": "User", + "parentfield": "social_logins", + } + ) + user_social_login.insert(ignore_permissions=True) + + return user + + +async def get_raven_user( + *, + provider: "Provider", + user: "User", + user_id: str, +) -> "RavenUser": + raven_user_pk = frappe.db.get_value( + doctype="Raven User", + filters={ + "user": user.name, + }, + fieldname="name", + ) + + if raven_user_pk: + raven_user = frappe.get_doc("Raven User", raven_user_pk) + else: + user_info = await provider.get_user_info( + user_id=user_id, + ) + + raven_user = frappe.new_doc("Raven User") + raven_user.update( + { + "type": "Customer", + "user": user.name, + "full_name": user_info["display_name"], + "user_image": user_info["picture_url"], + "enabled": True, + } + ) + raven_user.insert(ignore_permissions=True) + + return raven_user + + +def get_raven_channel( + *, + chat_integration: "OmniChannelChatProvider", + raven_user: "RavenUser", +) -> "RavenChannel": + channel_name = f"{chat_integration.name}_{raven_user.name}" + channel_pk = frappe.db.get_value( + doctype="Raven Channel", + filters=channel_name, + fieldname="name", + ) + + if channel_pk: + channel = frappe.get_doc("Raven Channel", channel_pk) + else: + channel = frappe.get_doc( + { + "doctype": "Raven Channel", + "channel_name": channel_name, + "id": channel_name, + "type": "Public", + "customer_user": raven_user.user, + "omni_channel_chat_provider": chat_integration.name, + "is_customer": True, + "enabled": True, + "workspace": chat_integration.raven_workspace, + } + ).insert(ignore_permissions=True) + + return channel + + +def create_message( + *, + raven_channel: "RavenChannel", + message: dict, +) -> None: + doc = frappe.new_doc(doctype="Raven Message") + doc.update( + { + "channel_id": raven_channel.name, + "message_type": message["message"]["type"], + "text": message["message"]["text"], + "is_customer_message": True, + "owner": raven_channel.customer_user, + } + ) + doc.insert(ignore_permissions=True) + + +async def handle_incoming_webhook_message( + *, + provider: "Provider", + message: dict, +) -> "RavenChannel": + chat_integration = provider.provider_config + user = get_customer_user( + chat_integration=chat_integration, + user_id=message["user_id"], + ) + + frappe.set_user(user.name) + + raven_user = await get_raven_user(provider=provider, user=user, user_id=message["user_id"]) + raven_channel = get_raven_channel(chat_integration=chat_integration, raven_user=raven_user) + create_message(raven_channel=raven_channel, message=message) + + return raven_channel diff --git a/raven/raven_channel_management/doctype/raven_channel/raven_channel.json b/raven/raven_channel_management/doctype/raven_channel/raven_channel.json index 02c2c80f9..61aa491e9 100644 --- a/raven/raven_channel_management/doctype/raven_channel/raven_channel.json +++ b/raven/raven_channel_management/doctype/raven_channel/raven_channel.json @@ -29,7 +29,7 @@ "dm_user_1", "dm_user_2", "customer_user", - "chat_integration", + "omni_channel_chat_provider", "column_break_eckt", "last_message_details", "section_break_acpc", @@ -246,10 +246,10 @@ }, { "depends_on": "eval:doc.is_customer;", - "fieldname": "chat_integration", + "fieldname": "omni_channel_chat_provider", "fieldtype": "Link", - "label": "Chat Integration", - "options": "Chat Integration" + "label": "Omni Channel Chat Provider", + "options": "Omni Channel Chat Provider" } ], "index_web_pages_for_search": 1, @@ -263,7 +263,7 @@ "link_fieldname": "channel_id" } ], - "modified": "2026-04-07 19:21:59.920739", + "modified": "2026-04-19 14:41:55.728586", "modified_by": "Administrator", "module": "Raven Channel Management", "name": "Raven Channel", diff --git a/raven/raven_channel_management/doctype/raven_channel/raven_channel.py b/raven/raven_channel_management/doctype/raven_channel/raven_channel.py index f9bc7716c..a92072307 100644 --- a/raven/raven_channel_management/doctype/raven_channel/raven_channel.py +++ b/raven/raven_channel_management/doctype/raven_channel/raven_channel.py @@ -16,14 +16,10 @@ class RavenChannel(Document): if TYPE_CHECKING: from frappe.types import DF - - from raven.raven.doctype.raven_pinned_messages.raven_pinned_messages import ( - RavenPinnedMessages, - ) + from raven.raven.doctype.raven_pinned_messages.raven_pinned_messages import RavenPinnedMessages channel_description: DF.SmallText | None channel_name: DF.Data - chat_integration: DF.Link | None customer_user: DF.Link | None dm_user_1: DF.Link | None dm_user_2: DF.Link | None @@ -39,6 +35,7 @@ class RavenChannel(Document): last_message_timestamp: DF.Datetime | None linked_doctype: DF.Link | None linked_document: DF.DynamicLink | None + omni_channel_chat_provider: DF.Link | None openai_thread_id: DF.Data | None pinned_messages: DF.Table[RavenPinnedMessages] pinned_messages_string: DF.SmallText | None diff --git a/raven/raven_messaging/doctype/raven_message/raven_message.py b/raven/raven_messaging/doctype/raven_message/raven_message.py index 0a3a1b489..f3e9c503b 100644 --- a/raven/raven_messaging/doctype/raven_message/raven_message.py +++ b/raven/raven_messaging/doctype/raven_message/raven_message.py @@ -34,7 +34,10 @@ class RavenMessage(Document): if TYPE_CHECKING: from frappe.types import DF - from raven.raven_messaging.doctype.raven_mention.raven_mention import RavenMention + + from raven.raven_messaging.doctype.raven_mention.raven_mention import ( + RavenMention, + ) blurhash: DF.SmallText | None bot: DF.Link | None @@ -163,7 +166,9 @@ def validate(self): if not self.is_new() and self.has_value_changed("message_reactions"): frappe.throw( - _("Direct modification of message_reactions is not allowed. Use the Reactions API.") + _( + "Direct modification of message_reactions is not allowed. Use the Reactions API." + ) ) def validate_linked_message(self): @@ -172,7 +177,8 @@ def validate_linked_message(self): """ if self.linked_message: if ( - frappe.get_cached_value("Raven Message", self.linked_message, "channel_id") != self.channel_id + frappe.get_cached_value("Raven Message", self.linked_message, "channel_id") + != self.channel_id ): frappe.throw(_("Linked message should be in the same channel")) @@ -212,6 +218,7 @@ def after_insert(self): self.handle_ai_message() self.send_push_notification() + self.push_message_to_omni_channel_chat_provider() def handle_ai_message(self): @@ -307,13 +314,10 @@ def publish_unread_count_event(self, last_message_details=None): channel_doc = frappe.get_cached_doc("Raven Channel", self.channel_id) # If the message is a direct message, then we can only send it to one user if channel_doc.is_direct_message: - if not channel_doc.is_self_message: - peer_user_doc = get_peer_user(self.channel_id, 1) if peer_user_doc.get("type") == "User": - frappe.publish_realtime( "raven:unread_channel_count_updated", { @@ -390,7 +394,11 @@ def add_mentioned_users_to_thread(self): ): try: frappe.get_doc( - {"doctype": "Raven Channel Member", "channel_id": self.channel_id, "user_id": mention.user} + { + "doctype": "Raven Channel Member", + "channel_id": self.channel_id, + "user_id": mention.user, + } ).insert(ignore_permissions=True) except Exception: pass @@ -506,7 +514,9 @@ def send_notification_for_channel_message(self): if is_thread: title = f"{owner_name} in thread" else: - channel_name = frappe.get_cached_value("Raven Channel", self.channel_id, "channel_name") + channel_name = frappe.get_cached_value( + "Raven Channel", self.channel_id, "channel_name" + ) title = f"{owner_name} in #{channel_name}" # Prepare content for data payload - truncate if text message @@ -550,7 +560,12 @@ def after_delete(self): # delete poll if the message is of type poll after deleting the message if self.message_type == "Poll": - frappe.delete_doc("Raven Poll", self.poll_id, ignore_permissions=True, delete_permanently=True) + frappe.delete_doc( + "Raven Poll", + self.poll_id, + ignore_permissions=True, + delete_permanently=True, + ) # TEMP: this is a temp fix for the Desk interface self.publish_deprecated_event_for_desk() @@ -685,7 +700,8 @@ def on_trash(self): # delete the pinned message is_pinned = frappe.get_all( - "Raven Pinned Messages", {"message_id": self.name, "parent": self.channel_id} + "Raven Pinned Messages", + {"message_id": self.name, "parent": self.channel_id}, ) if is_pinned: channel_doc = frappe.get_doc("Raven Channel", self.channel_id) @@ -698,6 +714,60 @@ def on_trash(self): channel_doc.remove(pinned_row) channel_doc.save() + def push_message_to_omni_channel_chat_provider(self) -> None: + import asyncio + + if self.is_customer_message or self.is_bot_message or self.message_type == "System": + return + + channel = frappe.db.get_value( + doctype="Raven Channel", + filters=self.channel_id, + fieldname=["is_customer", "customer_user", "omni_channel_chat_provider"], + as_dict=True, + ) + if not channel or not channel.is_customer: + return + + if not channel.omni_channel_chat_provider: + return + + omni_channel_chat_provider = frappe.get_doc( + "Omni Channel Chat Provider", channel.omni_channel_chat_provider + ) + + provider_user_id = frappe.db.get_value( + doctype="User Social Login", + filters={ + "provider": omni_channel_chat_provider.provider, + "parent": channel.customer_user, + }, + fieldname="userid", + ) + + if not provider_user_id: + return + + message = {"text": self.content} + + user = frappe.db.get_value( + "Raven User", + self.owner, + ["full_name", "user_image"], + as_dict=True, + ) + if user: + avatar_url = user.user_image + if avatar_url and avatar_url.startswith("/"): + avatar_url = frappe.utils.get_url(avatar_url) + message["sender"] = { + "name": user.full_name or None, + "icon_url": avatar_url or None, + } + + provider = omni_channel_chat_provider.get_provider() + asyncio.run(provider.send_message(user_id=provider_user_id, message=message)) + def on_doctype_update(): """ From 3a8f6ec12bf437b514680a6d96a595e1906c18dc Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Sun, 19 Apr 2026 08:39:46 +0000 Subject: [PATCH 06/36] refactor: remove async io --- raven/omni_channel_chat/api/webhooks.py | 6 ++-- .../provider/base_provider.py | 24 +++++++------- .../provider/facebook_provider.py | 32 +++++++++---------- .../provider/line_provider.py | 18 +++++------ raven/omni_channel_chat/webhook_handler.py | 8 ++--- .../doctype/raven_message/raven_message.py | 4 +-- 6 files changed, 44 insertions(+), 48 deletions(-) diff --git a/raven/omni_channel_chat/api/webhooks.py b/raven/omni_channel_chat/api/webhooks.py index ab1d04f18..b1fc54168 100644 --- a/raven/omni_channel_chat/api/webhooks.py +++ b/raven/omni_channel_chat/api/webhooks.py @@ -1,5 +1,3 @@ -import asyncio - import frappe from werkzeug.wrappers import Response @@ -35,7 +33,7 @@ def line() -> dict: omni_channel_chat = get_omni_channel_chat(slug=slug) provider = omni_channel_chat.get_provider() - asyncio.run(provider.handle_webhook(body=body, headers=headers)) + provider.handle_webhook(body=body, headers=headers) return {"status": "ok"} @@ -60,5 +58,5 @@ def facebook() -> dict | Response | None: body: bytes = request.get_data() headers: dict = dict(request.headers) - asyncio.run(provider.handle_webhook(body=body, headers=headers)) + provider.handle_webhook(body=body, headers=headers) return {"status": "ok"} diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py index 8b8f3ae1e..00948be6d 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py @@ -16,41 +16,41 @@ def __init__(self, config: "OmniChannelChatProvider"): self.provider_config = config self.provider_config.decode_password_field() - async def push_message_to_raven(self, messages: list[dict]) -> None: + def push_message_to_raven(self, messages: list[dict]) -> None: for message in messages: - await handle_incoming_webhook_message( + handle_incoming_webhook_message( provider=self, message=message, ) - async def handle_webhook(self, body: bytes, headers: dict) -> None: - messages = await self.extract_messages(body=body, headers=headers) - await self.push_message_to_raven(messages=messages) + def handle_webhook(self, body: bytes, headers: dict) -> None: + messages = self.extract_messages(body=body, headers=headers) + self.push_message_to_raven(messages=messages) @abstractmethod - async def get_user_info(self, user_id: str) -> dict: + def get_user_info(self, user_id: str) -> dict: """Fetch user info from the provider's platform.""" @abstractmethod - async def show_typing(self, user_id: str) -> None: + def show_typing(self, user_id: str) -> None: """Show a typing / loading indicator.""" @abstractmethod - async def send_reply(self, user_id: str, message: dict, context: Any) -> None: + def send_reply(self, user_id: str, message: dict, context: Any) -> None: """Send a chat response back within the webhook reply context.""" @abstractmethod - async def send_message(self, user_id: str, message: dict) -> None: + def send_message(self, user_id: str, message: dict) -> None: """Send an outbound message (push, not reply).""" @abstractmethod - async def event_mapper(self, event: ProviderWebhookEvent) -> dict | None: + def event_mapper(self, event: ProviderWebhookEvent) -> dict | None: """Map a provider-specific webhook event into a standardized event. Return None to skip.""" @abstractmethod - async def standardize_events(self, events: list[ProviderWebhookEvent]) -> list[dict]: + def standardize_events(self, events: list[ProviderWebhookEvent]) -> list[dict]: """Standardize a list of provider-specific webhook events into standardized events.""" @abstractmethod - async def extract_messages(self, body: bytes, headers: dict) -> list[dict]: + def extract_messages(self, body: bytes, headers: dict) -> list[dict]: """Parse the raw webhook body into standardized messages.""" diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py index e596de1c0..6077f253c 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py @@ -29,9 +29,9 @@ def _verify_signature(self, body: bytes, signature_header: str) -> bool: expected = hmac.new(self._app_secret.encode(), body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature_header.removeprefix("sha256=")) - async def get_user_info(self, user_id: str) -> dict: - async with httpx.AsyncClient() as client: - response = await client.get( + def get_user_info(self, user_id: str) -> dict: + with httpx.Client() as client: + response = client.get( f"https://graph.facebook.com/{user_id}", params={ "fields": "name,picture", @@ -46,20 +46,20 @@ async def get_user_info(self, user_id: str) -> dict: "picture_url": data.get("picture", {}).get("data", {}).get("url"), } - async def show_typing(self, user_id: str) -> None: - async with httpx.AsyncClient() as client: - await client.post( + def show_typing(self, user_id: str) -> None: + with httpx.Client() as client: + client.post( self.FB_API_URL, params={"access_token": self._page_access_token}, json={"recipient": {"id": user_id}, "sender_action": "typing_on"}, ) - async def send_reply(self, user_id: str, message: dict, context: Any) -> None: - await self.send_message(user_id=user_id, message=message) + def send_reply(self, user_id: str, message: dict, context: Any) -> None: + self.send_message(user_id=user_id, message=message) - async def send_message(self, user_id: str, message: dict) -> None: - async with httpx.AsyncClient() as client: - await client.post( + def send_message(self, user_id: str, message: dict) -> None: + with httpx.Client() as client: + client.post( self.FB_API_URL, params={"access_token": self._page_access_token}, json={ @@ -68,7 +68,7 @@ async def send_message(self, user_id: str, message: dict) -> None: }, ) - async def event_mapper(self, event: FacebookMessagingEvent) -> dict | None: + def event_mapper(self, event: FacebookMessagingEvent) -> dict | None: message = event.get("message") if message is None or "text" not in message: return None @@ -79,15 +79,15 @@ async def event_mapper(self, event: FacebookMessagingEvent) -> dict | None: "message_metadata": {"mid": message.get("mid")}, } - async def standardize_events(self, events: list[FacebookMessagingEvent]) -> list[dict]: + def standardize_events(self, events: list[FacebookMessagingEvent]) -> list[dict]: std_events: list[dict] = [] for event in events: - std_event = await self.event_mapper(event) + std_event = self.event_mapper(event) if std_event: std_events.append(std_event) return std_events - async def extract_messages(self, body: bytes, headers: dict) -> list[dict]: + def extract_messages(self, body: bytes, headers: dict) -> list[dict]: signature = headers.get("X-Hub-Signature-256", "") or headers.get( "x-hub-signature-256", "" ) @@ -103,4 +103,4 @@ async def extract_messages(self, body: bytes, headers: dict) -> list[dict]: for entry in payload.get("entry", []) for messaging_event in entry.get("messaging", []) ] - return await self.standardize_events(messaging_events) + return self.standardize_events(messaging_events) diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py index 4a07e48d1..9c49c6746 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py @@ -37,7 +37,7 @@ def __init__(self, config): channel_secret=self.provider_config.line_channel_secret, ) - async def get_user_info(self, user_id: str) -> dict: + def get_user_info(self, user_id: str) -> dict: with ApiClient(self.config) as api_client: profile = MessagingApi(api_client).get_profile(user_id) return { @@ -46,13 +46,13 @@ async def get_user_info(self, user_id: str) -> dict: "picture_url": profile.picture_url, } - async def show_typing(self, user_id: str) -> None: + def show_typing(self, user_id: str) -> None: with ApiClient(self.config) as api_client: MessagingApi(api_client).show_loading_animation( ShowLoadingAnimationRequest(chatId=user_id, loadingSeconds=60) ) - async def send_reply(self, user_id: str, message: dict, context: Any) -> None: + def send_reply(self, user_id: str, message: dict, context: Any) -> None: reply_token = context line_msg = LineTextMessage(text=message["text"]) if message.get("sender"): @@ -65,7 +65,7 @@ async def send_reply(self, user_id: str, message: dict, context: Any) -> None: ReplyMessageRequest(reply_token=reply_token, messages=[line_msg]) ) - async def send_message(self, user_id: str, message: dict) -> None: + def send_message(self, user_id: str, message: dict) -> None: line_msg = LineTextMessage(text=message["text"]) if message.get("sender"): line_msg.sender = LineSender( @@ -80,7 +80,7 @@ async def send_message(self, user_id: str, message: dict) -> None: ) ) - async def event_mapper(self, event: LineEvent) -> dict | None: + def event_mapper(self, event: LineEvent) -> dict | None: if not isinstance(event, LineMessageEvent): return None @@ -97,18 +97,18 @@ async def event_mapper(self, event: LineEvent) -> dict | None: } return None - async def standardize_events(self, events: list[LineEvent]) -> list[dict]: + def standardize_events(self, events: list[LineEvent]) -> list[dict]: std_events: list[dict] = [] for event in events: - std_event = await self.event_mapper(event) + std_event = self.event_mapper(event) if std_event: std_events.append(std_event) return std_events - async def extract_messages(self, body: bytes, headers: dict) -> list[dict]: + def extract_messages(self, body: bytes, headers: dict) -> list[dict]: signature = headers.get("x-line-signature", "") or headers.get("X-Line-Signature", "") try: events = self.parser.parse(body=body.decode(), signature=signature, as_payload=False) except InvalidSignatureError: frappe.throw("Invalid LINE signature", frappe.PermissionError) - return await self.standardize_events(events) + return self.standardize_events(events) diff --git a/raven/omni_channel_chat/webhook_handler.py b/raven/omni_channel_chat/webhook_handler.py index ed23fa5c2..e5576e3f7 100644 --- a/raven/omni_channel_chat/webhook_handler.py +++ b/raven/omni_channel_chat/webhook_handler.py @@ -66,7 +66,7 @@ def get_customer_user( return user -async def get_raven_user( +def get_raven_user( *, provider: "Provider", user: "User", @@ -83,7 +83,7 @@ async def get_raven_user( if raven_user_pk: raven_user = frappe.get_doc("Raven User", raven_user_pk) else: - user_info = await provider.get_user_info( + user_info = provider.get_user_info( user_id=user_id, ) @@ -152,7 +152,7 @@ def create_message( doc.insert(ignore_permissions=True) -async def handle_incoming_webhook_message( +def handle_incoming_webhook_message( *, provider: "Provider", message: dict, @@ -165,7 +165,7 @@ async def handle_incoming_webhook_message( frappe.set_user(user.name) - raven_user = await get_raven_user(provider=provider, user=user, user_id=message["user_id"]) + raven_user = get_raven_user(provider=provider, user=user, user_id=message["user_id"]) raven_channel = get_raven_channel(chat_integration=chat_integration, raven_user=raven_user) create_message(raven_channel=raven_channel, message=message) diff --git a/raven/raven_messaging/doctype/raven_message/raven_message.py b/raven/raven_messaging/doctype/raven_message/raven_message.py index f3e9c503b..f05de7bd4 100644 --- a/raven/raven_messaging/doctype/raven_message/raven_message.py +++ b/raven/raven_messaging/doctype/raven_message/raven_message.py @@ -715,8 +715,6 @@ def on_trash(self): channel_doc.save() def push_message_to_omni_channel_chat_provider(self) -> None: - import asyncio - if self.is_customer_message or self.is_bot_message or self.message_type == "System": return @@ -766,7 +764,7 @@ def push_message_to_omni_channel_chat_provider(self) -> None: } provider = omni_channel_chat_provider.get_provider() - asyncio.run(provider.send_message(user_id=provider_user_id, message=message)) + provider.send_message(user_id=provider_user_id, message=message) def on_doctype_update(): From 3f433af168640456aacb348a3281985ebfa41c31 Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Sun, 19 Apr 2026 08:52:58 +0000 Subject: [PATCH 07/36] refactor: convert webhook handler into class --- .../provider/base_provider.py | 8 +- raven/omni_channel_chat/webhook_handler.py | 255 ++++++++---------- 2 files changed, 117 insertions(+), 146 deletions(-) diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py index 00948be6d..99359c9f7 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any -from raven.omni_channel_chat.webhook_handler import handle_incoming_webhook_message +from raven.omni_channel_chat.webhook_handler import WebhookMessageHandler if TYPE_CHECKING: from raven.omni_channel_chat.doctype.omni_channel_chat_provider.omni_channel_chat_provider import ( @@ -17,11 +17,9 @@ def __init__(self, config: "OmniChannelChatProvider"): self.provider_config.decode_password_field() def push_message_to_raven(self, messages: list[dict]) -> None: + handler = WebhookMessageHandler(provider=self) for message in messages: - handle_incoming_webhook_message( - provider=self, - message=message, - ) + handler.handle(message) def handle_webhook(self, body: bytes, headers: dict) -> None: messages = self.extract_messages(body=body, headers=headers) diff --git a/raven/omni_channel_chat/webhook_handler.py b/raven/omni_channel_chat/webhook_handler.py index e5576e3f7..8777f5b43 100644 --- a/raven/omni_channel_chat/webhook_handler.py +++ b/raven/omni_channel_chat/webhook_handler.py @@ -17,156 +17,129 @@ ) -def get_customer_user( - *, - chat_integration: "OmniChannelChatProvider", - user_id: str, -) -> "User": - provider = chat_integration.provider - - user_pk = frappe.db.get_value( - doctype="User Social Login", - filters={ - "provider": provider, - "userid": user_id, - }, - fieldname="parent", - ) - - if user_pk: - user = frappe.get_doc("User", user_pk) - else: - username = frappe.generate_hash(length=10) - email = f"{username}@users.cafn.co" - - user = frappe.new_doc("User") - user.update( - { - "email": email, - "username": username, - "first_name": username, - "enabled": 1, - "user_type": "Website User", - } - ) - user.insert(ignore_permissions=True) - - user_social_login = frappe.new_doc("User Social Login") - user_social_login.update( - { - "provider": provider, +class WebhookMessageHandler: + def __init__(self, provider: "Provider"): + self.provider = provider + self.chat_integration: "OmniChannelChatProvider" = provider.provider_config + + def get_customer_user(self, user_id: str) -> "User": + provider_name = self.chat_integration.provider + + user_pk = frappe.db.get_value( + doctype="User Social Login", + filters={ + "provider": provider_name, "userid": user_id, - "parent": user.name, - "parenttype": "User", - "parentfield": "social_logins", - } + }, + fieldname="parent", ) - user_social_login.insert(ignore_permissions=True) - - return user - - -def get_raven_user( - *, - provider: "Provider", - user: "User", - user_id: str, -) -> "RavenUser": - raven_user_pk = frappe.db.get_value( - doctype="Raven User", - filters={ - "user": user.name, - }, - fieldname="name", - ) - if raven_user_pk: - raven_user = frappe.get_doc("Raven User", raven_user_pk) - else: - user_info = provider.get_user_info( - user_id=user_id, - ) - - raven_user = frappe.new_doc("Raven User") - raven_user.update( - { - "type": "Customer", + if user_pk: + user = frappe.get_doc("User", user_pk) + else: + username = frappe.generate_hash(length=10) + email = f"{username}@users.cafn.co" + + user = frappe.new_doc("User") + user.update( + { + "email": email, + "username": username, + "first_name": username, + "enabled": 1, + "user_type": "Website User", + } + ) + user.insert(ignore_permissions=True) + + user_social_login = frappe.new_doc("User Social Login") + user_social_login.update( + { + "provider": provider_name, + "userid": user_id, + "parent": user.name, + "parenttype": "User", + "parentfield": "social_logins", + } + ) + user_social_login.insert(ignore_permissions=True) + + return user + + def get_raven_user(self, *, user: "User", user_id: str) -> "RavenUser": + raven_user_pk = frappe.db.get_value( + doctype="Raven User", + filters={ "user": user.name, - "full_name": user_info["display_name"], - "user_image": user_info["picture_url"], - "enabled": True, - } + }, + fieldname="name", ) - raven_user.insert(ignore_permissions=True) - - return raven_user - -def get_raven_channel( - *, - chat_integration: "OmniChannelChatProvider", - raven_user: "RavenUser", -) -> "RavenChannel": - channel_name = f"{chat_integration.name}_{raven_user.name}" - channel_pk = frappe.db.get_value( - doctype="Raven Channel", - filters=channel_name, - fieldname="name", - ) + if raven_user_pk: + raven_user = frappe.get_doc("Raven User", raven_user_pk) + else: + user_info = self.provider.get_user_info(user_id=user_id) + + raven_user = frappe.new_doc("Raven User") + raven_user.update( + { + "type": "Customer", + "user": user.name, + "full_name": user_info["display_name"], + "user_image": user_info["picture_url"], + "enabled": True, + } + ) + raven_user.insert(ignore_permissions=True) + + return raven_user + + def get_raven_channel(self, raven_user: "RavenUser") -> "RavenChannel": + channel_name = f"{self.chat_integration.name}_{raven_user.name}" + channel_pk = frappe.db.get_value( + doctype="Raven Channel", + filters=channel_name, + fieldname="name", + ) - if channel_pk: - channel = frappe.get_doc("Raven Channel", channel_pk) - else: - channel = frappe.get_doc( + if channel_pk: + channel = frappe.get_doc("Raven Channel", channel_pk) + else: + channel = frappe.get_doc( + { + "doctype": "Raven Channel", + "channel_name": channel_name, + "id": channel_name, + "type": "Public", + "customer_user": raven_user.user, + "omni_channel_chat_provider": self.chat_integration.name, + "is_customer": True, + "enabled": True, + "workspace": self.chat_integration.raven_workspace, + } + ).insert(ignore_permissions=True) + + return channel + + def create_message(self, *, raven_channel: "RavenChannel", message: dict) -> None: + doc = frappe.new_doc(doctype="Raven Message") + doc.update( { - "doctype": "Raven Channel", - "channel_name": channel_name, - "id": channel_name, - "type": "Public", - "customer_user": raven_user.user, - "omni_channel_chat_provider": chat_integration.name, - "is_customer": True, - "enabled": True, - "workspace": chat_integration.raven_workspace, + "channel_id": raven_channel.name, + "message_type": message["message"]["type"], + "text": message["message"]["text"], + "is_customer_message": True, + "owner": raven_channel.customer_user, } - ).insert(ignore_permissions=True) - - return channel - - -def create_message( - *, - raven_channel: "RavenChannel", - message: dict, -) -> None: - doc = frappe.new_doc(doctype="Raven Message") - doc.update( - { - "channel_id": raven_channel.name, - "message_type": message["message"]["type"], - "text": message["message"]["text"], - "is_customer_message": True, - "owner": raven_channel.customer_user, - } - ) - doc.insert(ignore_permissions=True) - - -def handle_incoming_webhook_message( - *, - provider: "Provider", - message: dict, -) -> "RavenChannel": - chat_integration = provider.provider_config - user = get_customer_user( - chat_integration=chat_integration, - user_id=message["user_id"], - ) + ) + doc.insert(ignore_permissions=True) - frappe.set_user(user.name) + def handle(self, message: dict) -> "RavenChannel": + user = self.get_customer_user(user_id=message["user_id"]) + frappe.set_user(user.name) - raven_user = get_raven_user(provider=provider, user=user, user_id=message["user_id"]) - raven_channel = get_raven_channel(chat_integration=chat_integration, raven_user=raven_user) - create_message(raven_channel=raven_channel, message=message) + raven_user = self.get_raven_user(user=user, user_id=message["user_id"]) + raven_channel = self.get_raven_channel(raven_user=raven_user) + self.create_message(raven_channel=raven_channel, message=message) - return raven_channel + return raven_channel From c8305bad5c6ff7cc203dd8ac6da3efdd3ea95d63 Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Sun, 19 Apr 2026 08:54:40 +0000 Subject: [PATCH 08/36] fix: rename webhook handler --- .../omni_channel_chat_provider/provider/base_provider.py | 4 ++-- raven/omni_channel_chat/webhook_handler.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py index 99359c9f7..ea6e0326e 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any -from raven.omni_channel_chat.webhook_handler import WebhookMessageHandler +from raven.omni_channel_chat.webhook_handler import WebhookHandler if TYPE_CHECKING: from raven.omni_channel_chat.doctype.omni_channel_chat_provider.omni_channel_chat_provider import ( @@ -17,7 +17,7 @@ def __init__(self, config: "OmniChannelChatProvider"): self.provider_config.decode_password_field() def push_message_to_raven(self, messages: list[dict]) -> None: - handler = WebhookMessageHandler(provider=self) + handler = WebhookHandler(provider=self) for message in messages: handler.handle(message) diff --git a/raven/omni_channel_chat/webhook_handler.py b/raven/omni_channel_chat/webhook_handler.py index 8777f5b43..f67b0bd61 100644 --- a/raven/omni_channel_chat/webhook_handler.py +++ b/raven/omni_channel_chat/webhook_handler.py @@ -17,7 +17,7 @@ ) -class WebhookMessageHandler: +class WebhookHandler: def __init__(self, provider: "Provider"): self.provider = provider self.chat_integration: "OmniChannelChatProvider" = provider.provider_config From d5c26d4c36414155d11ca3eb8aae2a068e6e502e Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Sun, 19 Apr 2026 09:02:04 +0000 Subject: [PATCH 09/36] refactor: improve gate --- raven/raven_messaging/doctype/raven_message/raven_message.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/raven/raven_messaging/doctype/raven_message/raven_message.py b/raven/raven_messaging/doctype/raven_message/raven_message.py index f05de7bd4..cac1686eb 100644 --- a/raven/raven_messaging/doctype/raven_message/raven_message.py +++ b/raven/raven_messaging/doctype/raven_message/raven_message.py @@ -723,11 +723,10 @@ def push_message_to_omni_channel_chat_provider(self) -> None: filters=self.channel_id, fieldname=["is_customer", "customer_user", "omni_channel_chat_provider"], as_dict=True, + cache=True, ) - if not channel or not channel.is_customer: - return - if not channel.omni_channel_chat_provider: + if not channel or not channel.is_customer or not channel.omni_channel_chat_provider: return omni_channel_chat_provider = frappe.get_doc( From f7251d443f7feda2537713d139346b778631351c Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Sun, 19 Apr 2026 09:36:47 +0000 Subject: [PATCH 10/36] refactor: update connector --- .../provider/base_provider.py | 6 +- .../provider/line_provider.py | 15 +- raven/omni_channel_chat/webhook_handler.py | 244 +++++++++++++----- .../doctype/raven_message/raven_message.json | 10 +- .../doctype/raven_message/raven_message.py | 55 +--- 5 files changed, 204 insertions(+), 126 deletions(-) diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py index ea6e0326e..c950b28ff 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any -from raven.omni_channel_chat.webhook_handler import WebhookHandler +from raven.omni_channel_chat.webhook_handler import OmniChannelConnector if TYPE_CHECKING: from raven.omni_channel_chat.doctype.omni_channel_chat_provider.omni_channel_chat_provider import ( @@ -17,9 +17,9 @@ def __init__(self, config: "OmniChannelChatProvider"): self.provider_config.decode_password_field() def push_message_to_raven(self, messages: list[dict]) -> None: - handler = WebhookHandler(provider=self) + handler = OmniChannelConnector(provider=self) for message in messages: - handler.handle(message) + handler.receive_from_provider(message) def handle_webhook(self, body: bytes, headers: dict) -> None: messages = self.extract_messages(body=body, headers=headers) diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py index 9c49c6746..8b1a4e86c 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py @@ -53,16 +53,25 @@ def show_typing(self, user_id: str) -> None: ) def send_reply(self, user_id: str, message: dict, context: Any) -> None: - reply_token = context + reply_token = (context or {}).get("reply_token") line_msg = LineTextMessage(text=message["text"]) if message.get("sender"): line_msg.sender = LineSender( name=message["sender"].get("name"), icon_url=message["sender"].get("icon_url"), ) + if reply_token: + try: + with ApiClient(self.config) as api_client: + MessagingApi(api_client).reply_message( + ReplyMessageRequest(reply_token=reply_token, messages=[line_msg]) + ) + return + except Exception: + pass with ApiClient(self.config) as api_client: - MessagingApi(api_client).reply_message( - ReplyMessageRequest(reply_token=reply_token, messages=[line_msg]) + MessagingApi(api_client).push_message( + PushMessageRequest(to=user_id, messages=[line_msg]) ) def send_message(self, user_id: str, message: dict) -> None: diff --git a/raven/omni_channel_chat/webhook_handler.py b/raven/omni_channel_chat/webhook_handler.py index f67b0bd61..6ce746cf6 100644 --- a/raven/omni_channel_chat/webhook_handler.py +++ b/raven/omni_channel_chat/webhook_handler.py @@ -1,6 +1,7 @@ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast import frappe +from frappe.utils import get_url if TYPE_CHECKING: from frappe.core.doctype.user.user import User @@ -15,86 +16,90 @@ from raven.raven_channel_management.doctype.raven_channel.raven_channel import ( RavenChannel, ) + from raven.raven_messaging.doctype.raven_message.raven_message import RavenMessage -class WebhookHandler: +class OmniChannelConnector: + """Bridges Raven and an external omni-channel provider. + + Two main interfaces: + receive_from_provider – inbound path: provider webhook payload → Raven message + push_to_provider – outbound path: Raven message → external provider + """ + def __init__(self, provider: "Provider"): self.provider = provider self.chat_integration: "OmniChannelChatProvider" = provider.provider_config - def get_customer_user(self, user_id: str) -> "User": + # ── shared helpers ────────────────────────────────────────────────────── + + def _get_or_create_customer_user(self, user_id: str) -> "User": provider_name = self.chat_integration.provider user_pk = frappe.db.get_value( doctype="User Social Login", - filters={ - "provider": provider_name, - "userid": user_id, - }, + filters={"provider": provider_name, "userid": user_id}, fieldname="parent", ) if user_pk: - user = frappe.get_doc("User", user_pk) - else: - username = frappe.generate_hash(length=10) - email = f"{username}@users.cafn.co" + return cast("User", frappe.get_doc("User", str(user_pk))) - user = frappe.new_doc("User") - user.update( - { - "email": email, - "username": username, - "first_name": username, - "enabled": 1, - "user_type": "Website User", - } - ) - user.insert(ignore_permissions=True) + username = frappe.generate_hash(length=10) + email = f"{username}@users.cafn.co" - user_social_login = frappe.new_doc("User Social Login") - user_social_login.update( - { - "provider": provider_name, - "userid": user_id, - "parent": user.name, - "parenttype": "User", - "parentfield": "social_logins", - } - ) - user_social_login.insert(ignore_permissions=True) + user = frappe.new_doc("User") + user.update( + { + "email": email, + "username": username, + "first_name": username, + "enabled": 1, + "user_type": "Website User", + } + ) + user.insert(ignore_permissions=True) - return user + user_social_login = frappe.new_doc("User Social Login") + user_social_login.update( + { + "provider": provider_name, + "userid": user_id, + "parent": user.name, + "parenttype": "User", + "parentfield": "social_logins", + } + ) + user_social_login.insert(ignore_permissions=True) - def get_raven_user(self, *, user: "User", user_id: str) -> "RavenUser": + return cast("User", user) + + def _get_or_create_raven_user(self, *, user: "User", user_id: str) -> "RavenUser": raven_user_pk = frappe.db.get_value( doctype="Raven User", - filters={ - "user": user.name, - }, + filters={"user": user.name}, fieldname="name", ) if raven_user_pk: - raven_user = frappe.get_doc("Raven User", raven_user_pk) - else: - user_info = self.provider.get_user_info(user_id=user_id) + return cast("RavenUser", frappe.get_doc("Raven User", str(raven_user_pk))) - raven_user = frappe.new_doc("Raven User") - raven_user.update( - { - "type": "Customer", - "user": user.name, - "full_name": user_info["display_name"], - "user_image": user_info["picture_url"], - "enabled": True, - } - ) - raven_user.insert(ignore_permissions=True) + user_info = self.provider.get_user_info(user_id=user_id) + raven_user = frappe.new_doc("Raven User") + raven_user.update( + { + "type": "Customer", + "user": user.name, + "full_name": user_info["display_name"], + "user_image": user_info["picture_url"], + "enabled": True, + } + ) + raven_user.insert(ignore_permissions=True) - return raven_user + return cast("RavenUser", raven_user) - def get_raven_channel(self, raven_user: "RavenUser") -> "RavenChannel": + def _get_or_create_channel(self, raven_user: "RavenUser") -> "RavenChannel": channel_name = f"{self.chat_integration.name}_{raven_user.name}" channel_pk = frappe.db.get_value( doctype="Raven Channel", @@ -103,9 +108,11 @@ def get_raven_channel(self, raven_user: "RavenUser") -> "RavenChannel": ) if channel_pk: - channel = frappe.get_doc("Raven Channel", channel_pk) - else: - channel = frappe.get_doc( + return cast("RavenChannel", frappe.get_doc("Raven Channel", str(channel_pk))) + + return cast( + "RavenChannel", + frappe.get_doc( { "doctype": "Raven Channel", "channel_name": channel_name, @@ -117,11 +124,51 @@ def get_raven_channel(self, raven_user: "RavenUser") -> "RavenChannel": "enabled": True, "workspace": self.chat_integration.raven_workspace, } - ).insert(ignore_permissions=True) + ).insert(ignore_permissions=True), + ) + + def _get_external_user_id(self, customer_user: str) -> str: + """Resolve the provider's external user_id for a given Frappe user.""" + user_id = frappe.db.get_value( + doctype="User Social Login", + filters={ + "provider": self.chat_integration.provider, + "parent": customer_user, + }, + fieldname="userid", + ) + if not user_id: + frappe.throw( + f"No {self.chat_integration.provider} social login found for user {customer_user}" + ) + return cast(str, user_id) - return channel + # ── interface 1: provider → Raven (inbound) ───────────────────────────── + + def handle_webhook(self, body: bytes, headers: dict) -> None: + """Parse a raw webhook payload and persist all contained messages to Raven.""" + messages = self.provider.extract_messages(body=body, headers=headers) + for message in messages: + self.receive_from_provider(message) + + def receive_from_provider(self, message: dict) -> "RavenChannel": + """Inbound: turn a provider webhook payload into a Raven message. + + Creates the Frappe user, Raven user, and channel on first contact, + then appends the message to the channel. + + Returns the Raven channel the message was posted to. + """ + user = self._get_or_create_customer_user(user_id=message["user_id"]) + frappe.set_user(user.name) + + raven_user = self._get_or_create_raven_user(user=user, user_id=message["user_id"]) + raven_channel = self._get_or_create_channel(raven_user=raven_user) + self._save_inbound_message(raven_channel=raven_channel, message=message) + + return raven_channel - def create_message(self, *, raven_channel: "RavenChannel", message: dict) -> None: + def _save_inbound_message(self, *, raven_channel: "RavenChannel", message: dict) -> None: doc = frappe.new_doc(doctype="Raven Message") doc.update( { @@ -130,16 +177,79 @@ def create_message(self, *, raven_channel: "RavenChannel", message: dict) -> Non "text": message["message"]["text"], "is_customer_message": True, "owner": raven_channel.customer_user, + "omni_channel_msg_meta": message.get("message_metadata"), } ) doc.insert(ignore_permissions=True) - def handle(self, message: dict) -> "RavenChannel": - user = self.get_customer_user(user_id=message["user_id"]) - frappe.set_user(user.name) + # ── interface 2: Raven → provider (outbound) ──────────────────────────── + + @classmethod + def push_to_provider(cls, raven_message: "RavenMessage") -> None: + """Outbound: forward a staff Raven message to the customer on the external provider. + + Handles guard conditions, channel/provider resolution, message payload building + (text + sender avatar), reply-context fetch (e.g. LINE reply token), and dispatch. + Does nothing if the message is from a customer, a bot, or is a system message, + or if the channel is not an omni-channel customer channel. + """ + if raven_message.is_customer_message or raven_message.is_bot_message or raven_message.message_type == "System": + return + + channel = cast( + "dict | None", + frappe.db.get_value( + doctype="Raven Channel", + filters=raven_message.channel_id, + fieldname=["is_customer", "customer_user", "omni_channel_chat_provider"], + as_dict=True, + cache=True, + ), + ) - raven_user = self.get_raven_user(user=user, user_id=message["user_id"]) - raven_channel = self.get_raven_channel(raven_user=raven_user) - self.create_message(raven_channel=raven_channel, message=message) + if not channel or not channel["is_customer"] or not channel["omni_channel_chat_provider"]: + return - return raven_channel + provider_config = frappe.get_doc("Omni Channel Chat Provider", channel["omni_channel_chat_provider"]) + connector = cls(provider=provider_config.get_provider()) + + user_id = frappe.db.get_value( + doctype="User Social Login", + filters={"provider": connector.chat_integration.provider, "parent": channel["customer_user"]}, + fieldname="userid", + ) + if not user_id: + return + + message: dict = {"text": raven_message.content} + + sender = cast( + "dict | None", + frappe.db.get_value("Raven User", raven_message.owner, ["full_name", "user_image"], as_dict=True), + ) + if sender: + avatar_url = cast("str | None", sender["user_image"]) + if avatar_url and avatar_url.startswith("/"): + avatar_url = get_url(avatar_url) + message["sender"] = { + "name": sender["full_name"] or None, + "icon_url": avatar_url or None, + } + + context = frappe.parse_json( + cast( + str, + frappe.db.get_value( + "Raven Message", + filters={"channel_id": raven_message.channel_id, "is_customer_message": 1}, + fieldname="omni_channel_msg_meta", + order_by="creation desc", + ), + ) + ) + + connector.provider.send_reply(user_id=str(user_id), message=message, context=context) + + +# Backwards-compatibility alias — remove once all call-sites are updated. +WebhookHandler = OmniChannelConnector diff --git a/raven/raven_messaging/doctype/raven_message/raven_message.json b/raven/raven_messaging/doctype/raven_message/raven_message.json index b5c86c7b7..b5536c5d5 100644 --- a/raven/raven_messaging/doctype/raven_message/raven_message.json +++ b/raven/raven_messaging/doctype/raven_message/raven_message.json @@ -37,7 +37,8 @@ "bot", "hide_link_preview", "notification", - "is_customer_message" + "is_customer_message", + "omni_channel_msg_meta" ], "fields": [ { @@ -215,11 +216,16 @@ "fieldname": "is_customer_message", "fieldtype": "Check", "label": "Is Customer Message" + }, + { + "fieldname": "omni_channel_msg_meta", + "fieldtype": "JSON", + "label": "Omni-Channel Message Meta" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2026-04-06 15:26:23.592738", + "modified": "2026-04-19 16:05:30.311091", "modified_by": "Administrator", "module": "Raven Messaging", "name": "Raven Message", diff --git a/raven/raven_messaging/doctype/raven_message/raven_message.py b/raven/raven_messaging/doctype/raven_message/raven_message.py index cac1686eb..813416aaf 100644 --- a/raven/raven_messaging/doctype/raven_message/raven_message.py +++ b/raven/raven_messaging/doctype/raven_message/raven_message.py @@ -35,9 +35,7 @@ class RavenMessage(Document): if TYPE_CHECKING: from frappe.types import DF - from raven.raven_messaging.doctype.raven_mention.raven_mention import ( - RavenMention, - ) + from raven.raven_messaging.doctype.raven_mention.raven_mention import RavenMention blurhash: DF.SmallText | None bot: DF.Link | None @@ -63,6 +61,7 @@ class RavenMessage(Document): message_reactions: DF.JSON | None message_type: DF.Literal["Text", "Image", "File", "Poll", "System"] notification: DF.Data | None + omni_channel_msg_meta: DF.JSON | None poll_id: DF.Link | None replied_message_details: DF.JSON | None text: DF.LongText | None @@ -715,55 +714,9 @@ def on_trash(self): channel_doc.save() def push_message_to_omni_channel_chat_provider(self) -> None: - if self.is_customer_message or self.is_bot_message or self.message_type == "System": - return - - channel = frappe.db.get_value( - doctype="Raven Channel", - filters=self.channel_id, - fieldname=["is_customer", "customer_user", "omni_channel_chat_provider"], - as_dict=True, - cache=True, - ) - - if not channel or not channel.is_customer or not channel.omni_channel_chat_provider: - return - - omni_channel_chat_provider = frappe.get_doc( - "Omni Channel Chat Provider", channel.omni_channel_chat_provider - ) - - provider_user_id = frappe.db.get_value( - doctype="User Social Login", - filters={ - "provider": omni_channel_chat_provider.provider, - "parent": channel.customer_user, - }, - fieldname="userid", - ) - - if not provider_user_id: - return - - message = {"text": self.content} - - user = frappe.db.get_value( - "Raven User", - self.owner, - ["full_name", "user_image"], - as_dict=True, - ) - if user: - avatar_url = user.user_image - if avatar_url and avatar_url.startswith("/"): - avatar_url = frappe.utils.get_url(avatar_url) - message["sender"] = { - "name": user.full_name or None, - "icon_url": avatar_url or None, - } + from raven.omni_channel_chat.webhook_handler import OmniChannelConnector - provider = omni_channel_chat_provider.get_provider() - provider.send_message(user_id=provider_user_id, message=message) + OmniChannelConnector.push_to_provider(raven_message=self) def on_doctype_update(): From f104e4901ebed62416ea2cafe0f2f8b96a1e4feb Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Sun, 19 Apr 2026 09:53:27 +0000 Subject: [PATCH 11/36] refactor: rename class name --- .../provider/base_provider.py | 4 +-- ...ler.py => omni_channel_raven_connector.py} | 25 ++++++++++++------- .../doctype/raven_message/raven_message.py | 4 +-- 3 files changed, 20 insertions(+), 13 deletions(-) rename raven/omni_channel_chat/{webhook_handler.py => omni_channel_raven_connector.py} (93%) diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py index c950b28ff..f990fa46f 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any -from raven.omni_channel_chat.webhook_handler import OmniChannelConnector +from raven.omni_channel_chat.omni_channel_raven_connector import OmniChannelRavenConnector if TYPE_CHECKING: from raven.omni_channel_chat.doctype.omni_channel_chat_provider.omni_channel_chat_provider import ( @@ -17,7 +17,7 @@ def __init__(self, config: "OmniChannelChatProvider"): self.provider_config.decode_password_field() def push_message_to_raven(self, messages: list[dict]) -> None: - handler = OmniChannelConnector(provider=self) + handler = OmniChannelRavenConnector(provider=self) for message in messages: handler.receive_from_provider(message) diff --git a/raven/omni_channel_chat/webhook_handler.py b/raven/omni_channel_chat/omni_channel_raven_connector.py similarity index 93% rename from raven/omni_channel_chat/webhook_handler.py rename to raven/omni_channel_chat/omni_channel_raven_connector.py index 6ce746cf6..6df23d231 100644 --- a/raven/omni_channel_chat/webhook_handler.py +++ b/raven/omni_channel_chat/omni_channel_raven_connector.py @@ -19,7 +19,7 @@ from raven.raven_messaging.doctype.raven_message.raven_message import RavenMessage -class OmniChannelConnector: +class OmniChannelRavenConnector: """Bridges Raven and an external omni-channel provider. Two main interfaces: @@ -193,7 +193,11 @@ def push_to_provider(cls, raven_message: "RavenMessage") -> None: Does nothing if the message is from a customer, a bot, or is a system message, or if the channel is not an omni-channel customer channel. """ - if raven_message.is_customer_message or raven_message.is_bot_message or raven_message.message_type == "System": + if ( + raven_message.is_customer_message + or raven_message.is_bot_message + or raven_message.message_type == "System" + ): return channel = cast( @@ -210,12 +214,17 @@ def push_to_provider(cls, raven_message: "RavenMessage") -> None: if not channel or not channel["is_customer"] or not channel["omni_channel_chat_provider"]: return - provider_config = frappe.get_doc("Omni Channel Chat Provider", channel["omni_channel_chat_provider"]) + provider_config = frappe.get_doc( + "Omni Channel Chat Provider", channel["omni_channel_chat_provider"] + ) connector = cls(provider=provider_config.get_provider()) user_id = frappe.db.get_value( doctype="User Social Login", - filters={"provider": connector.chat_integration.provider, "parent": channel["customer_user"]}, + filters={ + "provider": connector.chat_integration.provider, + "parent": channel["customer_user"], + }, fieldname="userid", ) if not user_id: @@ -225,7 +234,9 @@ def push_to_provider(cls, raven_message: "RavenMessage") -> None: sender = cast( "dict | None", - frappe.db.get_value("Raven User", raven_message.owner, ["full_name", "user_image"], as_dict=True), + frappe.db.get_value( + "Raven User", raven_message.owner, ["full_name", "user_image"], as_dict=True + ), ) if sender: avatar_url = cast("str | None", sender["user_image"]) @@ -249,7 +260,3 @@ def push_to_provider(cls, raven_message: "RavenMessage") -> None: ) connector.provider.send_reply(user_id=str(user_id), message=message, context=context) - - -# Backwards-compatibility alias — remove once all call-sites are updated. -WebhookHandler = OmniChannelConnector diff --git a/raven/raven_messaging/doctype/raven_message/raven_message.py b/raven/raven_messaging/doctype/raven_message/raven_message.py index 813416aaf..6701f0baf 100644 --- a/raven/raven_messaging/doctype/raven_message/raven_message.py +++ b/raven/raven_messaging/doctype/raven_message/raven_message.py @@ -714,9 +714,9 @@ def on_trash(self): channel_doc.save() def push_message_to_omni_channel_chat_provider(self) -> None: - from raven.omni_channel_chat.webhook_handler import OmniChannelConnector + from raven.omni_channel_chat.omni_channel_raven_connector import OmniChannelRavenConnector - OmniChannelConnector.push_to_provider(raven_message=self) + OmniChannelRavenConnector.push_to_provider(raven_message=self) def on_doctype_update(): From 4b79c824b821d6e63652fdab94c1a57ea38a41a4 Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Mon, 20 Apr 2026 06:55:03 +0000 Subject: [PATCH 12/36] refactor: move webhook handler into provider --- raven/omni_channel_chat/api/webhooks.py | 42 +++---------------- .../provider/base_provider.py | 6 +++ .../provider/facebook_provider.py | 19 +++++++++ .../provider/line_provider.py | 6 +++ 4 files changed, 37 insertions(+), 36 deletions(-) diff --git a/raven/omni_channel_chat/api/webhooks.py b/raven/omni_channel_chat/api/webhooks.py index b1fc54168..d79992aff 100644 --- a/raven/omni_channel_chat/api/webhooks.py +++ b/raven/omni_channel_chat/api/webhooks.py @@ -6,7 +6,7 @@ ) -def extract_slug() -> str: +def extract_provider_slug() -> str: """Extract the trailing path segment (slug) from the current request URL. Strips a trailing slash if present, then returns the last path component. @@ -17,46 +17,16 @@ def extract_slug() -> str: str: The slug portion of the request path. Example: - For a request to `/api/method/raven.omni_channel_chat.api.webhooks.line/g9ju6k0e8r`, + For a request to `/api/method/raven.omni_channel_chat.api.webhooks.handle/g9ju6k0e8r`, this returns `g9ju6k0e8r`. """ request = frappe.local.request return request.path.rstrip("/").rsplit("/", 1)[-1] -@frappe.whitelist(allow_guest=True, methods=["POST"]) -def line() -> dict: - slug = extract_slug() - request = frappe.local.request - body: bytes = request.get_data() - headers: dict = dict(request.headers) - - omni_channel_chat = get_omni_channel_chat(slug=slug) - provider = omni_channel_chat.get_provider() - provider.handle_webhook(body=body, headers=headers) - return {"status": "ok"} - - -@frappe.whitelist(allow_guest=True, methods=["GET", "POST"]) -def facebook() -> dict | Response | None: - slug = extract_slug() - request = frappe.local.request - +@frappe.whitelist(allow_guest=True, methods=["POST", "GET"]) +def handle() -> Response: + slug = extract_provider_slug() omni_channel_chat = get_omni_channel_chat(slug=slug) provider = omni_channel_chat.get_provider() - - if request.method == "GET": - mode = frappe.form_dict.get("hub.mode") - verify_token = frappe.form_dict.get("hub.verify_token") - challenge = frappe.form_dict.get("hub.challenge", "0") - - if mode == "subscribe" and verify_token == provider._verify_token: - return Response(challenge, status=200, content_type="text/plain") - else: - frappe.throw("Verification failed", frappe.PermissionError) - - body: bytes = request.get_data() - headers: dict = dict(request.headers) - - provider.handle_webhook(body=body, headers=headers) - return {"status": "ok"} + return provider.handle_frappe_api() diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py index f990fa46f..2f223f253 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py @@ -1,6 +1,8 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any +from werkzeug.wrappers import Response + from raven.omni_channel_chat.omni_channel_raven_connector import OmniChannelRavenConnector if TYPE_CHECKING: @@ -25,6 +27,10 @@ def handle_webhook(self, body: bytes, headers: dict) -> None: messages = self.extract_messages(body=body, headers=headers) self.push_message_to_raven(messages=messages) + @abstractmethod + def handle_frappe_api(self) -> Response: + """Extract data from frappe request and pass to handle webhook.""" + @abstractmethod def get_user_info(self, user_id: str) -> dict: """Fetch user info from the provider's platform.""" diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py index 6077f253c..925d104ee 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py @@ -5,6 +5,7 @@ import frappe import httpx +from werkzeug.wrappers import Response from raven.omni_channel_chat.doctype.omni_channel_chat_provider.provider import ( Provider, @@ -23,6 +24,24 @@ def __init__(self, config): self._app_secret = self.provider_config.fb_app_secret self._verify_token = self.provider_config.fb_verify_token + def handle_frappe_api(self): + request = frappe.local.request + + if request.method == "GET": + mode = frappe.form_dict.get("hub.mode") + verify_token = frappe.form_dict.get("hub.verify_token") + challenge = frappe.form_dict.get("hub.challenge", "0") + + if mode == "subscribe" and verify_token == self._verify_token: + return Response(challenge, status=200, content_type="text/plain") + else: + frappe.throw("Verification failed", frappe.PermissionError) + + body: bytes = request.get_data() + headers: dict = dict(request.headers) + + return self.handle_webhook(body=body, headers=headers) + def _verify_signature(self, body: bytes, signature_header: str) -> bool: if not signature_header.startswith("sha256="): return False diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py index 8b1a4e86c..7a45a0a21 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py @@ -37,6 +37,12 @@ def __init__(self, config): channel_secret=self.provider_config.line_channel_secret, ) + def handle_frappe_api(self): + request = frappe.local.request + body: bytes = request.get_data() + headers: dict = dict(request.headers) + return self.handle_webhook(body=body, headers=headers) + def get_user_info(self, user_id: str) -> dict: with ApiClient(self.config) as api_client: profile = MessagingApi(api_client).get_profile(user_id) From 2cd83424ff380c55c9e19c3345d62dcecf2c8693 Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Mon, 20 Apr 2026 07:13:08 +0000 Subject: [PATCH 13/36] feat: add file and image inbound support --- .../provider/facebook_provider.py | 52 ++++++++++++++++--- .../provider/line_provider.py | 36 +++++++++++-- .../omni_channel_raven_connector.py | 17 +++++- 3 files changed, 93 insertions(+), 12 deletions(-) diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py index 925d104ee..7475f68b8 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py @@ -87,16 +87,54 @@ def send_message(self, user_id: str, message: dict) -> None: }, ) + def _download_attachment(self, url: str, default_name: str) -> tuple[bytes, str]: + with httpx.Client() as client: + response = client.get(url) + response.raise_for_status() + content_disposition = response.headers.get("content-disposition", "") + file_name = default_name + if "filename=" in content_disposition: + file_name = content_disposition.split("filename=")[-1].strip('" ') + return response.content, file_name + def event_mapper(self, event: FacebookMessagingEvent) -> dict | None: message = event.get("message") - if message is None or "text" not in message: + if message is None: return None - return { - "provider": self.provider_config.provider, - "user_id": event["sender"]["id"], - "message": {"type": "Text", "text": message["text"]}, - "message_metadata": {"mid": message.get("mid")}, - } + + mid = message.get("mid") + + if "text" in message: + return { + "provider": self.provider_config.provider, + "user_id": event["sender"]["id"], + "message": {"type": "Text", "text": message["text"]}, + "message_metadata": {"mid": mid}, + } + + for attachment in message.get("attachments") or []: + att_type = attachment.get("type") + url = attachment.get("payload", {}).get("url") + if not url: + continue + if att_type == "image": + content, file_name = self._download_attachment(url, f"{mid or 'image'}.jpg") + return { + "provider": self.provider_config.provider, + "user_id": event["sender"]["id"], + "message": {"type": "Image", "file_name": file_name, "file_content": content}, + "message_metadata": {"mid": mid}, + } + if att_type in ("file", "document"): + content, file_name = self._download_attachment(url, mid or "file") + return { + "provider": self.provider_config.provider, + "user_id": event["sender"]["id"], + "message": {"type": "File", "file_name": file_name, "file_content": content}, + "message_metadata": {"mid": mid}, + } + + return None def standardize_events(self, events: list[FacebookMessagingEvent]) -> list[dict]: std_events: list[dict] = [] diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py index 7a45a0a21..b41a966e5 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py @@ -6,6 +6,7 @@ ApiClient, Configuration, MessagingApi, + MessagingApiBlob, PushMessageRequest, ReplyMessageRequest, ShowLoadingAnimationRequest, @@ -17,6 +18,7 @@ from linebot.v3.messaging import TextMessage as LineTextMessage from linebot.v3.webhook import WebhookParser from linebot.v3.webhooks import Event as LineEvent +from linebot.v3.webhooks import FileMessageContent, ImageMessageContent from linebot.v3.webhooks import MessageEvent as LineMessageEvent from linebot.v3.webhooks import TextMessageContent @@ -95,21 +97,49 @@ def send_message(self, user_id: str, message: dict) -> None: ) ) + def _download_line_content(self, message_id: str) -> bytes: + with ApiClient(self.config) as api_client: + return bytes(MessagingApiBlob(api_client).get_message_content(message_id)) + def event_mapper(self, event: LineEvent) -> dict | None: if not isinstance(event, LineMessageEvent): return None msg = event.message + metadata = {"message_id": msg.id, "reply_token": event.reply_token} + if isinstance(msg, TextMessageContent): return { "provider": self.provider_config.provider, "user_id": event.source.user_id, "message": {"type": "Text", "text": msg.text}, - "message_metadata": { - "message_id": msg.id, - "reply_token": event.reply_token, + "message_metadata": metadata, + } + + if isinstance(msg, ImageMessageContent): + return { + "provider": self.provider_config.provider, + "user_id": event.source.user_id, + "message": { + "type": "Image", + "file_name": f"{msg.id}.jpg", + "file_content": self._download_line_content(msg.id), }, + "message_metadata": metadata, } + + if isinstance(msg, FileMessageContent): + return { + "provider": self.provider_config.provider, + "user_id": event.source.user_id, + "message": { + "type": "File", + "file_name": msg.file_name, + "file_content": self._download_line_content(msg.id), + }, + "message_metadata": metadata, + } + return None def standardize_events(self, events: list[LineEvent]) -> list[dict]: diff --git a/raven/omni_channel_chat/omni_channel_raven_connector.py b/raven/omni_channel_chat/omni_channel_raven_connector.py index 6df23d231..ed387b24d 100644 --- a/raven/omni_channel_chat/omni_channel_raven_connector.py +++ b/raven/omni_channel_chat/omni_channel_raven_connector.py @@ -169,17 +169,30 @@ def receive_from_provider(self, message: dict) -> "RavenChannel": return raven_channel def _save_inbound_message(self, *, raven_channel: "RavenChannel", message: dict) -> None: + msg = message["message"] doc = frappe.new_doc(doctype="Raven Message") doc.update( { "channel_id": raven_channel.name, - "message_type": message["message"]["type"], - "text": message["message"]["text"], + "message_type": msg["type"], "is_customer_message": True, "owner": raven_channel.customer_user, "omni_channel_msg_meta": message.get("message_metadata"), } ) + if msg["type"] == "Text": + doc.text = msg["text"] + elif msg["type"] in ("Image", "File"): + file_doc = frappe.get_doc( + { + "doctype": "File", + "file_name": msg["file_name"], + "content": msg["file_content"], + "is_private": 0, + } + ) + file_doc.insert(ignore_permissions=True) + doc.file = file_doc.file_url doc.insert(ignore_permissions=True) # ── interface 2: Raven → provider (outbound) ──────────────────────────── From 2deefb5ef5123af994ba6cc1b905344791667f4b Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Mon, 20 Apr 2026 07:54:24 +0000 Subject: [PATCH 14/36] feat: add file and image handler --- .../provider/facebook_provider.py | 22 ++++++-- .../provider/line_provider.py | 52 +++++++++++++------ .../omni_channel_raven_connector.py | 11 +++- .../doctype/raven_message/raven_message.py | 7 ++- 4 files changed, 69 insertions(+), 23 deletions(-) diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py index 7475f68b8..4517ca780 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py @@ -77,14 +77,28 @@ def send_reply(self, user_id: str, message: dict, context: Any) -> None: self.send_message(user_id=user_id, message=message) def send_message(self, user_id: str, message: dict) -> None: + msg_type = message.get("type", "Text") + if msg_type == "Image": + fb_message = { + "attachment": { + "type": "image", + "payload": {"url": message["file_url"], "is_reusable": True}, + } + } + elif msg_type == "File": + fb_message = { + "attachment": { + "type": "file", + "payload": {"url": message["file_url"], "is_reusable": True}, + } + } + else: + fb_message = {"text": message["text"]} with httpx.Client() as client: client.post( self.FB_API_URL, params={"access_token": self._page_access_token}, - json={ - "recipient": {"id": user_id}, - "message": {"text": message["text"]}, - }, + json={"recipient": {"id": user_id}, "message": fb_message}, ) def _download_attachment(self, url: str, default_name: str) -> tuple[bytes, str]: diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py index b41a966e5..e38a6cb44 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py @@ -5,6 +5,8 @@ from linebot.v3.messaging import ( ApiClient, Configuration, + ImageMessage, + Message, MessagingApi, MessagingApiBlob, PushMessageRequest, @@ -18,9 +20,8 @@ from linebot.v3.messaging import TextMessage as LineTextMessage from linebot.v3.webhook import WebhookParser from linebot.v3.webhooks import Event as LineEvent -from linebot.v3.webhooks import FileMessageContent, ImageMessageContent +from linebot.v3.webhooks import FileMessageContent, ImageMessageContent, TextMessageContent from linebot.v3.webhooks import MessageEvent as LineMessageEvent -from linebot.v3.webhooks import TextMessageContent from . import Provider @@ -60,14 +61,42 @@ def show_typing(self, user_id: str) -> None: ShowLoadingAnimationRequest(chatId=user_id, loadingSeconds=60) ) - def send_reply(self, user_id: str, message: dict, context: Any) -> None: - reply_token = (context or {}).get("reply_token") - line_msg = LineTextMessage(text=message["text"]) + @staticmethod + def _to_https(url: str) -> str: + if url and url.startswith("http://"): + return "https://" + url[7:] + return url + + def _build_outbound_message(self, message: dict) -> Message: + sender = None if message.get("sender"): - line_msg.sender = LineSender( + sender = LineSender( name=message["sender"].get("name"), icon_url=message["sender"].get("icon_url"), ) + msg_type = message.get("type", "Text") + if msg_type == "Image": + file_url = self._to_https(message["file_url"]) + print(file_url) + img_msg = ImageMessage( + original_content_url=file_url, + preview_image_url=file_url, + ) + if sender: + img_msg.sender = sender + return img_msg + # File falls back to a text link (LINE Messaging API has no outbound file type) + text = ( + self._to_https(message["file_url"]) if msg_type == "File" else message.get("text", "") + ) + line_msg = LineTextMessage(text=text) + if sender: + line_msg.sender = sender + return line_msg + + def send_reply(self, user_id: str, message: dict, context: Any) -> None: + reply_token = (context or {}).get("reply_token") + line_msg = self._build_outbound_message(message) if reply_token: try: with ApiClient(self.config) as api_client: @@ -83,18 +112,9 @@ def send_reply(self, user_id: str, message: dict, context: Any) -> None: ) def send_message(self, user_id: str, message: dict) -> None: - line_msg = LineTextMessage(text=message["text"]) - if message.get("sender"): - line_msg.sender = LineSender( - name=message["sender"].get("name"), - icon_url=message["sender"].get("icon_url"), - ) with ApiClient(self.config) as api_client: MessagingApi(api_client).push_message( - PushMessageRequest( - to=user_id, - messages=[line_msg], - ) + PushMessageRequest(to=user_id, messages=[self._build_outbound_message(message)]) ) def _download_line_content(self, message_id: str) -> bytes: diff --git a/raven/omni_channel_chat/omni_channel_raven_connector.py b/raven/omni_channel_chat/omni_channel_raven_connector.py index ed387b24d..0aa2a6637 100644 --- a/raven/omni_channel_chat/omni_channel_raven_connector.py +++ b/raven/omni_channel_chat/omni_channel_raven_connector.py @@ -209,7 +209,7 @@ def push_to_provider(cls, raven_message: "RavenMessage") -> None: if ( raven_message.is_customer_message or raven_message.is_bot_message - or raven_message.message_type == "System" + or raven_message.message_type in ("System", "Poll") ): return @@ -243,7 +243,14 @@ def push_to_provider(cls, raven_message: "RavenMessage") -> None: if not user_id: return - message: dict = {"text": raven_message.content} + message: dict = {"type": raven_message.message_type} + if raven_message.message_type == "Text": + message["text"] = raven_message.content + elif raven_message.message_type in ("Image", "File"): + file_url = raven_message.file + if file_url and file_url.startswith("/"): + file_url = get_url(file_url) + message["file_url"] = file_url sender = cast( "dict | None", diff --git a/raven/raven_messaging/doctype/raven_message/raven_message.py b/raven/raven_messaging/doctype/raven_message/raven_message.py index 6701f0baf..dfd09398a 100644 --- a/raven/raven_messaging/doctype/raven_message/raven_message.py +++ b/raven/raven_messaging/doctype/raven_message/raven_message.py @@ -217,7 +217,10 @@ def after_insert(self): self.handle_ai_message() self.send_push_notification() - self.push_message_to_omni_channel_chat_provider() + # For file/image messages, the file isn't attached until after insert (upload_file_with_message flow). + # The push will happen in on_update once the file field is populated. + if not (self.message_type in ("Image", "File") and not self.file): + self.push_message_to_omni_channel_chat_provider() def handle_ai_message(self): @@ -687,6 +690,8 @@ def on_update(self): if self.message_type == "File" or self.message_type == "Image": if self.file: self.handle_ai_message() + if self.has_value_changed("file"): + self.push_message_to_omni_channel_chat_provider() def on_trash(self): # delete all the reactions for the message From bf1fc1c639d2b8212c472538108e93c8ee98c70b Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Mon, 20 Apr 2026 08:11:38 +0000 Subject: [PATCH 15/36] feat: add field to workspace --- .../doctype/raven_workspace/raven_workspace.json | 14 +++++++++++--- .../doctype/raven_workspace/raven_workspace.py | 1 + 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/raven/raven/doctype/raven_workspace/raven_workspace.json b/raven/raven/doctype/raven_workspace/raven_workspace.json index a084622ec..5247abb3b 100644 --- a/raven/raven/doctype/raven_workspace/raven_workspace.json +++ b/raven/raven/doctype/raven_workspace/raven_workspace.json @@ -12,7 +12,8 @@ "description", "column_break_svnf", "logo", - "only_admins_can_create_channels" + "only_admins_can_create_channels", + "is_omni_channel_workspace" ], "fields": [ { @@ -58,6 +59,12 @@ "fieldname": "only_admins_can_create_channels", "fieldtype": "Check", "label": "Only allow admins to create channels in the workspace" + }, + { + "default": "0", + "fieldname": "is_omni_channel_workspace", + "fieldtype": "Check", + "label": "Is Omni-Channel Workspace" } ], "index_web_pages_for_search": 1, @@ -68,7 +75,7 @@ } ], "make_attachments_public": 1, - "modified": "2025-02-15 17:41:08.057640", + "modified": "2026-04-20 15:07:21.845609", "modified_by": "Administrator", "module": "Raven", "name": "Raven Workspace", @@ -112,7 +119,8 @@ } ], "quick_entry": 1, + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [] -} \ No newline at end of file +} diff --git a/raven/raven/doctype/raven_workspace/raven_workspace.py b/raven/raven/doctype/raven_workspace/raven_workspace.py index 89e2881d1..09cd91021 100644 --- a/raven/raven/doctype/raven_workspace/raven_workspace.py +++ b/raven/raven/doctype/raven_workspace/raven_workspace.py @@ -16,6 +16,7 @@ class RavenWorkspace(Document): can_only_join_via_invite: DF.Check description: DF.SmallText | None + is_omni_channel_workspace: DF.Check logo: DF.AttachImage | None only_admins_can_create_channels: DF.Check type: DF.Literal["Public", "Private"] From eed04ffb080fd7bca9e7c639e31baa18b7b63f80 Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Mon, 20 Apr 2026 08:40:52 +0000 Subject: [PATCH 16/36] feat: update omni channel ui --- .../feature/chat-header/ChannelHeader.tsx | 65 +++++-- .../layout/Sidebar/OmniChannelSidebarBody.tsx | 170 ++++++++++++++++++ .../components/layout/Sidebar/SidebarBody.tsx | 16 ++ .../src/hooks/fetchers/useFetchWorkspaces.ts | 2 +- frontend/src/types/Raven/RavenWorkspace.ts | 2 + .../RavenChannelManagement/RavenChannel.ts | 6 + .../src/utils/channel/ChannelListProvider.tsx | 3 +- raven/api/raven_channel.py | 8 +- raven/api/workspaces.py | 1 + 9 files changed, 258 insertions(+), 15 deletions(-) create mode 100644 frontend/src/components/layout/Sidebar/OmniChannelSidebarBody.tsx diff --git a/frontend/src/components/feature/chat-header/ChannelHeader.tsx b/frontend/src/components/feature/chat-header/ChannelHeader.tsx index acf01669f..bdcbc9947 100644 --- a/frontend/src/components/feature/chat-header/ChannelHeader.tsx +++ b/frontend/src/components/feature/chat-header/ChannelHeader.tsx @@ -2,7 +2,7 @@ import { PageHeader } from "@/components/layout/Heading/PageHeader" import { ChannelIcon } from "@/utils/layout/channelIcon" import { ChannelListItem } from "@/utils/channel/ChannelListProvider" import { EditChannelNameButton } from "../channel-details/rename-channel/EditChannelNameButton" -import { Flex, Heading } from "@radix-ui/themes" +import { Flex, Heading, Text } from "@radix-ui/themes" import ChannelHeaderMenu from "./ChannelHeaderMenu" import { ViewChannelMemberAvatars } from "./ViewChannelMemberAvatars" import { BiChevronLeft } from "react-icons/bi" @@ -10,6 +10,15 @@ import { Link } from "react-router-dom" import { ViewPinnedMessagesButton } from "../pinned-messages/ViewPinnedMessagesButton" import { useAtomValue } from "jotai" import { lastWorkspaceAtom } from "@/utils/lastVisitedAtoms" +import { useContext, useMemo } from "react" +import { UserListContext } from "@/utils/users/UserListProvider" +import { UserAvatar } from "@/components/common/UserAvatar" +import { FaFacebook, FaLine } from "react-icons/fa" + +const PROVIDER_ICONS: Record = { + line: , + facebook: , +} interface ChannelHeaderProps { channelData: ChannelListItem @@ -17,9 +26,18 @@ interface ChannelHeaderProps { export const ChannelHeader = ({ channelData }: ChannelHeaderProps) => { - // The channel header has the channel name, the channel type icon, edit channel name button, and the view or add members button - const lastWorkspace = useAtomValue(lastWorkspaceAtom) + const { users } = useContext(UserListContext) + + const customerUser = useMemo( + () => users.find(u => u.name === channelData.customer_user), + [users, channelData.customer_user] + ) + + const isOmniChannel = !!channelData.customer_user + const displayName = customerUser?.full_name ?? channelData.channel_name + const providerIcon = channelData.omni_channel_provider ? PROVIDER_ICONS[channelData.omni_channel_provider] : null + const providerDisplayName = channelData.omni_channel_display_name ?? channelData.omni_channel_chat_provider return ( @@ -28,15 +46,38 @@ export const ChannelHeader = ({ channelData }: ChannelHeaderProps) => { - - - {channelData.channel_name} - + {isOmniChannel ? ( + + + + {displayName} + + {providerDisplayName && ( + <> + | + {providerIcon && {providerIcon}} + {providerDisplayName} + + )} + + ) : ( + + + {channelData.channel_name} + + )} diff --git a/frontend/src/components/layout/Sidebar/OmniChannelSidebarBody.tsx b/frontend/src/components/layout/Sidebar/OmniChannelSidebarBody.tsx new file mode 100644 index 000000000..8a949c7eb --- /dev/null +++ b/frontend/src/components/layout/Sidebar/OmniChannelSidebarBody.tsx @@ -0,0 +1,170 @@ +import { useContext, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useParams } from 'react-router-dom' +import { Flex, ScrollArea, Text } from '@radix-ui/themes' +import { ChannelListContext, ChannelListContextType, ChannelListItem } from '@/utils/channel/ChannelListProvider' +import { + SidebarBadge, + SidebarGroup, + SidebarGroupItem, + SidebarGroupLabel, + SidebarGroupList, + SidebarIcon, + SidebarItem, + SidebarViewMoreButton, +} from './SidebarComp' +import { UserListContext } from '@/utils/users/UserListProvider' +import { UserAvatar } from '@/components/common/UserAvatar' +import { useFetchUnreadMessageCount } from '@/hooks/useUnreadMessageCount' +import { useStickyState } from '@/hooks/useStickyState' +import { __ } from '@/utils/translations' +import { FaFacebook, FaLine } from 'react-icons/fa' + +type OmniChannelItem = ChannelListItem & { unread_count: number } + +interface ProviderGroup { + provider?: string + channels: OmniChannelItem[] +} + +const PROVIDER_ICONS: Record = { + line: , + facebook: , +} + +export const OmniChannelSidebarBody = () => { + const { channels } = useContext(ChannelListContext) as ChannelListContextType + const { workspaceID } = useParams() + const unread_count = useFetchUnreadMessageCount() + + const channelsWithUnread = useMemo(() => { + const workspaceChannels = channels.filter(c => c.workspace === workspaceID && !c.is_archived) + return workspaceChannels.map(c => ({ + ...c, + unread_count: unread_count?.message?.find(u => u.name === c.name)?.unread_count ?? 0, + })) as OmniChannelItem[] + }, [channels, workspaceID, unread_count]) + + const unreadChannels = useMemo(() => channelsWithUnread.filter(c => c.unread_count > 0), [channelsWithUnread]) + + const groupedChannels = useMemo(() => { + const groups: Record = {} + channelsWithUnread.forEach(c => { + const key = c.omni_channel_display_name ?? c.omni_channel_chat_provider ?? 'Other' + if (!groups[key]) groups[key] = { provider: c.omni_channel_provider, channels: [] } + groups[key].channels.push(c) + }) + return groups + }, [channelsWithUnread]) + + return ( + + + {unreadChannels.length > 0 && ( + + )} + {Object.entries(groupedChannels).map(([label, group]) => ( + + ))} + + + ) +} + +interface OmniProviderGroupProps { + label: string + channels: OmniChannelItem[] + storageKey: string + provider?: string + showProvider?: boolean +} + +const OmniProviderGroup = ({ label, channels, storageKey, provider, showProvider }: OmniProviderGroupProps) => { + const [showData, setShowData] = useStickyState(true, storageKey) + const toggle = () => setShowData((d: boolean) => !d) + + const ref = useRef(null) + const [height, setHeight] = useState(ref?.current?.clientHeight ?? (showData ? channels.length * 44 : 0)) + + useLayoutEffect(() => { + setHeight(ref.current?.clientHeight ?? 0) + }, [channels]) + + const icon = provider ? PROVIDER_ICONS[provider] : null + + return ( + + + + + {icon && {icon}} + {label} + + + + + + +
+ {channels.map(channel => ( + + ))} +
+
+
+
+ ) +} + +const OmniChannelItemRow = ({ channel, showProvider }: { channel: OmniChannelItem, showProvider?: boolean }) => { + const { users } = useContext(UserListContext) + const { channelID } = useParams() + + const customerUser = useMemo(() => users.find(u => u.name === channel.customer_user), [users, channel.customer_user]) + + const displayName = customerUser?.full_name ?? channel.channel_name + const showUnread = channel.unread_count > 0 && channelID !== channel.name + + return ( + + + + + + + + {displayName} + + {showUnread && {channel.unread_count}} + + {showProvider && (channel.omni_channel_display_name ?? channel.omni_channel_chat_provider) && ( + + {channel.omni_channel_display_name ?? channel.omni_channel_chat_provider} + + )} + + + ) +} diff --git a/frontend/src/components/layout/Sidebar/SidebarBody.tsx b/frontend/src/components/layout/Sidebar/SidebarBody.tsx index 60be683e4..1b0a5305a 100644 --- a/frontend/src/components/layout/Sidebar/SidebarBody.tsx +++ b/frontend/src/components/layout/Sidebar/SidebarBody.tsx @@ -13,11 +13,27 @@ import { useGetChannelUnreadCounts } from './useGetChannelUnreadCounts' import { useParams } from 'react-router-dom' import { atomWithStorage } from 'jotai/utils' import useUnreadThreadsCount from '@/hooks/useUnreadThreadsCount' +import useFetchWorkspaces from '@/hooks/fetchers/useFetchWorkspaces' +import { OmniChannelSidebarBody } from './OmniChannelSidebarBody' export const showOnlyMyChannelsAtom = atomWithStorage('showOnlyMyChannels', false) export const SidebarBody = () => { + const { workspaceID } = useParams() + const { data: workspacesData } = useFetchWorkspaces() + + const isOmniChannel = workspacesData?.message.find(w => w.name === workspaceID)?.is_omni_channel_workspace === 1 + + if (isOmniChannel) { + return + } + + return +} + +const StandardSidebarBody = () => { + const unread_count = useFetchUnreadMessageCount() const { channels, dm_channels } = useContext(ChannelListContext) as ChannelListContextType diff --git a/frontend/src/hooks/fetchers/useFetchWorkspaces.ts b/frontend/src/hooks/fetchers/useFetchWorkspaces.ts index 417883515..c7ae132e8 100644 --- a/frontend/src/hooks/fetchers/useFetchWorkspaces.ts +++ b/frontend/src/hooks/fetchers/useFetchWorkspaces.ts @@ -1,7 +1,7 @@ import { useFrappeGetCall } from 'frappe-react-sdk' import { RavenWorkspace } from '@/types/Raven/RavenWorkspace' -export type WorkspaceFields = Pick & { +export type WorkspaceFields = Pick & { workspace_member_name?: string is_admin?: 0 | 1 } diff --git a/frontend/src/types/Raven/RavenWorkspace.ts b/frontend/src/types/Raven/RavenWorkspace.ts index ac0acbf4c..0818b601e 100644 --- a/frontend/src/types/Raven/RavenWorkspace.ts +++ b/frontend/src/types/Raven/RavenWorkspace.ts @@ -22,4 +22,6 @@ export interface RavenWorkspace{ logo?: string /** Only allow admins to create channels in the workspace : Check - If unchecked, any workspace member can create a channel */ only_admins_can_create_channels?: 0 | 1 + /** Is Omni-Channel Workspace : Check */ + is_omni_channel_workspace?: 0 | 1 } \ No newline at end of file diff --git a/frontend/src/types/RavenChannelManagement/RavenChannel.ts b/frontend/src/types/RavenChannelManagement/RavenChannel.ts index d46502a9f..df340f73c 100644 --- a/frontend/src/types/RavenChannelManagement/RavenChannel.ts +++ b/frontend/src/types/RavenChannelManagement/RavenChannel.ts @@ -49,4 +49,10 @@ export interface RavenChannel{ openai_thread_id?: string /** Thread Bot : Link - Raven Bot */ thread_bot?: string + /** Is Customer : Check */ + is_customer?: 0 | 1 + /** Customer : Link - Raven User */ + customer_user?: string + /** Omni Channel Chat Provider : Link - Omni Channel Chat Provider */ + omni_channel_chat_provider?: string } \ No newline at end of file diff --git a/frontend/src/utils/channel/ChannelListProvider.tsx b/frontend/src/utils/channel/ChannelListProvider.tsx index 4ad03da28..7ab18529e 100644 --- a/frontend/src/utils/channel/ChannelListProvider.tsx +++ b/frontend/src/utils/channel/ChannelListProvider.tsx @@ -13,7 +13,8 @@ export type UnreadCountData = UnreadChannelCountItem[] export type ChannelListItem = Pick & { member_id: string } + 'is_archived' | 'creation' | 'owner' | 'last_message_details' | 'last_message_timestamp' | 'workspace' | 'pinned_messages_string' | + 'customer_user' | 'omni_channel_chat_provider'> & { member_id: string, omni_channel_display_name?: string, omni_channel_provider?: string } export interface DMChannelListItem extends ChannelListItem { peer_user_id: string, diff --git a/raven/api/raven_channel.py b/raven/api/raven_channel.py index d3c9e8847..07cbd8723 100644 --- a/raven/api/raven_channel.py +++ b/raven/api/raven_channel.py @@ -45,8 +45,8 @@ def get_channel_list(hide_archived: bool = False): """ channel = frappe.qb.DocType("Raven Channel") channel_member = frappe.qb.DocType("Raven Channel Member") - workspace_member = frappe.qb.DocType("Raven Workspace Member") + omni_provider = frappe.qb.DocType("Omni Channel Chat Provider") query = ( frappe.qb.from_(channel) @@ -64,6 +64,10 @@ def get_channel_list(hide_archived: bool = False): channel.last_message_details, channel.pinned_messages_string, channel.workspace, + channel.customer_user, + channel.omni_channel_chat_provider, + omni_provider.display_name.as_("omni_channel_display_name"), + omni_provider.provider.as_("omni_channel_provider"), channel_member.name.as_("member_id"), ) .left_join(channel_member) @@ -75,6 +79,8 @@ def get_channel_list(hide_archived: bool = False): (channel.workspace == workspace_member.workspace) & (workspace_member.user == frappe.session.user) ) + .left_join(omni_provider) + .on(channel.omni_channel_chat_provider == omni_provider.name) .where( ((channel.is_direct_message == 1) & (channel_member.user_id == frappe.session.user)) | ( diff --git a/raven/api/workspaces.py b/raven/api/workspaces.py index 43a03ec52..28bf8bfea 100644 --- a/raven/api/workspaces.py +++ b/raven/api/workspaces.py @@ -30,6 +30,7 @@ def get_list(): workspace.type, workspace.description, workspace.can_only_join_via_invite, + workspace.is_omni_channel_workspace, workspace_member.name.as_("workspace_member_name"), workspace_member.is_admin.as_("is_admin"), ) From 5d62552bf34bc8d32563ece4b5ecca97d45d20a1 Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Mon, 20 Apr 2026 12:36:22 +0000 Subject: [PATCH 17/36] feat: add instagram integration --- .../omni_channel_chat_provider.js | 19 +- .../omni_channel_chat_provider.json | 35 +++- .../omni_channel_chat_provider.py | 9 +- .../provider/__init__.py | 1 + .../provider/instagram_provider.py | 176 ++++++++++++++++++ 5 files changed, 232 insertions(+), 8 deletions(-) create mode 100644 raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/instagram_provider.py diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.js b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.js index d436e8254..bd1b524c2 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.js +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.js @@ -1,8 +1,19 @@ // Copyright (c) 2026, The Commit Company (Algocode Technologies Pvt. Ltd.) and contributors // For license information, please see license.txt -// frappe.ui.form.on("Omni Channel Chat Provider", { -// refresh(frm) { +frappe.ui.form.on("Omni Channel Chat Provider", { + refresh(frm) { + set_instagram_banner(frm); + }, + provider(frm) { + set_instagram_banner(frm); + }, +}); -// }, -// }); +function set_instagram_banner(frm) { + if (frm.doc.provider === "instagram") { + frm.set_intro(__("To receive webhooks, your app must be in published state."), "yellow"); + } else { + frm.set_intro(""); + } +} diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.json b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.json index 77e8229d9..d66e83ad2 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.json +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.json @@ -15,7 +15,11 @@ "conf_facebook_sec", "fb_page_access_token", "fb_verify_token", - "fb_app_secret" + "fb_app_secret", + "conf_instagram_sec", + "ig_page_access_token", + "ig_verify_token", + "ig_app_secret" ], "fields": [ { @@ -23,7 +27,7 @@ "fieldtype": "Select", "in_list_view": 1, "label": "Provider", - "options": "\nline\nfacebook", + "options": "\nline\nfacebook\ninstagram", "reqd": 1 }, { @@ -91,6 +95,33 @@ "label": "App Secret", "length": 512, "mandatory_depends_on": "eval:doc.provider==\"facebook\"" + }, + { + "depends_on": "eval:doc.provider==\"instagram\"", + "fieldname": "conf_instagram_sec", + "fieldtype": "Section Break", + "label": "Config - Instagram" + }, + { + "fieldname": "ig_page_access_token", + "fieldtype": "Password", + "label": "Page Access Token", + "length": 512, + "mandatory_depends_on": "eval:doc.provider==\"instagram\"" + }, + { + "fieldname": "ig_verify_token", + "fieldtype": "Password", + "label": "Verify Token", + "length": 512, + "mandatory_depends_on": "eval:doc.provider==\"instagram\"" + }, + { + "fieldname": "ig_app_secret", + "fieldtype": "Password", + "label": "App Secret", + "length": 512, + "mandatory_depends_on": "eval:doc.provider==\"instagram\"" } ], "grid_page_length": 50, diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.py index a92bf55ad..f5a1bf25f 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.py @@ -5,7 +5,7 @@ from frappe import _ from frappe.model.document import Document -from .provider import FacebookProvider, LineProvider, Provider +from .provider import FacebookProvider, InstagramProvider, LineProvider, Provider class OmniChannelChatProvider(Document): @@ -21,9 +21,12 @@ class OmniChannelChatProvider(Document): fb_app_secret: DF.Password | None fb_page_access_token: DF.Password | None fb_verify_token: DF.Password | None + ig_app_secret: DF.Password | None + ig_page_access_token: DF.Password | None + ig_verify_token: DF.Password | None line_channel_access_token: DF.Password | None line_channel_secret: DF.Password | None - provider: DF.Literal["", "line", "facebook"] + provider: DF.Literal["", "line", "facebook", "instagram"] raven_workspace: DF.Link # end: auto-generated types @@ -44,6 +47,8 @@ def get_provider(self) -> Provider: return LineProvider(config=self) elif self.provider == "facebook": return FacebookProvider(config=self) + elif self.provider == "instagram": + return InstagramProvider(config=self) else: frappe.throw(_("Provider not implemented.")) diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/__init__.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/__init__.py index cea52469e..b6eb899db 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/__init__.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/__init__.py @@ -1,3 +1,4 @@ from .base_provider import Provider as Provider from .facebook_provider import FacebookProvider as FacebookProvider +from .instagram_provider import InstagramProvider as InstagramProvider from .line_provider import LineProvider as LineProvider diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/instagram_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/instagram_provider.py new file mode 100644 index 000000000..f7d32300b --- /dev/null +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/instagram_provider.py @@ -0,0 +1,176 @@ +import hashlib +import hmac +import json +from typing import Any + +import frappe +import httpx +from werkzeug.wrappers import Response + +from raven.omni_channel_chat.doctype.omni_channel_chat_provider.provider import ( + Provider, +) + +InstagramMessagingEvent = dict[str, Any] + + +class InstagramProvider(Provider[InstagramMessagingEvent, dict]): + IG_API_URL = "https://graph.instagram.com/v25.0/me/messages" + + def __init__(self, config): + super().__init__(config=config) + self._page_access_token = self.provider_config.ig_page_access_token + self._app_secret = self.provider_config.ig_app_secret + self._verify_token = self.provider_config.ig_verify_token + + def handle_frappe_api(self) -> Response: + request = frappe.local.request + + if request.method == "GET": + mode = frappe.form_dict.get("hub.mode") + verify_token = frappe.form_dict.get("hub.verify_token") + challenge = frappe.form_dict.get("hub.challenge", "0") + + if mode == "subscribe" and verify_token == self._verify_token: + return Response(challenge, status=200, content_type="text/plain") + else: + frappe.throw("Verification failed", frappe.PermissionError) + + body: bytes = request.get_data() + headers: dict = dict(request.headers) + + return self.handle_webhook(body=body, headers=headers) + + def _verify_signature(self, body: bytes, signature_header: str) -> bool: + if not signature_header.startswith("sha256="): + return False + expected = hmac.new(self._app_secret.encode(), body, hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, signature_header.removeprefix("sha256=")) + + def get_user_info(self, user_id: str) -> dict: + with httpx.Client() as client: + response = client.get( + f"https://graph.facebook.com/v22.0/{user_id}", + params={ + "fields": "name,profile_pic", + "access_token": self._page_access_token, + }, + ) + response.raise_for_status() + data = response.json() + return { + "user_id": user_id, + "display_name": data.get("name"), + "picture_url": data.get("profile_pic"), + } + + def show_typing(self, user_id: str) -> None: + with httpx.Client() as client: + client.post( + self.IG_API_URL, + params={"access_token": self._page_access_token}, + json={"recipient": {"id": user_id}, "sender_action": "typing_on"}, + ) + + def send_reply(self, user_id: str, message: dict, context: Any) -> None: + self.send_message(user_id=user_id, message=message) + + def send_message(self, user_id: str, message: dict) -> None: + msg_type = message.get("type", "Text") + if msg_type == "Image": + ig_message = { + "attachment": { + "type": "image", + "payload": {"url": message["file_url"], "is_reusable": True}, + } + } + elif msg_type == "File": + ig_message = { + "attachment": { + "type": "file", + "payload": {"url": message["file_url"], "is_reusable": True}, + } + } + else: + ig_message = {"text": message["text"]} + with httpx.Client() as client: + client.post( + self.IG_API_URL, + params={"access_token": self._page_access_token}, + json={"recipient": {"id": user_id}, "message": ig_message}, + ) + + def _download_attachment(self, url: str, default_name: str) -> tuple[bytes, str]: + with httpx.Client() as client: + response = client.get(url) + response.raise_for_status() + content_disposition = response.headers.get("content-disposition", "") + file_name = default_name + if "filename=" in content_disposition: + file_name = content_disposition.split("filename=")[-1].strip('" ') + return response.content, file_name + + def event_mapper(self, event: InstagramMessagingEvent) -> dict | None: + message = event.get("message") + if message is None: + return None + + mid = message.get("mid") + + if "text" in message: + return { + "provider": self.provider_config.provider, + "user_id": event["sender"]["id"], + "message": {"type": "Text", "text": message["text"]}, + "message_metadata": {"mid": mid}, + } + + for attachment in message.get("attachments") or []: + att_type = attachment.get("type") + url = attachment.get("payload", {}).get("url") + if not url: + continue + if att_type == "image": + content, file_name = self._download_attachment(url, f"{mid or 'image'}.jpg") + return { + "provider": self.provider_config.provider, + "user_id": event["sender"]["id"], + "message": {"type": "Image", "file_name": file_name, "file_content": content}, + "message_metadata": {"mid": mid}, + } + if att_type in ("file", "video", "audio"): + content, file_name = self._download_attachment(url, mid or "file") + return { + "provider": self.provider_config.provider, + "user_id": event["sender"]["id"], + "message": {"type": "File", "file_name": file_name, "file_content": content}, + "message_metadata": {"mid": mid}, + } + + return None + + def standardize_events(self, events: list[InstagramMessagingEvent]) -> list[dict]: + std_events: list[dict] = [] + for event in events: + std_event = self.event_mapper(event) + if std_event: + std_events.append(std_event) + return std_events + + def extract_messages(self, body: bytes, headers: dict) -> list[dict]: + signature = headers.get("X-Hub-Signature-256", "") or headers.get( + "x-hub-signature-256", "" + ) + if not self._verify_signature(body, signature): + frappe.throw("Invalid Instagram signature", frappe.PermissionError) + + payload = json.loads(body) + if payload.get("object") != "instagram": + frappe.throw("Not an Instagram event", frappe.ValidationError) + + messaging_events: list[InstagramMessagingEvent] = [ + messaging_event + for entry in payload.get("entry", []) + for messaging_event in entry.get("messaging", []) + ] + return self.standardize_events(messaging_events) From 733a669be2ed807c05d2b381c9ab8a9564822043 Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Mon, 20 Apr 2026 13:11:35 +0000 Subject: [PATCH 18/36] refactor: update webhook interface --- raven/omni_channel_chat/api/webhooks.py | 9 ++++++--- .../omni_channel_chat_provider.py | 9 +++++++-- .../omni_channel_chat_provider/provider/base_provider.py | 5 +++-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/raven/omni_channel_chat/api/webhooks.py b/raven/omni_channel_chat/api/webhooks.py index d79992aff..8276f5529 100644 --- a/raven/omni_channel_chat/api/webhooks.py +++ b/raven/omni_channel_chat/api/webhooks.py @@ -2,7 +2,7 @@ from werkzeug.wrappers import Response from raven.omni_channel_chat.doctype.omni_channel_chat_provider.omni_channel_chat_provider import ( - get_omni_channel_chat, + get_omni_channel_chat_provider, ) @@ -26,7 +26,10 @@ def extract_provider_slug() -> str: @frappe.whitelist(allow_guest=True, methods=["POST", "GET"]) def handle() -> Response: + """ + Endpoint: + /api/method/raven.omni_channel_chat.api.webhooks.handle + """ slug = extract_provider_slug() - omni_channel_chat = get_omni_channel_chat(slug=slug) - provider = omni_channel_chat.get_provider() + provider = get_omni_channel_chat_provider(slug=slug) return provider.handle_frappe_api() diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.py index f5a1bf25f..9e27afb73 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.py @@ -53,5 +53,10 @@ def get_provider(self) -> Provider: frappe.throw(_("Provider not implemented.")) -def get_omni_channel_chat(slug: str) -> OmniChannelChatProvider: - return frappe.get_doc("Omni Channel Chat Provider", slug) +def get_omni_channel_chat_provider(slug: str) -> Provider: + doc = frappe.get_doc("Omni Channel Chat Provider", slug) + + if not doc: + frappe.throw(_("Omni Channel Chat Provider not found.")) + + return doc.get_provider() diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py index 2f223f253..8af140b2d 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py @@ -23,13 +23,14 @@ def push_message_to_raven(self, messages: list[dict]) -> None: for message in messages: handler.receive_from_provider(message) - def handle_webhook(self, body: bytes, headers: dict) -> None: + def handle_webhook(self, body: bytes, headers: dict) -> Response: messages = self.extract_messages(body=body, headers=headers) self.push_message_to_raven(messages=messages) + return Response("ok", status=200, content_type="text/plain") @abstractmethod def handle_frappe_api(self) -> Response: - """Extract data from frappe request and pass to handle webhook.""" + """Extract data from frappe request and pass to `handle_webhook`.""" @abstractmethod def get_user_info(self, user_id: str) -> dict: From 9f0dd167e6bd2aa3be670055f96b50dc9c92ec89 Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Mon, 20 Apr 2026 13:22:34 +0000 Subject: [PATCH 19/36] refactor: add standardize data class --- .../provider/base_provider.py | 13 +++-- .../provider/facebook_provider.py | 41 +++++++------- .../provider/instagram_provider.py | 41 +++++++------- .../provider/line_provider.py | 55 +++++++++---------- raven/omni_channel_chat/models/messages.py | 49 +++++++++++++++++ .../omni_channel_raven_connector.py | 14 +++-- 6 files changed, 127 insertions(+), 86 deletions(-) create mode 100644 raven/omni_channel_chat/models/messages.py diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py index 8af140b2d..4358a6468 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py @@ -3,6 +3,7 @@ from werkzeug.wrappers import Response +from raven.omni_channel_chat.models.messages import StdMessage from raven.omni_channel_chat.omni_channel_raven_connector import OmniChannelRavenConnector if TYPE_CHECKING: @@ -18,7 +19,7 @@ def __init__(self, config: "OmniChannelChatProvider"): self.provider_config = config self.provider_config.decode_password_field() - def push_message_to_raven(self, messages: list[dict]) -> None: + def push_message_to_raven(self, messages: list[StdMessage]) -> None: handler = OmniChannelRavenConnector(provider=self) for message in messages: handler.receive_from_provider(message) @@ -49,13 +50,13 @@ def send_message(self, user_id: str, message: dict) -> None: """Send an outbound message (push, not reply).""" @abstractmethod - def event_mapper(self, event: ProviderWebhookEvent) -> dict | None: - """Map a provider-specific webhook event into a standardized event. Return None to skip.""" + def event_mapper(self, event: ProviderWebhookEvent) -> StdMessage | None: + """Map a provider-specific webhook event into a standardized message. Return None to skip.""" @abstractmethod - def standardize_events(self, events: list[ProviderWebhookEvent]) -> list[dict]: - """Standardize a list of provider-specific webhook events into standardized events.""" + def standardize_events(self, events: list[ProviderWebhookEvent]) -> list[StdMessage]: + """Standardize a list of provider-specific webhook events into StdMessage instances.""" @abstractmethod - def extract_messages(self, body: bytes, headers: dict) -> list[dict]: + def extract_messages(self, body: bytes, headers: dict) -> list[StdMessage]: """Parse the raw webhook body into standardized messages.""" diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py index 4517ca780..f6fc32e4d 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py @@ -10,6 +10,12 @@ from raven.omni_channel_chat.doctype.omni_channel_chat_provider.provider import ( Provider, ) +from raven.omni_channel_chat.models.messages import ( + FileMessage, + ImageMessage, + StdMessage, + TextMessage, +) # A "messaging event" dict from the Facebook webhook payload FacebookMessagingEvent = dict[str, Any] @@ -111,20 +117,17 @@ def _download_attachment(self, url: str, default_name: str) -> tuple[bytes, str] file_name = content_disposition.split("filename=")[-1].strip('" ') return response.content, file_name - def event_mapper(self, event: FacebookMessagingEvent) -> dict | None: + def event_mapper(self, event: FacebookMessagingEvent) -> StdMessage | None: message = event.get("message") if message is None: return None mid = message.get("mid") + user_id = event["sender"]["id"] + metadata = {"mid": mid} if "text" in message: - return { - "provider": self.provider_config.provider, - "user_id": event["sender"]["id"], - "message": {"type": "Text", "text": message["text"]}, - "message_metadata": {"mid": mid}, - } + return TextMessage(user_id=user_id, metadata=metadata, text=message["text"]) for attachment in message.get("attachments") or []: att_type = attachment.get("type") @@ -133,32 +136,26 @@ def event_mapper(self, event: FacebookMessagingEvent) -> dict | None: continue if att_type == "image": content, file_name = self._download_attachment(url, f"{mid or 'image'}.jpg") - return { - "provider": self.provider_config.provider, - "user_id": event["sender"]["id"], - "message": {"type": "Image", "file_name": file_name, "file_content": content}, - "message_metadata": {"mid": mid}, - } + return ImageMessage( + user_id=user_id, metadata=metadata, file_name=file_name, file_content=content + ) if att_type in ("file", "document"): content, file_name = self._download_attachment(url, mid or "file") - return { - "provider": self.provider_config.provider, - "user_id": event["sender"]["id"], - "message": {"type": "File", "file_name": file_name, "file_content": content}, - "message_metadata": {"mid": mid}, - } + return FileMessage( + user_id=user_id, metadata=metadata, file_name=file_name, file_content=content + ) return None - def standardize_events(self, events: list[FacebookMessagingEvent]) -> list[dict]: - std_events: list[dict] = [] + def standardize_events(self, events: list[FacebookMessagingEvent]) -> list[StdMessage]: + std_events: list[StdMessage] = [] for event in events: std_event = self.event_mapper(event) if std_event: std_events.append(std_event) return std_events - def extract_messages(self, body: bytes, headers: dict) -> list[dict]: + def extract_messages(self, body: bytes, headers: dict) -> list[StdMessage]: signature = headers.get("X-Hub-Signature-256", "") or headers.get( "x-hub-signature-256", "" ) diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/instagram_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/instagram_provider.py index f7d32300b..82b4a5e30 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/instagram_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/instagram_provider.py @@ -10,6 +10,12 @@ from raven.omni_channel_chat.doctype.omni_channel_chat_provider.provider import ( Provider, ) +from raven.omni_channel_chat.models.messages import ( + FileMessage, + ImageMessage, + StdMessage, + TextMessage, +) InstagramMessagingEvent = dict[str, Any] @@ -110,20 +116,17 @@ def _download_attachment(self, url: str, default_name: str) -> tuple[bytes, str] file_name = content_disposition.split("filename=")[-1].strip('" ') return response.content, file_name - def event_mapper(self, event: InstagramMessagingEvent) -> dict | None: + def event_mapper(self, event: InstagramMessagingEvent) -> StdMessage | None: message = event.get("message") if message is None: return None mid = message.get("mid") + user_id = event["sender"]["id"] + metadata = {"mid": mid} if "text" in message: - return { - "provider": self.provider_config.provider, - "user_id": event["sender"]["id"], - "message": {"type": "Text", "text": message["text"]}, - "message_metadata": {"mid": mid}, - } + return TextMessage(user_id=user_id, metadata=metadata, text=message["text"]) for attachment in message.get("attachments") or []: att_type = attachment.get("type") @@ -132,32 +135,26 @@ def event_mapper(self, event: InstagramMessagingEvent) -> dict | None: continue if att_type == "image": content, file_name = self._download_attachment(url, f"{mid or 'image'}.jpg") - return { - "provider": self.provider_config.provider, - "user_id": event["sender"]["id"], - "message": {"type": "Image", "file_name": file_name, "file_content": content}, - "message_metadata": {"mid": mid}, - } + return ImageMessage( + user_id=user_id, metadata=metadata, file_name=file_name, file_content=content + ) if att_type in ("file", "video", "audio"): content, file_name = self._download_attachment(url, mid or "file") - return { - "provider": self.provider_config.provider, - "user_id": event["sender"]["id"], - "message": {"type": "File", "file_name": file_name, "file_content": content}, - "message_metadata": {"mid": mid}, - } + return FileMessage( + user_id=user_id, metadata=metadata, file_name=file_name, file_content=content + ) return None - def standardize_events(self, events: list[InstagramMessagingEvent]) -> list[dict]: - std_events: list[dict] = [] + def standardize_events(self, events: list[InstagramMessagingEvent]) -> list[StdMessage]: + std_events: list[StdMessage] = [] for event in events: std_event = self.event_mapper(event) if std_event: std_events.append(std_event) return std_events - def extract_messages(self, body: bytes, headers: dict) -> list[dict]: + def extract_messages(self, body: bytes, headers: dict) -> list[StdMessage]: signature = headers.get("X-Hub-Signature-256", "") or headers.get( "x-hub-signature-256", "" ) diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py index e38a6cb44..b2f4cac01 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py @@ -23,6 +23,13 @@ from linebot.v3.webhooks import FileMessageContent, ImageMessageContent, TextMessageContent from linebot.v3.webhooks import MessageEvent as LineMessageEvent +from raven.omni_channel_chat.models.messages import ( + FileMessage, + ImageMessage, + StdMessage, + TextMessage as StdTextMessage, +) + from . import Provider @@ -121,56 +128,44 @@ def _download_line_content(self, message_id: str) -> bytes: with ApiClient(self.config) as api_client: return bytes(MessagingApiBlob(api_client).get_message_content(message_id)) - def event_mapper(self, event: LineEvent) -> dict | None: + def event_mapper(self, event: LineEvent) -> StdMessage | None: if not isinstance(event, LineMessageEvent): return None msg = event.message metadata = {"message_id": msg.id, "reply_token": event.reply_token} + user_id = event.source.user_id if isinstance(msg, TextMessageContent): - return { - "provider": self.provider_config.provider, - "user_id": event.source.user_id, - "message": {"type": "Text", "text": msg.text}, - "message_metadata": metadata, - } + return StdTextMessage(user_id=user_id, metadata=metadata, text=msg.text) if isinstance(msg, ImageMessageContent): - return { - "provider": self.provider_config.provider, - "user_id": event.source.user_id, - "message": { - "type": "Image", - "file_name": f"{msg.id}.jpg", - "file_content": self._download_line_content(msg.id), - }, - "message_metadata": metadata, - } + return ImageMessage( + user_id=user_id, + metadata=metadata, + file_name=f"{msg.id}.jpg", + file_content=self._download_line_content(msg.id), + ) if isinstance(msg, FileMessageContent): - return { - "provider": self.provider_config.provider, - "user_id": event.source.user_id, - "message": { - "type": "File", - "file_name": msg.file_name, - "file_content": self._download_line_content(msg.id), - }, - "message_metadata": metadata, - } + return FileMessage( + user_id=user_id, + metadata=metadata, + file_name=msg.file_name, + file_content=self._download_line_content(msg.id), + ) return None - def standardize_events(self, events: list[LineEvent]) -> list[dict]: - std_events: list[dict] = [] + def standardize_events(self, events: list[LineEvent]) -> list[StdMessage]: + std_events: list[StdMessage] = [] for event in events: std_event = self.event_mapper(event) if std_event: std_events.append(std_event) return std_events - def extract_messages(self, body: bytes, headers: dict) -> list[dict]: + def extract_messages(self, body: bytes, headers: dict) -> list[StdMessage]: signature = headers.get("x-line-signature", "") or headers.get("X-Line-Signature", "") try: events = self.parser.parse(body=body.decode(), signature=signature, as_payload=False) diff --git a/raven/omni_channel_chat/models/messages.py b/raven/omni_channel_chat/models/messages.py new file mode 100644 index 000000000..d12c151f2 --- /dev/null +++ b/raven/omni_channel_chat/models/messages.py @@ -0,0 +1,49 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass + + +@dataclass +class StdMessage(ABC): + user_id: str + metadata: dict + + @abstractmethod + def to_raven(self) -> dict: + pass + + +@dataclass +class TextMessage(StdMessage): + text: str + + def to_raven(self): + return { + "type": "Text", + "text": self.text, + } + + +@dataclass +class FileMessage(StdMessage): + file_name: str + file_content: bytes + + def to_raven(self): + return { + "type": "File", + "file_name": self.file_name, + "file_content": self.file_content, + } + + +@dataclass +class ImageMessage(StdMessage): + file_name: str + file_content: bytes + + def to_raven(self): + return { + "type": "Image", + "file_name": self.file_name, + "file_content": self.file_content, + } diff --git a/raven/omni_channel_chat/omni_channel_raven_connector.py b/raven/omni_channel_chat/omni_channel_raven_connector.py index 0aa2a6637..f1d5ace75 100644 --- a/raven/omni_channel_chat/omni_channel_raven_connector.py +++ b/raven/omni_channel_chat/omni_channel_raven_connector.py @@ -3,6 +3,8 @@ import frappe from frappe.utils import get_url +from raven.omni_channel_chat.models.messages import StdMessage + if TYPE_CHECKING: from frappe.core.doctype.user.user import User @@ -151,7 +153,7 @@ def handle_webhook(self, body: bytes, headers: dict) -> None: for message in messages: self.receive_from_provider(message) - def receive_from_provider(self, message: dict) -> "RavenChannel": + def receive_from_provider(self, message: StdMessage) -> "RavenChannel": """Inbound: turn a provider webhook payload into a Raven message. Creates the Frappe user, Raven user, and channel on first contact, @@ -159,17 +161,17 @@ def receive_from_provider(self, message: dict) -> "RavenChannel": Returns the Raven channel the message was posted to. """ - user = self._get_or_create_customer_user(user_id=message["user_id"]) + user = self._get_or_create_customer_user(user_id=message.user_id) frappe.set_user(user.name) - raven_user = self._get_or_create_raven_user(user=user, user_id=message["user_id"]) + raven_user = self._get_or_create_raven_user(user=user, user_id=message.user_id) raven_channel = self._get_or_create_channel(raven_user=raven_user) self._save_inbound_message(raven_channel=raven_channel, message=message) return raven_channel - def _save_inbound_message(self, *, raven_channel: "RavenChannel", message: dict) -> None: - msg = message["message"] + def _save_inbound_message(self, *, raven_channel: "RavenChannel", message: StdMessage) -> None: + msg = message.to_raven() doc = frappe.new_doc(doctype="Raven Message") doc.update( { @@ -177,7 +179,7 @@ def _save_inbound_message(self, *, raven_channel: "RavenChannel", message: dict) "message_type": msg["type"], "is_customer_message": True, "owner": raven_channel.customer_user, - "omni_channel_msg_meta": message.get("message_metadata"), + "omni_channel_msg_meta": message.metadata, } ) if msg["type"] == "Text": From 179b6f5ebeffd600fe13c24d74886bdf1e0da968 Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Mon, 20 Apr 2026 13:28:27 +0000 Subject: [PATCH 20/36] refactor: update msg object --- raven/omni_channel_chat/models/messages.py | 29 +++++++------------ .../omni_channel_raven_connector.py | 15 +++++----- 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/raven/omni_channel_chat/models/messages.py b/raven/omni_channel_chat/models/messages.py index d12c151f2..6de50768a 100644 --- a/raven/omni_channel_chat/models/messages.py +++ b/raven/omni_channel_chat/models/messages.py @@ -7,8 +7,9 @@ class StdMessage(ABC): user_id: str metadata: dict + @property @abstractmethod - def to_raven(self) -> dict: + def type(self) -> str: pass @@ -16,11 +17,9 @@ def to_raven(self) -> dict: class TextMessage(StdMessage): text: str - def to_raven(self): - return { - "type": "Text", - "text": self.text, - } + @property + def type(self) -> str: + return "Text" @dataclass @@ -28,12 +27,9 @@ class FileMessage(StdMessage): file_name: str file_content: bytes - def to_raven(self): - return { - "type": "File", - "file_name": self.file_name, - "file_content": self.file_content, - } + @property + def type(self) -> str: + return "File" @dataclass @@ -41,9 +37,6 @@ class ImageMessage(StdMessage): file_name: str file_content: bytes - def to_raven(self): - return { - "type": "Image", - "file_name": self.file_name, - "file_content": self.file_content, - } + @property + def type(self) -> str: + return "Image" diff --git a/raven/omni_channel_chat/omni_channel_raven_connector.py b/raven/omni_channel_chat/omni_channel_raven_connector.py index f1d5ace75..1b4cdd24c 100644 --- a/raven/omni_channel_chat/omni_channel_raven_connector.py +++ b/raven/omni_channel_chat/omni_channel_raven_connector.py @@ -171,25 +171,26 @@ def receive_from_provider(self, message: StdMessage) -> "RavenChannel": return raven_channel def _save_inbound_message(self, *, raven_channel: "RavenChannel", message: StdMessage) -> None: - msg = message.to_raven() + from raven.omni_channel_chat.models.messages import FileMessage, ImageMessage, TextMessage + doc = frappe.new_doc(doctype="Raven Message") doc.update( { "channel_id": raven_channel.name, - "message_type": msg["type"], + "message_type": message.type, "is_customer_message": True, "owner": raven_channel.customer_user, "omni_channel_msg_meta": message.metadata, } ) - if msg["type"] == "Text": - doc.text = msg["text"] - elif msg["type"] in ("Image", "File"): + if isinstance(message, TextMessage): + doc.text = message.text + elif isinstance(message, (ImageMessage, FileMessage)): file_doc = frappe.get_doc( { "doctype": "File", - "file_name": msg["file_name"], - "content": msg["file_content"], + "file_name": message.file_name, + "content": message.file_content, "is_private": 0, } ) From 691d74360834e39b49ba1077d13b9c093ce5081f Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Tue, 21 Apr 2026 03:24:10 +0000 Subject: [PATCH 21/36] refactor: msg object --- .../provider/base_provider.py | 12 +- .../provider/facebook_provider.py | 25 ++- .../provider/instagram_provider.py | 25 ++- .../provider/line_provider.py | 29 ++-- raven/omni_channel_chat/models/message.py | 157 ++++++++++++++++++ .../models/message_objects.py | 5 - raven/omni_channel_chat/models/messages.py | 42 ----- raven/omni_channel_chat/models/user.py | 5 - .../models/webhook_events.py | 13 -- .../omni_channel_raven_connector.py | 149 +++++++++++++---- 10 files changed, 328 insertions(+), 134 deletions(-) create mode 100644 raven/omni_channel_chat/models/message.py delete mode 100644 raven/omni_channel_chat/models/message_objects.py delete mode 100644 raven/omni_channel_chat/models/messages.py delete mode 100644 raven/omni_channel_chat/models/user.py delete mode 100644 raven/omni_channel_chat/models/webhook_events.py diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py index 4358a6468..9591b50b9 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py @@ -3,7 +3,7 @@ from werkzeug.wrappers import Response -from raven.omni_channel_chat.models.messages import StdMessage +from raven.omni_channel_chat.models.message import BaseMessage from raven.omni_channel_chat.omni_channel_raven_connector import OmniChannelRavenConnector if TYPE_CHECKING: @@ -19,7 +19,7 @@ def __init__(self, config: "OmniChannelChatProvider"): self.provider_config = config self.provider_config.decode_password_field() - def push_message_to_raven(self, messages: list[StdMessage]) -> None: + def push_message_to_raven(self, messages: list[BaseMessage]) -> None: handler = OmniChannelRavenConnector(provider=self) for message in messages: handler.receive_from_provider(message) @@ -50,13 +50,13 @@ def send_message(self, user_id: str, message: dict) -> None: """Send an outbound message (push, not reply).""" @abstractmethod - def event_mapper(self, event: ProviderWebhookEvent) -> StdMessage | None: + def event_mapper(self, event: ProviderWebhookEvent) -> BaseMessage | None: """Map a provider-specific webhook event into a standardized message. Return None to skip.""" @abstractmethod - def standardize_events(self, events: list[ProviderWebhookEvent]) -> list[StdMessage]: - """Standardize a list of provider-specific webhook events into StdMessage instances.""" + def standardize_events(self, events: list[ProviderWebhookEvent]) -> list[BaseMessage]: + """Standardize a list of provider-specific webhook events into BaseMessage instances.""" @abstractmethod - def extract_messages(self, body: bytes, headers: dict) -> list[StdMessage]: + def extract_messages(self, body: bytes, headers: dict) -> list[BaseMessage]: """Parse the raw webhook body into standardized messages.""" diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py index f6fc32e4d..17fd78f3b 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py @@ -10,10 +10,11 @@ from raven.omni_channel_chat.doctype.omni_channel_chat_provider.provider import ( Provider, ) -from raven.omni_channel_chat.models.messages import ( +from raven.omni_channel_chat.models.message import ( + BaseMessage, + FileContent, FileMessage, ImageMessage, - StdMessage, TextMessage, ) @@ -117,7 +118,7 @@ def _download_attachment(self, url: str, default_name: str) -> tuple[bytes, str] file_name = content_disposition.split("filename=")[-1].strip('" ') return response.content, file_name - def event_mapper(self, event: FacebookMessagingEvent) -> StdMessage | None: + def event_mapper(self, event: FacebookMessagingEvent) -> BaseMessage | None: message = event.get("message") if message is None: return None @@ -127,7 +128,7 @@ def event_mapper(self, event: FacebookMessagingEvent) -> StdMessage | None: metadata = {"mid": mid} if "text" in message: - return TextMessage(user_id=user_id, metadata=metadata, text=message["text"]) + return TextMessage(provider="facebook", user_id=user_id, metadata=metadata, text=message["text"]) for attachment in message.get("attachments") or []: att_type = attachment.get("type") @@ -137,25 +138,31 @@ def event_mapper(self, event: FacebookMessagingEvent) -> StdMessage | None: if att_type == "image": content, file_name = self._download_attachment(url, f"{mid or 'image'}.jpg") return ImageMessage( - user_id=user_id, metadata=metadata, file_name=file_name, file_content=content + provider="facebook", + user_id=user_id, + metadata=metadata, + file=FileContent(file_name=file_name, file_content=content), ) if att_type in ("file", "document"): content, file_name = self._download_attachment(url, mid or "file") return FileMessage( - user_id=user_id, metadata=metadata, file_name=file_name, file_content=content + provider="facebook", + user_id=user_id, + metadata=metadata, + file=FileContent(file_name=file_name, file_content=content), ) return None - def standardize_events(self, events: list[FacebookMessagingEvent]) -> list[StdMessage]: - std_events: list[StdMessage] = [] + def standardize_events(self, events: list[FacebookMessagingEvent]) -> list[BaseMessage]: + std_events: list[BaseMessage] = [] for event in events: std_event = self.event_mapper(event) if std_event: std_events.append(std_event) return std_events - def extract_messages(self, body: bytes, headers: dict) -> list[StdMessage]: + def extract_messages(self, body: bytes, headers: dict) -> list[BaseMessage]: signature = headers.get("X-Hub-Signature-256", "") or headers.get( "x-hub-signature-256", "" ) diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/instagram_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/instagram_provider.py index 82b4a5e30..b777eb52f 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/instagram_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/instagram_provider.py @@ -10,10 +10,11 @@ from raven.omni_channel_chat.doctype.omni_channel_chat_provider.provider import ( Provider, ) -from raven.omni_channel_chat.models.messages import ( +from raven.omni_channel_chat.models.message import ( + BaseMessage, + FileContent, FileMessage, ImageMessage, - StdMessage, TextMessage, ) @@ -116,7 +117,7 @@ def _download_attachment(self, url: str, default_name: str) -> tuple[bytes, str] file_name = content_disposition.split("filename=")[-1].strip('" ') return response.content, file_name - def event_mapper(self, event: InstagramMessagingEvent) -> StdMessage | None: + def event_mapper(self, event: InstagramMessagingEvent) -> BaseMessage | None: message = event.get("message") if message is None: return None @@ -126,7 +127,7 @@ def event_mapper(self, event: InstagramMessagingEvent) -> StdMessage | None: metadata = {"mid": mid} if "text" in message: - return TextMessage(user_id=user_id, metadata=metadata, text=message["text"]) + return TextMessage(provider="instagram", user_id=user_id, metadata=metadata, text=message["text"]) for attachment in message.get("attachments") or []: att_type = attachment.get("type") @@ -136,25 +137,31 @@ def event_mapper(self, event: InstagramMessagingEvent) -> StdMessage | None: if att_type == "image": content, file_name = self._download_attachment(url, f"{mid or 'image'}.jpg") return ImageMessage( - user_id=user_id, metadata=metadata, file_name=file_name, file_content=content + provider="instagram", + user_id=user_id, + metadata=metadata, + file=FileContent(file_name=file_name, file_content=content), ) if att_type in ("file", "video", "audio"): content, file_name = self._download_attachment(url, mid or "file") return FileMessage( - user_id=user_id, metadata=metadata, file_name=file_name, file_content=content + provider="instagram", + user_id=user_id, + metadata=metadata, + file=FileContent(file_name=file_name, file_content=content), ) return None - def standardize_events(self, events: list[InstagramMessagingEvent]) -> list[StdMessage]: - std_events: list[StdMessage] = [] + def standardize_events(self, events: list[InstagramMessagingEvent]) -> list[BaseMessage]: + std_events: list[BaseMessage] = [] for event in events: std_event = self.event_mapper(event) if std_event: std_events.append(std_event) return std_events - def extract_messages(self, body: bytes, headers: dict) -> list[StdMessage]: + def extract_messages(self, body: bytes, headers: dict) -> list[BaseMessage]: signature = headers.get("X-Hub-Signature-256", "") or headers.get( "x-hub-signature-256", "" ) diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py index b2f4cac01..62f90a9dc 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py @@ -23,10 +23,11 @@ from linebot.v3.webhooks import FileMessageContent, ImageMessageContent, TextMessageContent from linebot.v3.webhooks import MessageEvent as LineMessageEvent -from raven.omni_channel_chat.models.messages import ( +from raven.omni_channel_chat.models.message import ( + BaseMessage, + FileContent, FileMessage, ImageMessage, - StdMessage, TextMessage as StdTextMessage, ) @@ -128,7 +129,7 @@ def _download_line_content(self, message_id: str) -> bytes: with ApiClient(self.config) as api_client: return bytes(MessagingApiBlob(api_client).get_message_content(message_id)) - def event_mapper(self, event: LineEvent) -> StdMessage | None: + def event_mapper(self, event: LineEvent) -> BaseMessage | None: if not isinstance(event, LineMessageEvent): return None @@ -137,35 +138,41 @@ def event_mapper(self, event: LineEvent) -> StdMessage | None: user_id = event.source.user_id if isinstance(msg, TextMessageContent): - return StdTextMessage(user_id=user_id, metadata=metadata, text=msg.text) + return StdTextMessage(provider="line", user_id=user_id, metadata=metadata, text=msg.text) if isinstance(msg, ImageMessageContent): return ImageMessage( + provider="line", user_id=user_id, metadata=metadata, - file_name=f"{msg.id}.jpg", - file_content=self._download_line_content(msg.id), + file=FileContent( + file_name=f"{msg.id}.jpg", + file_content=self._download_line_content(msg.id), + ), ) if isinstance(msg, FileMessageContent): return FileMessage( + provider="line", user_id=user_id, metadata=metadata, - file_name=msg.file_name, - file_content=self._download_line_content(msg.id), + file=FileContent( + file_name=msg.file_name, + file_content=self._download_line_content(msg.id), + ), ) return None - def standardize_events(self, events: list[LineEvent]) -> list[StdMessage]: - std_events: list[StdMessage] = [] + def standardize_events(self, events: list[LineEvent]) -> list[BaseMessage]: + std_events: list[BaseMessage] = [] for event in events: std_event = self.event_mapper(event) if std_event: std_events.append(std_event) return std_events - def extract_messages(self, body: bytes, headers: dict) -> list[StdMessage]: + def extract_messages(self, body: bytes, headers: dict) -> list[BaseMessage]: signature = headers.get("x-line-signature", "") or headers.get("X-Line-Signature", "") try: events = self.parser.parse(body=body.decode(), signature=signature, as_payload=False) diff --git a/raven/omni_channel_chat/models/message.py b/raven/omni_channel_chat/models/message.py new file mode 100644 index 000000000..d3f21530d --- /dev/null +++ b/raven/omni_channel_chat/models/message.py @@ -0,0 +1,157 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import TYPE_CHECKING, Callable + +if TYPE_CHECKING: + from raven.omni_channel_chat.doctype.omni_channel_chat_provider.omni_channel_chat_provider import ( + OmniChannelChatProvider, + ) + from raven.raven_messaging.doctype.raven_message.raven_message import RavenMessage + + ProviderType = OmniChannelChatProvider.provider + MessageType = RavenMessage.message_type + +# --- +# BASE MESSAGE CLASSES +# --- + + +@dataclass(kw_only=True) +class SenderInfo: + name: str + icon_url: str | None = None + + +@dataclass(kw_only=True) +class BaseMessage(ABC): + provider: "ProviderType" + user_id: str + + sender: SenderInfo | None = None + + metadata: dict | None = None + + @property + @abstractmethod + def type(self) -> "MessageType": + """Type of the message to match with Raven Message doctype.""" + + @property + @abstractmethod + def provider_mapping(self) -> dict["ProviderType", Callable[["BaseMessage"], dict]]: + """Mapping of provider to a callable that converts the message to the provider format.""" + + def to_provider(self) -> dict: + if self.provider not in self.provider_mapping: + raise NotImplementedError("Provider not implemented.") + + return self.provider_mapping[self.provider](self) + + +@dataclass(kw_only=True) +class FileUrl: + url: str + + +@dataclass(kw_only=True) +class FileContent: + file_name: str + file_content: bytes + + +File = FileUrl | FileContent + + +# --- +# MESSAGE CLASSES IMPLEMEN +# --- + + +@dataclass(kw_only=True) +class TextMessage(BaseMessage): + text: str + + @property + def type(self) -> str: + return "Text" + + def to_line(self) -> dict: + return { + "type": "text", + "text": self.text, + } + + def to_facebook(self) -> dict: + return { + "text": self.text, + } + + def to_instagram(self) -> dict: + return { + "text": self.text, + } + + def provider_mapping(self): + return { + "line": self.to_line, + "facebook": self.to_facebook, + "instagram": self.to_instagram, + } + + +@dataclass(kw_only=True) +class FileMessage(BaseMessage): + file: File + + @property + def type(self) -> str: + return "File" + + def to_line(self) -> dict: + if isinstance(self.file, FileUrl): + return { + "type": "file", + "file": { + "url": self.file.url, + }, + } + else: + raise NotImplementedError("Line provider does not support file content.") + + def to_facebook(self) -> dict: + if isinstance(self.file, FileUrl): + return { + "attachment": { + "type": "file", + "payload": { + "url": self.file.url, + }, + }, + } + else: + raise NotImplementedError("Facebook provider does not support file content.") + + def to_instagram(self) -> dict: + if isinstance(self.file, FileUrl): + return { + "attachment": { + "type": "file", + "payload": { + "url": self.file.url, + }, + }, + } + else: + raise NotImplementedError("Instagram provider does not support file content.") + + def provider_mapping(self): + return { + "line": self.to_line, + "facebook": self.to_facebook, + "instagram": self.to_instagram, + } + + +@dataclass(kw_only=True) +class ImageMessage(FileMessage): + pass diff --git a/raven/omni_channel_chat/models/message_objects.py b/raven/omni_channel_chat/models/message_objects.py deleted file mode 100644 index 55caeca35..000000000 --- a/raven/omni_channel_chat/models/message_objects.py +++ /dev/null @@ -1,5 +0,0 @@ -from typing import Any - -# Plain dict type alias for outgoing message objects. -# Keys: text (str), sender (dict | None) with keys name (str | None), icon_url (str | None) -MessageObjects = dict[str, Any] diff --git a/raven/omni_channel_chat/models/messages.py b/raven/omni_channel_chat/models/messages.py deleted file mode 100644 index 6de50768a..000000000 --- a/raven/omni_channel_chat/models/messages.py +++ /dev/null @@ -1,42 +0,0 @@ -from abc import ABC, abstractmethod -from dataclasses import dataclass - - -@dataclass -class StdMessage(ABC): - user_id: str - metadata: dict - - @property - @abstractmethod - def type(self) -> str: - pass - - -@dataclass -class TextMessage(StdMessage): - text: str - - @property - def type(self) -> str: - return "Text" - - -@dataclass -class FileMessage(StdMessage): - file_name: str - file_content: bytes - - @property - def type(self) -> str: - return "File" - - -@dataclass -class ImageMessage(StdMessage): - file_name: str - file_content: bytes - - @property - def type(self) -> str: - return "Image" diff --git a/raven/omni_channel_chat/models/user.py b/raven/omni_channel_chat/models/user.py deleted file mode 100644 index bdeaf0729..000000000 --- a/raven/omni_channel_chat/models/user.py +++ /dev/null @@ -1,5 +0,0 @@ -from typing import Any - -# Plain dict type alias for user profile. -# Keys: user_id (str), display_name (str), picture_url (str | None) -UserProfile = dict[str, Any] diff --git a/raven/omni_channel_chat/models/webhook_events.py b/raven/omni_channel_chat/models/webhook_events.py deleted file mode 100644 index 215e39299..000000000 --- a/raven/omni_channel_chat/models/webhook_events.py +++ /dev/null @@ -1,13 +0,0 @@ -from typing import Any - -# Plain dict type aliases for standardized webhook events. -# These replace the previous Pydantic models. - -# {"type": str, "text": str} -StdTextMessage = dict[str, Any] - -# {"provider": str, "user_id": str, "message": StdTextMessage, "message_metadata": dict} -StdWebhookEvent = dict[str, Any] - -# Alias kept for backwards compatibility -StdMessageEvent = StdWebhookEvent diff --git a/raven/omni_channel_chat/omni_channel_raven_connector.py b/raven/omni_channel_chat/omni_channel_raven_connector.py index 1b4cdd24c..70bd3c5b0 100644 --- a/raven/omni_channel_chat/omni_channel_raven_connector.py +++ b/raven/omni_channel_chat/omni_channel_raven_connector.py @@ -3,7 +3,15 @@ import frappe from frappe.utils import get_url -from raven.omni_channel_chat.models.messages import StdMessage +from raven.omni_channel_chat.models.message import ( + BaseMessage, + FileMessage, + FileUrl, + FileContent, + ImageMessage, + SenderInfo, + TextMessage, +) if TYPE_CHECKING: from frappe.core.doctype.user.user import User @@ -21,6 +29,56 @@ from raven.raven_messaging.doctype.raven_message.raven_message import RavenMessage +def _resolve_sender(owner: str) -> "SenderInfo | None": + raven_user = cast( + "dict | None", + frappe.db.get_value("Raven User", owner, ["full_name", "user_image"], as_dict=True), + ) + if not raven_user: + return None + avatar_url = cast("str | None", raven_user["user_image"]) + if avatar_url and avatar_url.startswith("/"): + avatar_url = get_url(avatar_url) + return SenderInfo(name=raven_user["full_name"] or "", icon_url=avatar_url or None) + + +def _build_outbound_message( + raven_message: "RavenMessage", sender: "SenderInfo | None", provider_name: str +) -> BaseMessage: + msg_type = raven_message.message_type + if msg_type == "Text": + return TextMessage(provider=provider_name, user_id="", text=raven_message.content or "", sender=sender) + file_url = raven_message.file + if file_url and file_url.startswith("/"): + file_url = get_url(file_url) + if msg_type == "Image": + return ImageMessage( + provider=provider_name, user_id="", file=FileUrl(url=file_url or ""), sender=sender + ) + if msg_type == "File": + return FileMessage( + provider=provider_name, user_id="", file=FileUrl(url=file_url or ""), sender=sender + ) + raise ValueError(f"Unsupported outbound message type: {msg_type}") + + +def _message_to_payload(message: BaseMessage) -> dict: + sender = ( + {"name": message.sender.name, "icon_url": message.sender.icon_url} + if message.sender + else None + ) + if isinstance(message, TextMessage): + return {"type": "Text", "text": message.text, "sender": sender} + if isinstance(message, ImageMessage): + file_url = message.file.url if isinstance(message.file, FileUrl) else "" + return {"type": "Image", "file_url": file_url, "sender": sender} + if isinstance(message, FileMessage): + file_url = message.file.url if isinstance(message.file, FileUrl) else "" + return {"type": "File", "file_url": file_url, "sender": sender} + raise ValueError(f"Unsupported message type: {type(message)}") + + class OmniChannelRavenConnector: """Bridges Raven and an external omni-channel provider. @@ -153,7 +211,7 @@ def handle_webhook(self, body: bytes, headers: dict) -> None: for message in messages: self.receive_from_provider(message) - def receive_from_provider(self, message: StdMessage) -> "RavenChannel": + def receive_from_provider(self, message: BaseMessage) -> "RavenChannel": """Inbound: turn a provider webhook payload into a Raven message. Creates the Frappe user, Raven user, and channel on first contact, @@ -161,8 +219,8 @@ def receive_from_provider(self, message: StdMessage) -> "RavenChannel": Returns the Raven channel the message was posted to. """ - user = self._get_or_create_customer_user(user_id=message.user_id) - frappe.set_user(user.name) + user = self._get_or_create_customer_user(user_id=message.user_id or "") + frappe.set_user(str(user.name)) raven_user = self._get_or_create_raven_user(user=user, user_id=message.user_id) raven_channel = self._get_or_create_channel(raven_user=raven_user) @@ -170,9 +228,7 @@ def receive_from_provider(self, message: StdMessage) -> "RavenChannel": return raven_channel - def _save_inbound_message(self, *, raven_channel: "RavenChannel", message: StdMessage) -> None: - from raven.omni_channel_chat.models.messages import FileMessage, ImageMessage, TextMessage - + def _save_inbound_message(self, *, raven_channel: "RavenChannel", message: BaseMessage) -> None: doc = frappe.new_doc(doctype="Raven Message") doc.update( { @@ -185,12 +241,12 @@ def _save_inbound_message(self, *, raven_channel: "RavenChannel", message: StdMe ) if isinstance(message, TextMessage): doc.text = message.text - elif isinstance(message, (ImageMessage, FileMessage)): + elif isinstance(message, (ImageMessage, FileMessage)) and isinstance(message.file, FileContent): file_doc = frappe.get_doc( { "doctype": "File", - "file_name": message.file_name, - "content": message.file_content, + "file_name": message.file.file_name, + "content": message.file.file_content, "is_private": 0, } ) @@ -246,29 +302,10 @@ def push_to_provider(cls, raven_message: "RavenMessage") -> None: if not user_id: return - message: dict = {"type": raven_message.message_type} - if raven_message.message_type == "Text": - message["text"] = raven_message.content - elif raven_message.message_type in ("Image", "File"): - file_url = raven_message.file - if file_url and file_url.startswith("/"): - file_url = get_url(file_url) - message["file_url"] = file_url - - sender = cast( - "dict | None", - frappe.db.get_value( - "Raven User", raven_message.owner, ["full_name", "user_image"], as_dict=True - ), - ) - if sender: - avatar_url = cast("str | None", sender["user_image"]) - if avatar_url and avatar_url.startswith("/"): - avatar_url = get_url(avatar_url) - message["sender"] = { - "name": sender["full_name"] or None, - "icon_url": avatar_url or None, - } + provider_name = connector.chat_integration.provider + sender = _resolve_sender(raven_message.owner) + outbound_msg = _build_outbound_message(raven_message, sender, provider_name) + payload = _message_to_payload(outbound_msg) context = frappe.parse_json( cast( @@ -282,4 +319,48 @@ def push_to_provider(cls, raven_message: "RavenMessage") -> None: ) ) - connector.provider.send_reply(user_id=str(user_id), message=message, context=context) + connector.provider.send_reply(user_id=str(user_id), message=payload, context=context) + + @classmethod + def send_outbound_message( + cls, channel_name: str, message: BaseMessage, owner: str | None = None + ) -> None: + """Push a typed outbound message to the external provider for a given Raven channel. + + Unlike push_to_provider (which is triggered by a Raven message doc), this method + accepts any BaseMessage directly — e.g. TrackingStatusMessage — and dispatches + it to the provider without creating a Raven message first. + """ + channel = cast( + "dict | None", + frappe.db.get_value( + doctype="Raven Channel", + filters=channel_name, + fieldname=["is_customer", "customer_user", "omni_channel_chat_provider"], + as_dict=True, + ), + ) + if not channel or not channel["is_customer"] or not channel["omni_channel_chat_provider"]: + return + + provider_config = frappe.get_doc( + "Omni Channel Chat Provider", channel["omni_channel_chat_provider"] + ) + connector = cls(provider=provider_config.get_provider()) + + user_id = frappe.db.get_value( + doctype="User Social Login", + filters={ + "provider": connector.chat_integration.provider, + "parent": channel["customer_user"], + }, + fieldname="userid", + ) + if not user_id: + return + + if message.sender is None and owner: + message.sender = _resolve_sender(owner) + + payload = _message_to_payload(message) + connector.provider.send_message(user_id=str(user_id), message=payload) From 5273fb4c10ef15e2deda10453036615da7994b66 Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Tue, 21 Apr 2026 03:26:55 +0000 Subject: [PATCH 22/36] fix: remove blank provider --- .../omni_channel_chat_provider.json | 4 ++-- .../omni_channel_chat_provider/omni_channel_chat_provider.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.json b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.json index d66e83ad2..d6906d243 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.json +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.json @@ -27,7 +27,7 @@ "fieldtype": "Select", "in_list_view": 1, "label": "Provider", - "options": "\nline\nfacebook\ninstagram", + "options": "line\nfacebook\ninstagram", "reqd": 1 }, { @@ -127,7 +127,7 @@ "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2026-04-10 14:17:07.282091", + "modified": "2026-04-21 10:26:22.036135", "modified_by": "Administrator", "module": "Omni-Channel Chat", "name": "Omni Channel Chat Provider", diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.py index 9e27afb73..b0a73af98 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.py @@ -26,7 +26,7 @@ class OmniChannelChatProvider(Document): ig_verify_token: DF.Password | None line_channel_access_token: DF.Password | None line_channel_secret: DF.Password | None - provider: DF.Literal["", "line", "facebook", "instagram"] + provider: DF.Literal["line", "facebook", "instagram"] raven_workspace: DF.Link # end: auto-generated types From e40a6ac856418644464f6365360481d657ba1827 Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Tue, 21 Apr 2026 03:30:54 +0000 Subject: [PATCH 23/36] refactor: remove instagram, it hasn't been test yet --- .../omni_channel_chat_provider.js | 15 +- .../omni_channel_chat_provider.json | 33 +--- .../omni_channel_chat_provider.py | 9 +- .../provider/__init__.py | 1 - .../provider/instagram_provider.py | 180 ------------------ raven/omni_channel_chat/models/message.py | 20 -- 6 files changed, 4 insertions(+), 254 deletions(-) delete mode 100644 raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/instagram_provider.py diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.js b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.js index bd1b524c2..b037e85c3 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.js +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.js @@ -2,18 +2,5 @@ // For license information, please see license.txt frappe.ui.form.on("Omni Channel Chat Provider", { - refresh(frm) { - set_instagram_banner(frm); - }, - provider(frm) { - set_instagram_banner(frm); - }, + refresh(frm) { }, }); - -function set_instagram_banner(frm) { - if (frm.doc.provider === "instagram") { - frm.set_intro(__("To receive webhooks, your app must be in published state."), "yellow"); - } else { - frm.set_intro(""); - } -} diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.json b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.json index d6906d243..80ce85e61 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.json +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.json @@ -16,10 +16,6 @@ "fb_page_access_token", "fb_verify_token", "fb_app_secret", - "conf_instagram_sec", - "ig_page_access_token", - "ig_verify_token", - "ig_app_secret" ], "fields": [ { @@ -27,7 +23,7 @@ "fieldtype": "Select", "in_list_view": 1, "label": "Provider", - "options": "line\nfacebook\ninstagram", + "options": "line\nfacebook", "reqd": 1 }, { @@ -96,33 +92,6 @@ "length": 512, "mandatory_depends_on": "eval:doc.provider==\"facebook\"" }, - { - "depends_on": "eval:doc.provider==\"instagram\"", - "fieldname": "conf_instagram_sec", - "fieldtype": "Section Break", - "label": "Config - Instagram" - }, - { - "fieldname": "ig_page_access_token", - "fieldtype": "Password", - "label": "Page Access Token", - "length": 512, - "mandatory_depends_on": "eval:doc.provider==\"instagram\"" - }, - { - "fieldname": "ig_verify_token", - "fieldtype": "Password", - "label": "Verify Token", - "length": 512, - "mandatory_depends_on": "eval:doc.provider==\"instagram\"" - }, - { - "fieldname": "ig_app_secret", - "fieldtype": "Password", - "label": "App Secret", - "length": 512, - "mandatory_depends_on": "eval:doc.provider==\"instagram\"" - } ], "grid_page_length": 50, "index_web_pages_for_search": 1, diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.py index b0a73af98..a74d9cae5 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.py @@ -5,7 +5,7 @@ from frappe import _ from frappe.model.document import Document -from .provider import FacebookProvider, InstagramProvider, LineProvider, Provider +from .provider import FacebookProvider, LineProvider, Provider class OmniChannelChatProvider(Document): @@ -21,12 +21,9 @@ class OmniChannelChatProvider(Document): fb_app_secret: DF.Password | None fb_page_access_token: DF.Password | None fb_verify_token: DF.Password | None - ig_app_secret: DF.Password | None - ig_page_access_token: DF.Password | None - ig_verify_token: DF.Password | None line_channel_access_token: DF.Password | None line_channel_secret: DF.Password | None - provider: DF.Literal["line", "facebook", "instagram"] + provider: DF.Literal["line", "facebook"] raven_workspace: DF.Link # end: auto-generated types @@ -47,8 +44,6 @@ def get_provider(self) -> Provider: return LineProvider(config=self) elif self.provider == "facebook": return FacebookProvider(config=self) - elif self.provider == "instagram": - return InstagramProvider(config=self) else: frappe.throw(_("Provider not implemented.")) diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/__init__.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/__init__.py index b6eb899db..cea52469e 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/__init__.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/__init__.py @@ -1,4 +1,3 @@ from .base_provider import Provider as Provider from .facebook_provider import FacebookProvider as FacebookProvider -from .instagram_provider import InstagramProvider as InstagramProvider from .line_provider import LineProvider as LineProvider diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/instagram_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/instagram_provider.py deleted file mode 100644 index b777eb52f..000000000 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/instagram_provider.py +++ /dev/null @@ -1,180 +0,0 @@ -import hashlib -import hmac -import json -from typing import Any - -import frappe -import httpx -from werkzeug.wrappers import Response - -from raven.omni_channel_chat.doctype.omni_channel_chat_provider.provider import ( - Provider, -) -from raven.omni_channel_chat.models.message import ( - BaseMessage, - FileContent, - FileMessage, - ImageMessage, - TextMessage, -) - -InstagramMessagingEvent = dict[str, Any] - - -class InstagramProvider(Provider[InstagramMessagingEvent, dict]): - IG_API_URL = "https://graph.instagram.com/v25.0/me/messages" - - def __init__(self, config): - super().__init__(config=config) - self._page_access_token = self.provider_config.ig_page_access_token - self._app_secret = self.provider_config.ig_app_secret - self._verify_token = self.provider_config.ig_verify_token - - def handle_frappe_api(self) -> Response: - request = frappe.local.request - - if request.method == "GET": - mode = frappe.form_dict.get("hub.mode") - verify_token = frappe.form_dict.get("hub.verify_token") - challenge = frappe.form_dict.get("hub.challenge", "0") - - if mode == "subscribe" and verify_token == self._verify_token: - return Response(challenge, status=200, content_type="text/plain") - else: - frappe.throw("Verification failed", frappe.PermissionError) - - body: bytes = request.get_data() - headers: dict = dict(request.headers) - - return self.handle_webhook(body=body, headers=headers) - - def _verify_signature(self, body: bytes, signature_header: str) -> bool: - if not signature_header.startswith("sha256="): - return False - expected = hmac.new(self._app_secret.encode(), body, hashlib.sha256).hexdigest() - return hmac.compare_digest(expected, signature_header.removeprefix("sha256=")) - - def get_user_info(self, user_id: str) -> dict: - with httpx.Client() as client: - response = client.get( - f"https://graph.facebook.com/v22.0/{user_id}", - params={ - "fields": "name,profile_pic", - "access_token": self._page_access_token, - }, - ) - response.raise_for_status() - data = response.json() - return { - "user_id": user_id, - "display_name": data.get("name"), - "picture_url": data.get("profile_pic"), - } - - def show_typing(self, user_id: str) -> None: - with httpx.Client() as client: - client.post( - self.IG_API_URL, - params={"access_token": self._page_access_token}, - json={"recipient": {"id": user_id}, "sender_action": "typing_on"}, - ) - - def send_reply(self, user_id: str, message: dict, context: Any) -> None: - self.send_message(user_id=user_id, message=message) - - def send_message(self, user_id: str, message: dict) -> None: - msg_type = message.get("type", "Text") - if msg_type == "Image": - ig_message = { - "attachment": { - "type": "image", - "payload": {"url": message["file_url"], "is_reusable": True}, - } - } - elif msg_type == "File": - ig_message = { - "attachment": { - "type": "file", - "payload": {"url": message["file_url"], "is_reusable": True}, - } - } - else: - ig_message = {"text": message["text"]} - with httpx.Client() as client: - client.post( - self.IG_API_URL, - params={"access_token": self._page_access_token}, - json={"recipient": {"id": user_id}, "message": ig_message}, - ) - - def _download_attachment(self, url: str, default_name: str) -> tuple[bytes, str]: - with httpx.Client() as client: - response = client.get(url) - response.raise_for_status() - content_disposition = response.headers.get("content-disposition", "") - file_name = default_name - if "filename=" in content_disposition: - file_name = content_disposition.split("filename=")[-1].strip('" ') - return response.content, file_name - - def event_mapper(self, event: InstagramMessagingEvent) -> BaseMessage | None: - message = event.get("message") - if message is None: - return None - - mid = message.get("mid") - user_id = event["sender"]["id"] - metadata = {"mid": mid} - - if "text" in message: - return TextMessage(provider="instagram", user_id=user_id, metadata=metadata, text=message["text"]) - - for attachment in message.get("attachments") or []: - att_type = attachment.get("type") - url = attachment.get("payload", {}).get("url") - if not url: - continue - if att_type == "image": - content, file_name = self._download_attachment(url, f"{mid or 'image'}.jpg") - return ImageMessage( - provider="instagram", - user_id=user_id, - metadata=metadata, - file=FileContent(file_name=file_name, file_content=content), - ) - if att_type in ("file", "video", "audio"): - content, file_name = self._download_attachment(url, mid or "file") - return FileMessage( - provider="instagram", - user_id=user_id, - metadata=metadata, - file=FileContent(file_name=file_name, file_content=content), - ) - - return None - - def standardize_events(self, events: list[InstagramMessagingEvent]) -> list[BaseMessage]: - std_events: list[BaseMessage] = [] - for event in events: - std_event = self.event_mapper(event) - if std_event: - std_events.append(std_event) - return std_events - - def extract_messages(self, body: bytes, headers: dict) -> list[BaseMessage]: - signature = headers.get("X-Hub-Signature-256", "") or headers.get( - "x-hub-signature-256", "" - ) - if not self._verify_signature(body, signature): - frappe.throw("Invalid Instagram signature", frappe.PermissionError) - - payload = json.loads(body) - if payload.get("object") != "instagram": - frappe.throw("Not an Instagram event", frappe.ValidationError) - - messaging_events: list[InstagramMessagingEvent] = [ - messaging_event - for entry in payload.get("entry", []) - for messaging_event in entry.get("messaging", []) - ] - return self.standardize_events(messaging_events) diff --git a/raven/omni_channel_chat/models/message.py b/raven/omni_channel_chat/models/message.py index d3f21530d..6556cb8af 100644 --- a/raven/omni_channel_chat/models/message.py +++ b/raven/omni_channel_chat/models/message.py @@ -86,16 +86,10 @@ def to_facebook(self) -> dict: "text": self.text, } - def to_instagram(self) -> dict: - return { - "text": self.text, - } - def provider_mapping(self): return { "line": self.to_line, "facebook": self.to_facebook, - "instagram": self.to_instagram, } @@ -131,24 +125,10 @@ def to_facebook(self) -> dict: else: raise NotImplementedError("Facebook provider does not support file content.") - def to_instagram(self) -> dict: - if isinstance(self.file, FileUrl): - return { - "attachment": { - "type": "file", - "payload": { - "url": self.file.url, - }, - }, - } - else: - raise NotImplementedError("Instagram provider does not support file content.") - def provider_mapping(self): return { "line": self.to_line, "facebook": self.to_facebook, - "instagram": self.to_instagram, } From b1d733d7851d116b972b822f66f835b05a92d224 Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Tue, 21 Apr 2026 03:32:02 +0000 Subject: [PATCH 24/36] fix: json error on omni channel chat provider --- .../omni_channel_chat_provider.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.json b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.json index 80ce85e61..3b68ad71b 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.json +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.json @@ -15,7 +15,7 @@ "conf_facebook_sec", "fb_page_access_token", "fb_verify_token", - "fb_app_secret", + "fb_app_secret" ], "fields": [ { @@ -91,7 +91,7 @@ "label": "App Secret", "length": 512, "mandatory_depends_on": "eval:doc.provider==\"facebook\"" - }, + } ], "grid_page_length": 50, "index_web_pages_for_search": 1, From 2dd7def53d3aa52e0d1a272a4ecc152e7c495f3c Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Tue, 21 Apr 2026 03:34:47 +0000 Subject: [PATCH 25/36] refactor: change to generic type --- .../omni_channel_chat_provider/provider/base_provider.py | 6 ++++-- .../provider/facebook_provider.py | 2 +- .../omni_channel_chat_provider/provider/line_provider.py | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py index 9591b50b9..5a1f55827 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Generic, TypeVar from werkzeug.wrappers import Response @@ -11,8 +11,10 @@ OmniChannelChatProvider, ) +ProviderWebhookEvent = TypeVar("ProviderWebhookEvent") -class Provider[ProviderWebhookEvent, ProviderMessageObject](ABC): + +class Provider(ABC, Generic[ProviderWebhookEvent]): provider_config: "OmniChannelChatProvider" def __init__(self, config: "OmniChannelChatProvider"): diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py index 17fd78f3b..a00cc5a9f 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py @@ -22,7 +22,7 @@ FacebookMessagingEvent = dict[str, Any] -class FacebookProvider(Provider[FacebookMessagingEvent, dict]): +class FacebookProvider(Provider[FacebookMessagingEvent]): FB_API_URL = "https://graph.facebook.com/v22.0/me/messages" def __init__(self, config): diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py index 62f90a9dc..4db73b881 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py @@ -34,7 +34,7 @@ from . import Provider -class LineProvider(Provider[LineEvent, list[TextMessage]]): +class LineProvider(Provider[LineEvent]): config: Configuration parser: WebhookParser From 6089fb2e0b92f82d255637083f4b82d94cbec129 Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Tue, 21 Apr 2026 04:13:14 +0000 Subject: [PATCH 26/36] fix: update msg interface --- .../provider/base_provider.py | 4 +- .../provider/facebook_provider.py | 31 ++--- .../provider/line_provider.py | 53 ++------ raven/omni_channel_chat/models/message.py | 118 ++++++++++++------ .../omni_channel_raven_connector.py | 27 +--- 5 files changed, 108 insertions(+), 125 deletions(-) diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py index 5a1f55827..957dff9bf 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py @@ -44,11 +44,11 @@ def show_typing(self, user_id: str) -> None: """Show a typing / loading indicator.""" @abstractmethod - def send_reply(self, user_id: str, message: dict, context: Any) -> None: + def send_reply(self, message: BaseMessage) -> None: """Send a chat response back within the webhook reply context.""" @abstractmethod - def send_message(self, user_id: str, message: dict) -> None: + def send_message(self, message: BaseMessage) -> None: """Send an outbound message (push, not reply).""" @abstractmethod diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py index a00cc5a9f..8d4c5323e 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py @@ -80,32 +80,15 @@ def show_typing(self, user_id: str) -> None: json={"recipient": {"id": user_id}, "sender_action": "typing_on"}, ) - def send_reply(self, user_id: str, message: dict, context: Any) -> None: - self.send_message(user_id=user_id, message=message) - - def send_message(self, user_id: str, message: dict) -> None: - msg_type = message.get("type", "Text") - if msg_type == "Image": - fb_message = { - "attachment": { - "type": "image", - "payload": {"url": message["file_url"], "is_reusable": True}, - } - } - elif msg_type == "File": - fb_message = { - "attachment": { - "type": "file", - "payload": {"url": message["file_url"], "is_reusable": True}, - } - } - else: - fb_message = {"text": message["text"]} + def send_reply(self, message: BaseMessage) -> None: + self.send_message(message) + + def send_message(self, message: BaseMessage) -> None: with httpx.Client() as client: client.post( self.FB_API_URL, params={"access_token": self._page_access_token}, - json={"recipient": {"id": user_id}, "message": fb_message}, + json={"recipient": {"id": message.user_id}, "message": message.to_provider()}, ) def _download_attachment(self, url: str, default_name: str) -> tuple[bytes, str]: @@ -128,7 +111,9 @@ def event_mapper(self, event: FacebookMessagingEvent) -> BaseMessage | None: metadata = {"mid": mid} if "text" in message: - return TextMessage(provider="facebook", user_id=user_id, metadata=metadata, text=message["text"]) + return TextMessage( + provider="facebook", user_id=user_id, metadata=metadata, text=message["text"] + ) for attachment in message.get("attachments") or []: att_type = attachment.get("type") diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py index 4db73b881..26ea84fb8 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py @@ -1,23 +1,15 @@ -from typing import Any - import frappe from linebot.v3.exceptions import InvalidSignatureError from linebot.v3.messaging import ( ApiClient, Configuration, - ImageMessage, Message, MessagingApi, MessagingApiBlob, PushMessageRequest, ReplyMessageRequest, ShowLoadingAnimationRequest, - TextMessage, ) -from linebot.v3.messaging import ( - Sender as LineSender, -) -from linebot.v3.messaging import TextMessage as LineTextMessage from linebot.v3.webhook import WebhookParser from linebot.v3.webhooks import Event as LineEvent from linebot.v3.webhooks import FileMessageContent, ImageMessageContent, TextMessageContent @@ -28,6 +20,8 @@ FileContent, FileMessage, ImageMessage, +) +from raven.omni_channel_chat.models.message import ( TextMessage as StdTextMessage, ) @@ -75,36 +69,9 @@ def _to_https(url: str) -> str: return "https://" + url[7:] return url - def _build_outbound_message(self, message: dict) -> Message: - sender = None - if message.get("sender"): - sender = LineSender( - name=message["sender"].get("name"), - icon_url=message["sender"].get("icon_url"), - ) - msg_type = message.get("type", "Text") - if msg_type == "Image": - file_url = self._to_https(message["file_url"]) - print(file_url) - img_msg = ImageMessage( - original_content_url=file_url, - preview_image_url=file_url, - ) - if sender: - img_msg.sender = sender - return img_msg - # File falls back to a text link (LINE Messaging API has no outbound file type) - text = ( - self._to_https(message["file_url"]) if msg_type == "File" else message.get("text", "") - ) - line_msg = LineTextMessage(text=text) - if sender: - line_msg.sender = sender - return line_msg - - def send_reply(self, user_id: str, message: dict, context: Any) -> None: - reply_token = (context or {}).get("reply_token") - line_msg = self._build_outbound_message(message) + def send_reply(self, message: BaseMessage) -> None: + reply_token = (message.metadata or {}).get("reply_token") + line_msg = message.to_provider() if reply_token: try: with ApiClient(self.config) as api_client: @@ -116,13 +83,13 @@ def send_reply(self, user_id: str, message: dict, context: Any) -> None: pass with ApiClient(self.config) as api_client: MessagingApi(api_client).push_message( - PushMessageRequest(to=user_id, messages=[line_msg]) + PushMessageRequest(to=message.user_id, messages=[line_msg]) ) - def send_message(self, user_id: str, message: dict) -> None: + def send_message(self, message: BaseMessage) -> None: with ApiClient(self.config) as api_client: MessagingApi(api_client).push_message( - PushMessageRequest(to=user_id, messages=[self._build_outbound_message(message)]) + PushMessageRequest(to=message.user_id, messages=[message.to_provider()]) ) def _download_line_content(self, message_id: str) -> bytes: @@ -138,7 +105,9 @@ def event_mapper(self, event: LineEvent) -> BaseMessage | None: user_id = event.source.user_id if isinstance(msg, TextMessageContent): - return StdTextMessage(provider="line", user_id=user_id, metadata=metadata, text=msg.text) + return StdTextMessage( + provider="line", user_id=user_id, metadata=metadata, text=msg.text + ) if isinstance(msg, ImageMessageContent): return ImageMessage( diff --git a/raven/omni_channel_chat/models/message.py b/raven/omni_channel_chat/models/message.py index 6556cb8af..c7cad1d17 100644 --- a/raven/omni_channel_chat/models/message.py +++ b/raven/omni_channel_chat/models/message.py @@ -1,6 +1,16 @@ from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING, Any, Callable + +from linebot.v3.messaging import ( + ImageMessage as LineImageMessage, +) +from linebot.v3.messaging import ( + Sender as LineSender, +) +from linebot.v3.messaging import ( + TextMessageV2 as LineTextMessage, +) if TYPE_CHECKING: from raven.omni_channel_chat.doctype.omni_channel_chat_provider.omni_channel_chat_provider import ( @@ -22,8 +32,22 @@ class SenderInfo: icon_url: str | None = None +# --- +# PROVIDER MESSAGE MIXIN +# --- + + +class LineMessageMixin: + sender: "SenderInfo | None" + + def build_line_sender(self) -> LineSender | None: + if self.sender is not None: + return LineSender(name=self.sender.name, iconUrl=self.sender.icon_url) + return None + + @dataclass(kw_only=True) -class BaseMessage(ABC): +class BaseMessage(ABC, LineMessageMixin): provider: "ProviderType" user_id: str @@ -38,14 +62,14 @@ def type(self) -> "MessageType": @property @abstractmethod - def provider_mapping(self) -> dict["ProviderType", Callable[["BaseMessage"], dict]]: + def provider_mapping(self) -> dict["ProviderType", Callable[[], dict]]: """Mapping of provider to a callable that converts the message to the provider format.""" - def to_provider(self) -> dict: + def to_provider(self) -> Any: if self.provider not in self.provider_mapping: raise NotImplementedError("Provider not implemented.") - return self.provider_mapping[self.provider](self) + return self.provider_mapping[self.provider]() @dataclass(kw_only=True) @@ -63,7 +87,7 @@ class FileContent: # --- -# MESSAGE CLASSES IMPLEMEN +# MESSAGE CLASSES # --- @@ -72,20 +96,16 @@ class TextMessage(BaseMessage): text: str @property - def type(self) -> str: + def type(self): return "Text" - def to_line(self) -> dict: - return { - "type": "text", - "text": self.text, - } + def to_line(self): + return LineTextMessage(text=self.text, sender=self.build_line_sender()) - def to_facebook(self) -> dict: - return { - "text": self.text, - } + def to_facebook(self): + return {"text": self.text} + @property def provider_mapping(self): return { "line": self.to_line, @@ -98,33 +118,29 @@ class FileMessage(BaseMessage): file: File @property - def type(self) -> str: + def type(self): return "File" - def to_line(self) -> dict: + def to_line(self): if isinstance(self.file, FileUrl): - return { - "type": "file", - "file": { - "url": self.file.url, - }, - } - else: - raise NotImplementedError("Line provider does not support file content.") - - def to_facebook(self) -> dict: + return LineTextMessage( + text="File: {url}", + substitution={"url": self.file.url}, + sender=self.build_line_sender(), + ) + raise NotImplementedError("Line provider does not support file content.") + + def to_facebook(self): if isinstance(self.file, FileUrl): return { "attachment": { "type": "file", - "payload": { - "url": self.file.url, - }, - }, + "payload": {"url": self.file.url, "is_reusable": True}, + } } - else: - raise NotImplementedError("Facebook provider does not support file content.") + raise NotImplementedError("Facebook provider does not support file content.") + @property def provider_mapping(self): return { "line": self.to_line, @@ -133,5 +149,35 @@ def provider_mapping(self): @dataclass(kw_only=True) -class ImageMessage(FileMessage): - pass +class ImageMessage(BaseMessage): + file: File + + @property + def type(self): + return "Image" + + def to_line(self): + if isinstance(self.file, FileUrl): + return LineImageMessage( + original_content_url=self.file.url, + preview_image_url=self.file.url, + sender=self.build_line_sender(), + ) + raise NotImplementedError("Line provider does not support file content.") + + def to_facebook(self): + if isinstance(self.file, FileUrl): + return { + "attachment": { + "type": "image", + "payload": {"url": self.file.url, "is_reusable": True}, + } + } + raise NotImplementedError("Facebook provider does not support file content.") + + @property + def provider_mapping(self): + return { + "line": self.to_line, + "facebook": self.to_facebook, + } diff --git a/raven/omni_channel_chat/omni_channel_raven_connector.py b/raven/omni_channel_chat/omni_channel_raven_connector.py index 70bd3c5b0..371ca5cbf 100644 --- a/raven/omni_channel_chat/omni_channel_raven_connector.py +++ b/raven/omni_channel_chat/omni_channel_raven_connector.py @@ -62,22 +62,6 @@ def _build_outbound_message( raise ValueError(f"Unsupported outbound message type: {msg_type}") -def _message_to_payload(message: BaseMessage) -> dict: - sender = ( - {"name": message.sender.name, "icon_url": message.sender.icon_url} - if message.sender - else None - ) - if isinstance(message, TextMessage): - return {"type": "Text", "text": message.text, "sender": sender} - if isinstance(message, ImageMessage): - file_url = message.file.url if isinstance(message.file, FileUrl) else "" - return {"type": "Image", "file_url": file_url, "sender": sender} - if isinstance(message, FileMessage): - file_url = message.file.url if isinstance(message.file, FileUrl) else "" - return {"type": "File", "file_url": file_url, "sender": sender} - raise ValueError(f"Unsupported message type: {type(message)}") - class OmniChannelRavenConnector: """Bridges Raven and an external omni-channel provider. @@ -305,9 +289,8 @@ def push_to_provider(cls, raven_message: "RavenMessage") -> None: provider_name = connector.chat_integration.provider sender = _resolve_sender(raven_message.owner) outbound_msg = _build_outbound_message(raven_message, sender, provider_name) - payload = _message_to_payload(outbound_msg) - - context = frappe.parse_json( + outbound_msg.user_id = str(user_id) + outbound_msg.metadata = frappe.parse_json( cast( str, frappe.db.get_value( @@ -319,7 +302,7 @@ def push_to_provider(cls, raven_message: "RavenMessage") -> None: ) ) - connector.provider.send_reply(user_id=str(user_id), message=payload, context=context) + connector.provider.send_reply(message=outbound_msg) @classmethod def send_outbound_message( @@ -362,5 +345,5 @@ def send_outbound_message( if message.sender is None and owner: message.sender = _resolve_sender(owner) - payload = _message_to_payload(message) - connector.provider.send_message(user_id=str(user_id), message=payload) + message.user_id = str(user_id) + connector.provider.send_message(message=message) From a4d1445e08924f85a83d6b3f6c4daf4988b4cc3d Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Tue, 21 Apr 2026 05:01:49 +0000 Subject: [PATCH 27/36] refactor: message --- .../provider/base_provider.py | 10 +- .../provider/facebook_provider.py | 93 +++++++++++-------- .../provider/line_provider.py | 22 +---- raven/omni_channel_chat/models/message.py | 7 ++ 4 files changed, 72 insertions(+), 60 deletions(-) diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py index 957dff9bf..b28ff6e75 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py @@ -1,9 +1,9 @@ from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any, Generic, TypeVar +from typing import TYPE_CHECKING, Generic, TypeVar from werkzeug.wrappers import Response -from raven.omni_channel_chat.models.message import BaseMessage +from raven.omni_channel_chat.models.message import BaseMessage, FileContent, UserInfo from raven.omni_channel_chat.omni_channel_raven_connector import OmniChannelRavenConnector if TYPE_CHECKING: @@ -36,9 +36,13 @@ def handle_frappe_api(self) -> Response: """Extract data from frappe request and pass to `handle_webhook`.""" @abstractmethod - def get_user_info(self, user_id: str) -> dict: + def get_user_info(self, user_id: str) -> UserInfo: """Fetch user info from the provider's platform.""" + @abstractmethod + def download_attachment(self, url: str, file_name: str | None = None) -> FileContent: + """Download a file from the given URL and return its content and a file name.""" + @abstractmethod def show_typing(self, user_id: str) -> None: """Show a typing / loading indicator.""" diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py index 8d4c5323e..9aece0c97 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py @@ -1,6 +1,7 @@ import hashlib import hmac import json +from dataclasses import dataclass from typing import Any import frappe @@ -16,67 +17,81 @@ FileMessage, ImageMessage, TextMessage, + UserInfo, ) # A "messaging event" dict from the Facebook webhook payload FacebookMessagingEvent = dict[str, Any] +@dataclass +class FacebookConfig: + app_secret: str + page_access_token: str + verify_token: str + + class FacebookProvider(Provider[FacebookMessagingEvent]): - FB_API_URL = "https://graph.facebook.com/v22.0/me/messages" + fb_api_url = "https://graph.facebook.com/v25.0/me/messages" def __init__(self, config): super().__init__(config=config) - self._page_access_token = self.provider_config.fb_page_access_token - self._app_secret = self.provider_config.fb_app_secret - self._verify_token = self.provider_config.fb_verify_token + self.config = FacebookConfig( + app_secret=self.provider_config.fb_app_secret, + page_access_token=self.provider_config.fb_page_access_token, + verify_token=self.provider_config.fb_verify_token, + ) + + def verify_token(self) -> Response: + mode = frappe.form_dict.get("hub.mode") + verify_token = frappe.form_dict.get("hub.verify_token") + challenge = frappe.form_dict.get("hub.challenge", "0") + + if mode == "subscribe" and verify_token == self.config.verify_token: + return Response(challenge, status=200, content_type="text/plain") + else: + frappe.throw("Verification failed", frappe.PermissionError) def handle_frappe_api(self): request = frappe.local.request if request.method == "GET": - mode = frappe.form_dict.get("hub.mode") - verify_token = frappe.form_dict.get("hub.verify_token") - challenge = frappe.form_dict.get("hub.challenge", "0") - - if mode == "subscribe" and verify_token == self._verify_token: - return Response(challenge, status=200, content_type="text/plain") - else: - frappe.throw("Verification failed", frappe.PermissionError) - - body: bytes = request.get_data() - headers: dict = dict(request.headers) - - return self.handle_webhook(body=body, headers=headers) - - def _verify_signature(self, body: bytes, signature_header: str) -> bool: + return self.verify_token() + elif request.method == "POST": + body: bytes = request.get_data() + headers: dict = dict(request.headers) + return self.handle_webhook(body=body, headers=headers) + else: + return Response("Method Not Allowed", status=405, content_type="text/plain") + + def verify_signature(self, body: bytes, signature_header: str) -> bool: if not signature_header.startswith("sha256="): return False - expected = hmac.new(self._app_secret.encode(), body, hashlib.sha256).hexdigest() + expected = hmac.new(self.config.app_secret.encode(), body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature_header.removeprefix("sha256=")) - def get_user_info(self, user_id: str) -> dict: + def get_user_info(self, user_id: str) -> UserInfo: with httpx.Client() as client: response = client.get( - f"https://graph.facebook.com/{user_id}", + f"{self.fb_api_url}/{user_id}", params={ "fields": "name,picture", - "access_token": self._page_access_token, + "access_token": self.config.page_access_token, }, ) response.raise_for_status() data = response.json() - return { - "user_id": user_id, - "display_name": data.get("name"), - "picture_url": data.get("picture", {}).get("data", {}).get("url"), - } + return UserInfo( + user_id=user_id, + display_name=data.get("name"), + picture_url=data.get("picture", {}).get("data", {}).get("url"), + ) def show_typing(self, user_id: str) -> None: with httpx.Client() as client: client.post( - self.FB_API_URL, - params={"access_token": self._page_access_token}, + self.fb_api_url, + params={"access_token": self.config.page_access_token}, json={"recipient": {"id": user_id}, "sender_action": "typing_on"}, ) @@ -86,20 +101,20 @@ def send_reply(self, message: BaseMessage) -> None: def send_message(self, message: BaseMessage) -> None: with httpx.Client() as client: client.post( - self.FB_API_URL, - params={"access_token": self._page_access_token}, + self.fb_api_url, + params={"access_token": self.config.page_access_token}, json={"recipient": {"id": message.user_id}, "message": message.to_provider()}, ) - def _download_attachment(self, url: str, default_name: str) -> tuple[bytes, str]: + def download_attachment(self, url: str, file_name: str | None = None) -> FileContent: with httpx.Client() as client: response = client.get(url) response.raise_for_status() content_disposition = response.headers.get("content-disposition", "") - file_name = default_name + file_name = file_name or "attachment" if "filename=" in content_disposition: file_name = content_disposition.split("filename=")[-1].strip('" ') - return response.content, file_name + return FileContent(file_name=file_name, file_content=response.content) def event_mapper(self, event: FacebookMessagingEvent) -> BaseMessage | None: message = event.get("message") @@ -121,20 +136,18 @@ def event_mapper(self, event: FacebookMessagingEvent) -> BaseMessage | None: if not url: continue if att_type == "image": - content, file_name = self._download_attachment(url, f"{mid or 'image'}.jpg") return ImageMessage( provider="facebook", user_id=user_id, metadata=metadata, - file=FileContent(file_name=file_name, file_content=content), + file=self.download_attachment(url), ) if att_type in ("file", "document"): - content, file_name = self._download_attachment(url, mid or "file") return FileMessage( provider="facebook", user_id=user_id, metadata=metadata, - file=FileContent(file_name=file_name, file_content=content), + file=self.download_attachment(url), ) return None @@ -151,7 +164,7 @@ def extract_messages(self, body: bytes, headers: dict) -> list[BaseMessage]: signature = headers.get("X-Hub-Signature-256", "") or headers.get( "x-hub-signature-256", "" ) - if not self._verify_signature(body, signature): + if not self.verify_signature(body, signature): frappe.throw("Invalid Facebook signature", frappe.PermissionError) payload = json.loads(body) diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py index 26ea84fb8..af7154a39 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py @@ -3,7 +3,6 @@ from linebot.v3.messaging import ( ApiClient, Configuration, - Message, MessagingApi, MessagingApiBlob, PushMessageRequest, @@ -63,12 +62,6 @@ def show_typing(self, user_id: str) -> None: ShowLoadingAnimationRequest(chatId=user_id, loadingSeconds=60) ) - @staticmethod - def _to_https(url: str) -> str: - if url and url.startswith("http://"): - return "https://" + url[7:] - return url - def send_reply(self, message: BaseMessage) -> None: reply_token = (message.metadata or {}).get("reply_token") line_msg = message.to_provider() @@ -92,9 +85,10 @@ def send_message(self, message: BaseMessage) -> None: PushMessageRequest(to=message.user_id, messages=[message.to_provider()]) ) - def _download_line_content(self, message_id: str) -> bytes: + def download_attachment(self, message_id: str, file_name: str | None = None) -> FileContent: with ApiClient(self.config) as api_client: - return bytes(MessagingApiBlob(api_client).get_message_content(message_id)) + content = bytes(MessagingApiBlob(api_client).get_message_content(message_id)) + return FileContent(file_name=file_name or "attachment", file_content=content) def event_mapper(self, event: LineEvent) -> BaseMessage | None: if not isinstance(event, LineMessageEvent): @@ -114,10 +108,7 @@ def event_mapper(self, event: LineEvent) -> BaseMessage | None: provider="line", user_id=user_id, metadata=metadata, - file=FileContent( - file_name=f"{msg.id}.jpg", - file_content=self._download_line_content(msg.id), - ), + file=self.download_attachment(msg.id, f"{msg.id}.jpg"), ) if isinstance(msg, FileMessageContent): @@ -125,10 +116,7 @@ def event_mapper(self, event: LineEvent) -> BaseMessage | None: provider="line", user_id=user_id, metadata=metadata, - file=FileContent( - file_name=msg.file_name, - file_content=self._download_line_content(msg.id), - ), + file=self.download_attachment(msg.id, msg.file_name), ) return None diff --git a/raven/omni_channel_chat/models/message.py b/raven/omni_channel_chat/models/message.py index c7cad1d17..0814e5ee8 100644 --- a/raven/omni_channel_chat/models/message.py +++ b/raven/omni_channel_chat/models/message.py @@ -26,6 +26,13 @@ # --- +@dataclass(kw_only=True) +class UserInfo: + user_id: str + display_name: str + picture_url: str | None = None + + @dataclass(kw_only=True) class SenderInfo: name: str From b7ef54739c3f9cc864dab3e70ee7df8509b9ebc6 Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Tue, 21 Apr 2026 06:38:29 +0000 Subject: [PATCH 28/36] refactor: webhook handler --- raven/omni_channel_chat/api/webhooks.py | 5 ++++- .../provider/base_provider.py | 17 +++++++---------- .../provider/facebook_provider.py | 6 +++--- .../provider/line_provider.py | 7 +++++-- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/raven/omni_channel_chat/api/webhooks.py b/raven/omni_channel_chat/api/webhooks.py index 8276f5529..27e66b9fd 100644 --- a/raven/omni_channel_chat/api/webhooks.py +++ b/raven/omni_channel_chat/api/webhooks.py @@ -4,6 +4,7 @@ from raven.omni_channel_chat.doctype.omni_channel_chat_provider.omni_channel_chat_provider import ( get_omni_channel_chat_provider, ) +from raven.omni_channel_chat.omni_channel_raven_connector import OmniChannelRavenConnector def extract_provider_slug() -> str: @@ -32,4 +33,6 @@ def handle() -> Response: """ slug = extract_provider_slug() provider = get_omni_channel_chat_provider(slug=slug) - return provider.handle_frappe_api() + return provider.handle_frappe_api( + callback=OmniChannelRavenConnector(provider=provider).receive_from_provider + ) diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py index b28ff6e75..8583ec81f 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py @@ -1,10 +1,9 @@ from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Generic, TypeVar +from typing import TYPE_CHECKING, Callable, Generic, TypeVar from werkzeug.wrappers import Response from raven.omni_channel_chat.models.message import BaseMessage, FileContent, UserInfo -from raven.omni_channel_chat.omni_channel_raven_connector import OmniChannelRavenConnector if TYPE_CHECKING: from raven.omni_channel_chat.doctype.omni_channel_chat_provider.omni_channel_chat_provider import ( @@ -21,18 +20,16 @@ def __init__(self, config: "OmniChannelChatProvider"): self.provider_config = config self.provider_config.decode_password_field() - def push_message_to_raven(self, messages: list[BaseMessage]) -> None: - handler = OmniChannelRavenConnector(provider=self) - for message in messages: - handler.receive_from_provider(message) - - def handle_webhook(self, body: bytes, headers: dict) -> Response: + def handle_webhook( + self, body: bytes, headers: dict, callback: Callable[[BaseMessage], None] + ) -> Response: messages = self.extract_messages(body=body, headers=headers) - self.push_message_to_raven(messages=messages) + for message in messages: + callback(message) return Response("ok", status=200, content_type="text/plain") @abstractmethod - def handle_frappe_api(self) -> Response: + def handle_frappe_api(self, callback: Callable[[BaseMessage], None]) -> Response: """Extract data from frappe request and pass to `handle_webhook`.""" @abstractmethod diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py index 9aece0c97..9c55d6c3d 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py @@ -2,7 +2,7 @@ import hmac import json from dataclasses import dataclass -from typing import Any +from typing import Any, Callable import frappe import httpx @@ -52,7 +52,7 @@ def verify_token(self) -> Response: else: frappe.throw("Verification failed", frappe.PermissionError) - def handle_frappe_api(self): + def handle_frappe_api(self, callback: Callable[[BaseMessage], None]) -> Response: request = frappe.local.request if request.method == "GET": @@ -60,7 +60,7 @@ def handle_frappe_api(self): elif request.method == "POST": body: bytes = request.get_data() headers: dict = dict(request.headers) - return self.handle_webhook(body=body, headers=headers) + return self.handle_webhook(body=body, headers=headers, callback=callback) else: return Response("Method Not Allowed", status=405, content_type="text/plain") diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py index af7154a39..2a42438f3 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py @@ -1,3 +1,5 @@ +from typing import Callable + import frappe from linebot.v3.exceptions import InvalidSignatureError from linebot.v3.messaging import ( @@ -13,6 +15,7 @@ from linebot.v3.webhooks import Event as LineEvent from linebot.v3.webhooks import FileMessageContent, ImageMessageContent, TextMessageContent from linebot.v3.webhooks import MessageEvent as LineMessageEvent +from werkzeug.wrappers import Response from raven.omni_channel_chat.models.message import ( BaseMessage, @@ -41,11 +44,11 @@ def __init__(self, config): channel_secret=self.provider_config.line_channel_secret, ) - def handle_frappe_api(self): + def handle_frappe_api(self, callback: Callable[[BaseMessage], None]) -> Response: request = frappe.local.request body: bytes = request.get_data() headers: dict = dict(request.headers) - return self.handle_webhook(body=body, headers=headers) + return self.handle_webhook(body=body, headers=headers, callback=callback) def get_user_info(self, user_id: str) -> dict: with ApiClient(self.config) as api_client: From f72fd0859c89cb3e99c163f3ead588edfb5f6dbf Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Tue, 21 Apr 2026 06:40:57 +0000 Subject: [PATCH 29/36] refactor: minor cleanup --- raven/omni_channel_chat/api/webhooks.py | 6 +++--- .../omni_channel_raven_connector.py | 19 +++++++++++-------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/raven/omni_channel_chat/api/webhooks.py b/raven/omni_channel_chat/api/webhooks.py index 27e66b9fd..cf23a57a4 100644 --- a/raven/omni_channel_chat/api/webhooks.py +++ b/raven/omni_channel_chat/api/webhooks.py @@ -33,6 +33,6 @@ def handle() -> Response: """ slug = extract_provider_slug() provider = get_omni_channel_chat_provider(slug=slug) - return provider.handle_frappe_api( - callback=OmniChannelRavenConnector(provider=provider).receive_from_provider - ) + connector = OmniChannelRavenConnector(provider=provider) + + return provider.handle_frappe_api(callback=connector.receive_from_provider) diff --git a/raven/omni_channel_chat/omni_channel_raven_connector.py b/raven/omni_channel_chat/omni_channel_raven_connector.py index 371ca5cbf..774b76160 100644 --- a/raven/omni_channel_chat/omni_channel_raven_connector.py +++ b/raven/omni_channel_chat/omni_channel_raven_connector.py @@ -5,9 +5,9 @@ from raven.omni_channel_chat.models.message import ( BaseMessage, + FileContent, FileMessage, FileUrl, - FileContent, ImageMessage, SenderInfo, TextMessage, @@ -47,7 +47,9 @@ def _build_outbound_message( ) -> BaseMessage: msg_type = raven_message.message_type if msg_type == "Text": - return TextMessage(provider=provider_name, user_id="", text=raven_message.content or "", sender=sender) + return TextMessage( + provider=provider_name, user_id="", text=raven_message.content or "", sender=sender + ) file_url = raven_message.file if file_url and file_url.startswith("/"): file_url = get_url(file_url) @@ -62,7 +64,6 @@ def _build_outbound_message( raise ValueError(f"Unsupported outbound message type: {msg_type}") - class OmniChannelRavenConnector: """Bridges Raven and an external omni-channel provider. @@ -195,7 +196,7 @@ def handle_webhook(self, body: bytes, headers: dict) -> None: for message in messages: self.receive_from_provider(message) - def receive_from_provider(self, message: BaseMessage) -> "RavenChannel": + def receive_from_provider(self, message: BaseMessage) -> None: """Inbound: turn a provider webhook payload into a Raven message. Creates the Frappe user, Raven user, and channel on first contact, @@ -210,9 +211,9 @@ def receive_from_provider(self, message: BaseMessage) -> "RavenChannel": raven_channel = self._get_or_create_channel(raven_user=raven_user) self._save_inbound_message(raven_channel=raven_channel, message=message) - return raven_channel - - def _save_inbound_message(self, *, raven_channel: "RavenChannel", message: BaseMessage) -> None: + def _save_inbound_message( + self, *, raven_channel: "RavenChannel", message: BaseMessage + ) -> None: doc = frappe.new_doc(doctype="Raven Message") doc.update( { @@ -225,7 +226,9 @@ def _save_inbound_message(self, *, raven_channel: "RavenChannel", message: BaseM ) if isinstance(message, TextMessage): doc.text = message.text - elif isinstance(message, (ImageMessage, FileMessage)) and isinstance(message.file, FileContent): + elif isinstance(message, (ImageMessage, FileMessage)) and isinstance( + message.file, FileContent + ): file_doc = frappe.get_doc( { "doctype": "File", From e4a90c66acbca219a1d1e5bf588dcc16ba493c2a Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Tue, 21 Apr 2026 07:06:22 +0000 Subject: [PATCH 30/36] docs: add readme --- raven/omni_channel_chat/README.md | 36 +++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 raven/omni_channel_chat/README.md diff --git a/raven/omni_channel_chat/README.md b/raven/omni_channel_chat/README.md new file mode 100644 index 000000000..0db81bb56 --- /dev/null +++ b/raven/omni_channel_chat/README.md @@ -0,0 +1,36 @@ +## Feature + +### Messaging + +| | Line | Facebook | +|-------------|------|----------| +| Incoming | | | +| Message | | | +| Text | Y | Y | +| Image | Y | Y | +| File | Y | Y | +| Sticker | TODO | Y | +| Feature | | | +| Reply | TODO | TODO | +| Outgoing | | | +| Message | | | +| Text | Y | Y | +| Image | *1 | *1 | +| File | *1 | *1 | +| Feature | | | +| Reply | TODO | TODO | + +**Remark** + +1. Public / Private file has to be handle properly, currently files upload to raven will be private file but chat required it to be private. + +## TODO + +### UX/UI + +- [ ] Create Omni-Channel Chat interface for mobile + +### Provider + +- [ ] Instagram +- [ ] WhatsApp From 0fdd7824b57618223cfe015e0109251bb3bd5aea Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Tue, 21 Apr 2026 13:03:03 +0000 Subject: [PATCH 31/36] refactor: container --- raven/omni_channel_chat/README.md | 9 +- .../provider/facebook_provider.py | 18 +- .../provider/line_provider.py | 27 +- raven/omni_channel_chat/models/message.py | 2 + .../omni_channel_raven_connector.py | 412 +++++++++--------- .../doctype/raven_message/raven_message.py | 4 +- 6 files changed, 237 insertions(+), 235 deletions(-) diff --git a/raven/omni_channel_chat/README.md b/raven/omni_channel_chat/README.md index 0db81bb56..d996f6e16 100644 --- a/raven/omni_channel_chat/README.md +++ b/raven/omni_channel_chat/README.md @@ -22,7 +22,7 @@ **Remark** -1. Public / Private file has to be handle properly, currently files upload to raven will be private file but chat required it to be private. +1. Public / Private file has to be handle properly, currently files upload to raven will be private file but chat required it to be private ## TODO @@ -34,3 +34,10 @@ - [ ] Instagram - [ ] WhatsApp + +### Misc + +- [ ] Outgoing messages needs message id so we could reply to it properly +- [x] Provider from social login has to change to use provider id, using just "facebook" or "line" will cause error when there are more than 1 provider per each provider (prefix like `OCC_` should be add to provider) +- [x] On BaseMessage `provider` should be change to `provider_id` +- [ ] Create inject interface for connector class \ No newline at end of file diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py index 9c55d6c3d..6e9c0b3b2 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py @@ -32,7 +32,7 @@ class FacebookConfig: class FacebookProvider(Provider[FacebookMessagingEvent]): - fb_api_url = "https://graph.facebook.com/v25.0/me/messages" + fb_api_url = "https://graph.facebook.com/v25.0" def __init__(self, config): super().__init__(config=config) @@ -90,7 +90,7 @@ def get_user_info(self, user_id: str) -> UserInfo: def show_typing(self, user_id: str) -> None: with httpx.Client() as client: client.post( - self.fb_api_url, + f"{self.fb_api_url}/me/messages", params={"access_token": self.config.page_access_token}, json={"recipient": {"id": user_id}, "sender_action": "typing_on"}, ) @@ -101,7 +101,7 @@ def send_reply(self, message: BaseMessage) -> None: def send_message(self, message: BaseMessage) -> None: with httpx.Client() as client: client.post( - self.fb_api_url, + f"{self.fb_api_url}/me/messages", params={"access_token": self.config.page_access_token}, json={"recipient": {"id": message.user_id}, "message": message.to_provider()}, ) @@ -127,7 +127,11 @@ def event_mapper(self, event: FacebookMessagingEvent) -> BaseMessage | None: if "text" in message: return TextMessage( - provider="facebook", user_id=user_id, metadata=metadata, text=message["text"] + provider=self.provider_config.provider, + provider_id=self.provider_config.name, + user_id=user_id, + metadata=metadata, + text=message["text"], ) for attachment in message.get("attachments") or []: @@ -137,14 +141,16 @@ def event_mapper(self, event: FacebookMessagingEvent) -> BaseMessage | None: continue if att_type == "image": return ImageMessage( - provider="facebook", + provider=self.provider_config.provider, + provider_id=self.provider_config.name, user_id=user_id, metadata=metadata, file=self.download_attachment(url), ) if att_type in ("file", "document"): return FileMessage( - provider="facebook", + provider=self.provider_config.provider, + provider_id=self.provider_config.name, user_id=user_id, metadata=metadata, file=self.download_attachment(url), diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py index 2a42438f3..d609986dd 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py @@ -22,6 +22,7 @@ FileContent, FileMessage, ImageMessage, + UserInfo, ) from raven.omni_channel_chat.models.message import ( TextMessage as StdTextMessage, @@ -50,14 +51,14 @@ def handle_frappe_api(self, callback: Callable[[BaseMessage], None]) -> Response headers: dict = dict(request.headers) return self.handle_webhook(body=body, headers=headers, callback=callback) - def get_user_info(self, user_id: str) -> dict: + def get_user_info(self, user_id: str) -> UserInfo: with ApiClient(self.config) as api_client: profile = MessagingApi(api_client).get_profile(user_id) - return { - "user_id": profile.user_id, - "display_name": profile.display_name, - "picture_url": profile.picture_url, - } + return UserInfo( + user_id=profile.user_id, + display_name=profile.display_name, + picture_url=profile.picture_url, + ) def show_typing(self, user_id: str) -> None: with ApiClient(self.config) as api_client: @@ -85,7 +86,7 @@ def send_reply(self, message: BaseMessage) -> None: def send_message(self, message: BaseMessage) -> None: with ApiClient(self.config) as api_client: MessagingApi(api_client).push_message( - PushMessageRequest(to=message.user_id, messages=[message.to_provider()]) + PushMessageRequest(to=message.user_id, messages=[message.to_line()]) ) def download_attachment(self, message_id: str, file_name: str | None = None) -> FileContent: @@ -103,12 +104,17 @@ def event_mapper(self, event: LineEvent) -> BaseMessage | None: if isinstance(msg, TextMessageContent): return StdTextMessage( - provider="line", user_id=user_id, metadata=metadata, text=msg.text + provider=self.provider_config.provider, + provider_id=self.provider_config.name, + user_id=user_id, + metadata=metadata, + text=msg.text, ) if isinstance(msg, ImageMessageContent): return ImageMessage( - provider="line", + provider=self.provider_config.provider, + provider_id=self.provider_config.name, user_id=user_id, metadata=metadata, file=self.download_attachment(msg.id, f"{msg.id}.jpg"), @@ -116,7 +122,8 @@ def event_mapper(self, event: LineEvent) -> BaseMessage | None: if isinstance(msg, FileMessageContent): return FileMessage( - provider="line", + provider=self.provider_config.provider, + provider_id=self.provider_config.name, user_id=user_id, metadata=metadata, file=self.download_attachment(msg.id, msg.file_name), diff --git a/raven/omni_channel_chat/models/message.py b/raven/omni_channel_chat/models/message.py index 0814e5ee8..5e3a8d803 100644 --- a/raven/omni_channel_chat/models/message.py +++ b/raven/omni_channel_chat/models/message.py @@ -56,6 +56,8 @@ def build_line_sender(self) -> LineSender | None: @dataclass(kw_only=True) class BaseMessage(ABC, LineMessageMixin): provider: "ProviderType" + + provider_id: str user_id: str sender: SenderInfo | None = None diff --git a/raven/omni_channel_chat/omni_channel_raven_connector.py b/raven/omni_channel_chat/omni_channel_raven_connector.py index 774b76160..7684a55fe 100644 --- a/raven/omni_channel_chat/omni_channel_raven_connector.py +++ b/raven/omni_channel_chat/omni_channel_raven_connector.py @@ -1,8 +1,12 @@ from typing import TYPE_CHECKING, cast import frappe -from frappe.utils import get_url +from frappe import _ +from frappe.utils import get_host_name, get_site_name, get_url +from raven.omni_channel_chat.doctype.omni_channel_chat_provider.omni_channel_chat_provider import ( + get_omni_channel_chat_provider, +) from raven.omni_channel_chat.models.message import ( BaseMessage, FileContent, @@ -16,9 +20,6 @@ if TYPE_CHECKING: from frappe.core.doctype.user.user import User - from raven.omni_channel_chat.doctype.omni_channel_chat_provider.omni_channel_chat_provider import ( - OmniChannelChatProvider, - ) from raven.omni_channel_chat.doctype.omni_channel_chat_provider.provider import ( Provider, ) @@ -29,69 +30,114 @@ from raven.raven_messaging.doctype.raven_message.raven_message import RavenMessage -def _resolve_sender(owner: str) -> "SenderInfo | None": - raven_user = cast( - "dict | None", - frappe.db.get_value("Raven User", owner, ["full_name", "user_image"], as_dict=True), - ) - if not raven_user: - return None - avatar_url = cast("str | None", raven_user["user_image"]) - if avatar_url and avatar_url.startswith("/"): - avatar_url = get_url(avatar_url) - return SenderInfo(name=raven_user["full_name"] or "", icon_url=avatar_url or None) - - -def _build_outbound_message( - raven_message: "RavenMessage", sender: "SenderInfo | None", provider_name: str -) -> BaseMessage: - msg_type = raven_message.message_type - if msg_type == "Text": - return TextMessage( - provider=provider_name, user_id="", text=raven_message.content or "", sender=sender - ) - file_url = raven_message.file - if file_url and file_url.startswith("/"): - file_url = get_url(file_url) - if msg_type == "Image": - return ImageMessage( - provider=provider_name, user_id="", file=FileUrl(url=file_url or ""), sender=sender - ) - if msg_type == "File": - return FileMessage( - provider=provider_name, user_id="", file=FileUrl(url=file_url or ""), sender=sender - ) - raise ValueError(f"Unsupported outbound message type: {msg_type}") - - class OmniChannelRavenConnector: - """Bridges Raven and an external omni-channel provider. - - Two main interfaces: - receive_from_provider – inbound path: provider webhook payload → Raven message - push_to_provider – outbound path: Raven message → external provider - """ + provider_prefix = "occ" def __init__(self, provider: "Provider"): self.provider = provider - self.chat_integration: "OmniChannelChatProvider" = provider.provider_config - # ── shared helpers ────────────────────────────────────────────────────── + @staticmethod + def get_provider_from_channel(channel_name: str) -> "Provider": + provider_name = frappe.db.get_value( + doctype="Raven Channel", + filters=channel_name, + fieldname="omni_channel_chat_provider", + ) + if not provider_name: + frappe.throw( + _("Omni Channel Chat Provider not found for channel {0}").format(channel_name) + ) + return get_omni_channel_chat_provider( + slug=provider_name, + ) - def _get_or_create_customer_user(self, user_id: str) -> "User": - provider_name = self.chat_integration.provider + def get_provider_pk(self, provider_id: str) -> str: + return f"{self.provider_prefix}_{provider_id}" - user_pk = frappe.db.get_value( + def get_channel_name(self, raven_user: "RavenUser") -> str: + return f"{self.provider.provider_config.name}_{raven_user.name}" + + def get_sender_info(self, owner: str) -> "SenderInfo | None": + raven_user = cast( + "dict | None", + frappe.db.get_value("Raven User", owner, ["full_name", "user_image"], as_dict=True), + ) + if not raven_user: + return None + avatar_url = cast("str | None", raven_user["user_image"]) + if avatar_url and avatar_url.startswith("/"): + avatar_url = get_url(avatar_url) + return SenderInfo(name=raven_user["full_name"] or "", icon_url=avatar_url or None) + + def get_user_id(self, channel_id: str) -> str: + channel_user = frappe.db.get_value( + doctype="Raven Channel", + filters=channel_id, + fieldname="customer_user", + ) + + user_id = frappe.db.get_value( doctype="User Social Login", - filters={"provider": provider_name, "userid": user_id}, - fieldname="parent", + filters={ + "provider": self.get_provider_pk(self.provider.provider_config.name), + "parent": channel_user, + }, + fieldname="userid", ) - if user_pk: - return cast("User", frappe.get_doc("User", str(user_pk))) + if not user_id: + frappe.throw(_("User ID not found for channel {0}").format(channel_id)) + + return user_id + + def raven_to_std_msg( + self, + raven_message: "RavenMessage", + user_id: str, + sender: "SenderInfo | None", + ) -> BaseMessage: + provider_id = self.provider.provider_config.name + msg_type = raven_message.message_type + if msg_type == "Text": + return TextMessage( + provider=self.provider.provider_config.provider, + provider_id=provider_id, + user_id=user_id, + text=raven_message.content or "", + sender=sender, + ) + file_url = raven_message.file + if file_url and file_url.startswith("/"): + file_url = get_url(file_url) + if msg_type == "Image": + return ImageMessage( + provider=self.provider.provider_config.provider, + provider_id=provider_id, + user_id=user_id, + file=FileUrl(url=file_url or ""), + sender=sender, + ) + if msg_type == "File": + return FileMessage( + provider=self.provider.provider_config.provider, + provider_id=provider_id, + user_id=user_id, + file=FileUrl(url=file_url or ""), + sender=sender, + ) + + raise ValueError(f"Unsupported outbound message type: {msg_type}") + + def get_hostname_without_port(self) -> str: + return get_site_name(get_host_name()) + + def create_customer_user(self, user_id: str, provider_id: str) -> "User": + provider_pk = self.get_provider_pk(provider_id) username = frappe.generate_hash(length=10) - email = f"{username}@users.cafn.co" + hostname = self.get_hostname_without_port() + + email = f"{username}@{self.provider_prefix}.{hostname}" user = frappe.new_doc("User") user.update( @@ -108,7 +154,7 @@ def _get_or_create_customer_user(self, user_id: str) -> "User": user_social_login = frappe.new_doc("User Social Login") user_social_login.update( { - "provider": provider_name, + "provider": provider_pk, "userid": user_id, "parent": user.name, "parenttype": "User", @@ -117,103 +163,86 @@ def _get_or_create_customer_user(self, user_id: str) -> "User": ) user_social_login.insert(ignore_permissions=True) - return cast("User", user) + return user - def _get_or_create_raven_user(self, *, user: "User", user_id: str) -> "RavenUser": - raven_user_pk = frappe.db.get_value( - doctype="Raven User", - filters={"user": user.name}, - fieldname="name", + def get_or_create_customer_user(self, user_id: str | None, provider_id: str) -> "User": + provider_pk = self.get_provider_pk(provider_id) + + user_pk = frappe.db.get_value( + doctype="User Social Login", + filters={"provider": provider_pk, "userid": user_id}, + fieldname="parent", ) - if raven_user_pk: - return cast("RavenUser", frappe.get_doc("Raven User", str(raven_user_pk))) + if user_pk: + return frappe.get_doc("User", user_pk) + return self.create_customer_user(user_id=user_id, provider_id=provider_id) + + def create_raven_user(self, user: "User", user_id: str) -> "RavenUser": user_info = self.provider.get_user_info(user_id=user_id) + raven_user = frappe.new_doc("Raven User") raven_user.update( { "type": "Customer", "user": user.name, - "full_name": user_info["display_name"], - "user_image": user_info["picture_url"], + "full_name": user_info.display_name, + "user_image": user_info.picture_url, "enabled": True, } ) raven_user.insert(ignore_permissions=True) - return cast("RavenUser", raven_user) + return raven_user - def _get_or_create_channel(self, raven_user: "RavenUser") -> "RavenChannel": - channel_name = f"{self.chat_integration.name}_{raven_user.name}" - channel_pk = frappe.db.get_value( - doctype="Raven Channel", - filters=channel_name, + def get_or_create_raven_user(self, user: "User", user_id: str) -> "RavenUser": + raven_user_pk = frappe.db.get_value( + doctype="Raven User", + filters={"user": user.name}, fieldname="name", ) - if channel_pk: - return cast("RavenChannel", frappe.get_doc("Raven Channel", str(channel_pk))) + if raven_user_pk: + return frappe.get_doc("Raven User", raven_user_pk) - return cast( - "RavenChannel", - frappe.get_doc( - { - "doctype": "Raven Channel", - "channel_name": channel_name, - "id": channel_name, - "type": "Public", - "customer_user": raven_user.user, - "omni_channel_chat_provider": self.chat_integration.name, - "is_customer": True, - "enabled": True, - "workspace": self.chat_integration.raven_workspace, - } - ).insert(ignore_permissions=True), - ) + return self.create_raven_user(user=user, user_id=user_id) - def _get_external_user_id(self, customer_user: str) -> str: - """Resolve the provider's external user_id for a given Frappe user.""" - user_id = frappe.db.get_value( - doctype="User Social Login", - filters={ - "provider": self.chat_integration.provider, - "parent": customer_user, - }, - fieldname="userid", - ) - if not user_id: - frappe.throw( - f"No {self.chat_integration.provider} social login found for user {customer_user}" - ) - return cast(str, user_id) + def create_channel(self, raven_user: "RavenUser") -> "RavenChannel": + channel_name = self.get_channel_name(raven_user) - # ── interface 1: provider → Raven (inbound) ───────────────────────────── + channel = frappe.new_doc("Raven Channel") + channel.update( + { + "channel_name": channel_name, + "id": channel_name, + "type": "Public", + "customer_user": raven_user.user, + "omni_channel_chat_provider": self.provider.provider_config.name, + "is_customer": True, + "enabled": True, + "workspace": self.provider.provider_config.raven_workspace, + } + ) + channel.insert(ignore_permissions=True) - def handle_webhook(self, body: bytes, headers: dict) -> None: - """Parse a raw webhook payload and persist all contained messages to Raven.""" - messages = self.provider.extract_messages(body=body, headers=headers) - for message in messages: - self.receive_from_provider(message) + return channel - def receive_from_provider(self, message: BaseMessage) -> None: - """Inbound: turn a provider webhook payload into a Raven message. + def get_or_create_channel(self, raven_user: "RavenUser") -> "RavenChannel": + channel_name = self.get_channel_name(raven_user) - Creates the Frappe user, Raven user, and channel on first contact, - then appends the message to the channel. + channel_pk = frappe.db.get_value( + doctype="Raven Channel", + filters=channel_name, + fieldname="name", + ) - Returns the Raven channel the message was posted to. - """ - user = self._get_or_create_customer_user(user_id=message.user_id or "") - frappe.set_user(str(user.name)) + if channel_pk: + return frappe.get_doc("Raven Channel", channel_pk) - raven_user = self._get_or_create_raven_user(user=user, user_id=message.user_id) - raven_channel = self._get_or_create_channel(raven_user=raven_user) - self._save_inbound_message(raven_channel=raven_channel, message=message) + return self.create_channel(raven_user) - def _save_inbound_message( - self, *, raven_channel: "RavenChannel", message: BaseMessage - ) -> None: + def create_raven_message(self, raven_channel: "RavenChannel", message: BaseMessage) -> None: doc = frappe.new_doc(doctype="Raven Message") doc.update( { @@ -224,6 +253,7 @@ def _save_inbound_message( "omni_channel_msg_meta": message.metadata, } ) + if isinstance(message, TextMessage): doc.text = message.text elif isinstance(message, (ImageMessage, FileMessage)) and isinstance( @@ -239,114 +269,62 @@ def _save_inbound_message( ) file_doc.insert(ignore_permissions=True) doc.file = file_doc.file_url + doc.insert(ignore_permissions=True) - # ── interface 2: Raven → provider (outbound) ──────────────────────────── + # ── provider → Raven (inbound) ───────────────────────────── - @classmethod - def push_to_provider(cls, raven_message: "RavenMessage") -> None: - """Outbound: forward a staff Raven message to the customer on the external provider. + def receive_from_provider(self, message: BaseMessage) -> None: + """Inbound: turn a provider webhook payload into a Raven message. - Handles guard conditions, channel/provider resolution, message payload building - (text + sender avatar), reply-context fetch (e.g. LINE reply token), and dispatch. - Does nothing if the message is from a customer, a bot, or is a system message, - or if the channel is not an omni-channel customer channel. + Creates the Frappe user, Raven user, and channel on first contact, + then appends the message to the channel. + + Returns the Raven channel the message was posted to. """ - if ( - raven_message.is_customer_message - or raven_message.is_bot_message - or raven_message.message_type in ("System", "Poll") - ): - return + user_id = message.user_id - channel = cast( - "dict | None", - frappe.db.get_value( - doctype="Raven Channel", - filters=raven_message.channel_id, - fieldname=["is_customer", "customer_user", "omni_channel_chat_provider"], - as_dict=True, - cache=True, - ), + user = self.get_or_create_customer_user( + user_id=user_id, + provider_id=message.provider_id, ) - if not channel or not channel["is_customer"] or not channel["omni_channel_chat_provider"]: - return + frappe.set_user(user.name) - provider_config = frappe.get_doc( - "Omni Channel Chat Provider", channel["omni_channel_chat_provider"] + raven_user = self.get_or_create_raven_user( + user=user, + user_id=user_id, ) - connector = cls(provider=provider_config.get_provider()) - - user_id = frappe.db.get_value( - doctype="User Social Login", - filters={ - "provider": connector.chat_integration.provider, - "parent": channel["customer_user"], - }, - fieldname="userid", + raven_channel = self.get_or_create_channel( + raven_user=raven_user, ) - if not user_id: - return - - provider_name = connector.chat_integration.provider - sender = _resolve_sender(raven_message.owner) - outbound_msg = _build_outbound_message(raven_message, sender, provider_name) - outbound_msg.user_id = str(user_id) - outbound_msg.metadata = frappe.parse_json( - cast( - str, - frappe.db.get_value( - "Raven Message", - filters={"channel_id": raven_message.channel_id, "is_customer_message": 1}, - fieldname="omni_channel_msg_meta", - order_by="creation desc", - ), - ) + self.create_raven_message( + raven_channel=raven_channel, + message=message, ) - connector.provider.send_reply(message=outbound_msg) + # ── Raven → provider (outbound) ──────────────────────────── - @classmethod - def send_outbound_message( - cls, channel_name: str, message: BaseMessage, owner: str | None = None - ) -> None: - """Push a typed outbound message to the external provider for a given Raven channel. + def push_to_provider(self, raven_message: "RavenMessage") -> None: + """Outbound: forward a staff Raven message to the customer on the external provider. - Unlike push_to_provider (which is triggered by a Raven message doc), this method - accepts any BaseMessage directly — e.g. TrackingStatusMessage — and dispatches - it to the provider without creating a Raven message first. + Handles guard conditions, channel/provider resolution, message payload building + (text + sender avatar), reply-context fetch (e.g. LINE reply token), and dispatch. + Does nothing if the message is from a customer, a bot, or is a system message, + or if the channel is not an omni-channel customer channel. """ - channel = cast( - "dict | None", - frappe.db.get_value( - doctype="Raven Channel", - filters=channel_name, - fieldname=["is_customer", "customer_user", "omni_channel_chat_provider"], - as_dict=True, - ), - ) - if not channel or not channel["is_customer"] or not channel["omni_channel_chat_provider"]: + if ( + raven_message.is_customer_message + or raven_message.is_bot_message + or raven_message.message_type in ("System", "Poll") + ): return - provider_config = frappe.get_doc( - "Omni Channel Chat Provider", channel["omni_channel_chat_provider"] + user_id = self.get_user_id(channel_id=raven_message.channel_id) + sender = self.get_sender_info(owner=raven_message.owner) + outbound_msg = self.raven_to_std_msg( + raven_message=raven_message, + sender=sender, + user_id=user_id, ) - connector = cls(provider=provider_config.get_provider()) - - user_id = frappe.db.get_value( - doctype="User Social Login", - filters={ - "provider": connector.chat_integration.provider, - "parent": channel["customer_user"], - }, - fieldname="userid", - ) - if not user_id: - return - - if message.sender is None and owner: - message.sender = _resolve_sender(owner) - - message.user_id = str(user_id) - connector.provider.send_message(message=message) + self.provider.send_reply(message=outbound_msg) diff --git a/raven/raven_messaging/doctype/raven_message/raven_message.py b/raven/raven_messaging/doctype/raven_message/raven_message.py index dfd09398a..61155a146 100644 --- a/raven/raven_messaging/doctype/raven_message/raven_message.py +++ b/raven/raven_messaging/doctype/raven_message/raven_message.py @@ -721,7 +721,9 @@ def on_trash(self): def push_message_to_omni_channel_chat_provider(self) -> None: from raven.omni_channel_chat.omni_channel_raven_connector import OmniChannelRavenConnector - OmniChannelRavenConnector.push_to_provider(raven_message=self) + provider = OmniChannelRavenConnector.get_provider_from_channel(self.channel_id) + connector = OmniChannelRavenConnector(provider=provider) + connector.push_to_provider(raven_message=self) def on_doctype_update(): From ff910b351503dd65fc271eecdfbac8c99d18d2e9 Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Wed, 22 Apr 2026 02:17:50 +0000 Subject: [PATCH 32/36] refactor: connector func name --- raven/omni_channel_chat/api/webhooks.py | 2 +- raven/omni_channel_chat/omni_channel_raven_connector.py | 4 ++-- raven/raven_messaging/doctype/raven_message/raven_message.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/raven/omni_channel_chat/api/webhooks.py b/raven/omni_channel_chat/api/webhooks.py index cf23a57a4..8e08e4a15 100644 --- a/raven/omni_channel_chat/api/webhooks.py +++ b/raven/omni_channel_chat/api/webhooks.py @@ -35,4 +35,4 @@ def handle() -> Response: provider = get_omni_channel_chat_provider(slug=slug) connector = OmniChannelRavenConnector(provider=provider) - return provider.handle_frappe_api(callback=connector.receive_from_provider) + return provider.handle_frappe_api(callback=connector.handle_inbound) diff --git a/raven/omni_channel_chat/omni_channel_raven_connector.py b/raven/omni_channel_chat/omni_channel_raven_connector.py index 7684a55fe..4003fc99b 100644 --- a/raven/omni_channel_chat/omni_channel_raven_connector.py +++ b/raven/omni_channel_chat/omni_channel_raven_connector.py @@ -274,7 +274,7 @@ def create_raven_message(self, raven_channel: "RavenChannel", message: BaseMessa # ── provider → Raven (inbound) ───────────────────────────── - def receive_from_provider(self, message: BaseMessage) -> None: + def handle_inbound(self, message: BaseMessage) -> None: """Inbound: turn a provider webhook payload into a Raven message. Creates the Frappe user, Raven user, and channel on first contact, @@ -305,7 +305,7 @@ def receive_from_provider(self, message: BaseMessage) -> None: # ── Raven → provider (outbound) ──────────────────────────── - def push_to_provider(self, raven_message: "RavenMessage") -> None: + def handle_outbound(self, raven_message: "RavenMessage") -> None: """Outbound: forward a staff Raven message to the customer on the external provider. Handles guard conditions, channel/provider resolution, message payload building diff --git a/raven/raven_messaging/doctype/raven_message/raven_message.py b/raven/raven_messaging/doctype/raven_message/raven_message.py index 61155a146..c92a10dff 100644 --- a/raven/raven_messaging/doctype/raven_message/raven_message.py +++ b/raven/raven_messaging/doctype/raven_message/raven_message.py @@ -723,7 +723,7 @@ def push_message_to_omni_channel_chat_provider(self) -> None: provider = OmniChannelRavenConnector.get_provider_from_channel(self.channel_id) connector = OmniChannelRavenConnector(provider=provider) - connector.push_to_provider(raven_message=self) + connector.handle_outbound(raven_message=self) def on_doctype_update(): From 80889c66f68f4f2acf9a3994b775f4f107773314 Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Wed, 22 Apr 2026 06:24:34 +0000 Subject: [PATCH 33/36] feat: support group chat --- .../provider/base_provider.py | 46 +++-- .../provider/facebook_provider.py | 83 +++++---- .../provider/line_provider.py | 138 +++++++------- raven/omni_channel_chat/models/message.py | 49 ++--- .../omni_channel_raven_connector.py | 169 +++++++++++------- .../doctype/raven_channel/raven_channel.json | 8 + 6 files changed, 280 insertions(+), 213 deletions(-) diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py index 8583ec81f..6d465906d 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py @@ -3,7 +3,13 @@ from werkzeug.wrappers import Response -from raven.omni_channel_chat.models.message import BaseMessage, FileContent, UserInfo +from raven.omni_channel_chat.models.message import ( + ChatDestination, + FileContent, + StdInboundEvent, + StdMessage, + UserDisplay, +) if TYPE_CHECKING: from raven.omni_channel_chat.doctype.omni_channel_chat_provider.omni_channel_chat_provider import ( @@ -21,45 +27,49 @@ def __init__(self, config: "OmniChannelChatProvider"): self.provider_config.decode_password_field() def handle_webhook( - self, body: bytes, headers: dict, callback: Callable[[BaseMessage], None] + self, + body: bytes, + headers: dict, + callback: Callable[[ChatDestination, str, StdMessage], None], ) -> Response: - messages = self.extract_messages(body=body, headers=headers) - for message in messages: - callback(message) + for e in self.extract_messages(body=body, headers=headers): + callback(e.destination, e.sender_id, e.message) return Response("ok", status=200, content_type="text/plain") @abstractmethod - def handle_frappe_api(self, callback: Callable[[BaseMessage], None]) -> Response: + def handle_frappe_api( + self, callback: Callable[[ChatDestination, str, StdMessage], None] + ) -> Response: """Extract data from frappe request and pass to `handle_webhook`.""" @abstractmethod - def get_user_info(self, user_id: str) -> UserInfo: - """Fetch user info from the provider's platform.""" + def get_user_info(self, user_id: str, destination: "ChatDestination") -> UserDisplay: + """Fetch user display info from the provider's platform.""" @abstractmethod def download_attachment(self, url: str, file_name: str | None = None) -> FileContent: """Download a file from the given URL and return its content and a file name.""" @abstractmethod - def show_typing(self, user_id: str) -> None: + def show_typing(self, destination_id: str) -> None: """Show a typing / loading indicator.""" @abstractmethod - def send_reply(self, message: BaseMessage) -> None: + def send_reply(self, destination_id: str, message: StdMessage) -> None: """Send a chat response back within the webhook reply context.""" @abstractmethod - def send_message(self, message: BaseMessage) -> None: - """Send an outbound message (push, not reply).""" + def send_message(self, destination_id: str, message: StdMessage) -> None: + """Send an outbound message (push).""" @abstractmethod - def event_mapper(self, event: ProviderWebhookEvent) -> BaseMessage | None: - """Map a provider-specific webhook event into a standardized message. Return None to skip.""" + def event_mapper(self, event: ProviderWebhookEvent) -> StdInboundEvent | None: + """Map a provider-specific webhook event into a standardized inbound event. Return None to skip.""" @abstractmethod - def standardize_events(self, events: list[ProviderWebhookEvent]) -> list[BaseMessage]: - """Standardize a list of provider-specific webhook events into BaseMessage instances.""" + def standardize_events(self, events: list[ProviderWebhookEvent]) -> list[StdInboundEvent]: + """Standardize a list of provider-specific webhook events into StdInboundMessage tuples.""" @abstractmethod - def extract_messages(self, body: bytes, headers: dict) -> list[BaseMessage]: - """Parse the raw webhook body into standardized messages.""" + def extract_messages(self, body: bytes, headers: dict) -> list[StdInboundEvent]: + """Parse the raw webhook body into standardized inbound events.""" diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py index 6e9c0b3b2..9a76f58ba 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py @@ -12,12 +12,14 @@ Provider, ) from raven.omni_channel_chat.models.message import ( - BaseMessage, + ChatDestination, FileContent, FileMessage, ImageMessage, + StdInboundEvent, + StdMessage, TextMessage, - UserInfo, + UserDisplay, ) # A "messaging event" dict from the Facebook webhook payload @@ -52,7 +54,9 @@ def verify_token(self) -> Response: else: frappe.throw("Verification failed", frappe.PermissionError) - def handle_frappe_api(self, callback: Callable[[BaseMessage], None]) -> Response: + def handle_frappe_api( + self, callback: Callable[[ChatDestination, str, StdMessage], None] + ) -> Response: request = frappe.local.request if request.method == "GET": @@ -70,7 +74,7 @@ def verify_signature(self, body: bytes, signature_header: str) -> bool: expected = hmac.new(self.config.app_secret.encode(), body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature_header.removeprefix("sha256=")) - def get_user_info(self, user_id: str) -> UserInfo: + def get_user_info(self, user_id: str, destination: "ChatDestination") -> UserDisplay: with httpx.Client() as client: response = client.get( f"{self.fb_api_url}/{user_id}", @@ -81,29 +85,31 @@ def get_user_info(self, user_id: str) -> UserInfo: ) response.raise_for_status() data = response.json() - return UserInfo( - user_id=user_id, - display_name=data.get("name"), - picture_url=data.get("picture", {}).get("data", {}).get("url"), + return UserDisplay( + name=data.get("name") or "", + icon_url=data.get("picture", {}).get("data", {}).get("url"), ) - def show_typing(self, user_id: str) -> None: + def show_typing(self, destination_id: str) -> None: with httpx.Client() as client: client.post( f"{self.fb_api_url}/me/messages", params={"access_token": self.config.page_access_token}, - json={"recipient": {"id": user_id}, "sender_action": "typing_on"}, + json={"recipient": {"id": destination_id}, "sender_action": "typing_on"}, ) - def send_reply(self, message: BaseMessage) -> None: - self.send_message(message) + def send_reply(self, destination_id: str, message: StdMessage) -> None: + self.send_message(destination_id, message) - def send_message(self, message: BaseMessage) -> None: + def send_message(self, destination_id: str, message: StdMessage) -> None: with httpx.Client() as client: client.post( f"{self.fb_api_url}/me/messages", params={"access_token": self.config.page_access_token}, - json={"recipient": {"id": message.user_id}, "message": message.to_provider()}, + json={ + "recipient": {"id": destination_id}, + "message": message.to_provider(provider_type=self.provider_config.provider), + }, ) def download_attachment(self, url: str, file_name: str | None = None) -> FileContent: @@ -116,22 +122,19 @@ def download_attachment(self, url: str, file_name: str | None = None) -> FileCon file_name = content_disposition.split("filename=")[-1].strip('" ') return FileContent(file_name=file_name, file_content=response.content) - def event_mapper(self, event: FacebookMessagingEvent) -> BaseMessage | None: + def event_mapper(self, event: FacebookMessagingEvent) -> StdInboundEvent | None: message = event.get("message") if message is None: return None - mid = message.get("mid") user_id = event["sender"]["id"] - metadata = {"mid": mid} + destination = ChatDestination(type="User", destination_id=user_id) if "text" in message: - return TextMessage( - provider=self.provider_config.provider, - provider_id=self.provider_config.name, - user_id=user_id, - metadata=metadata, - text=message["text"], + return StdInboundEvent( + destination=destination, + sender_id=user_id, + message=TextMessage(text=message["text"]), ) for attachment in message.get("attachments") or []: @@ -140,33 +143,29 @@ def event_mapper(self, event: FacebookMessagingEvent) -> BaseMessage | None: if not url: continue if att_type == "image": - return ImageMessage( - provider=self.provider_config.provider, - provider_id=self.provider_config.name, - user_id=user_id, - metadata=metadata, - file=self.download_attachment(url), + return StdInboundEvent( + destination=destination, + sender_id=user_id, + message=ImageMessage(file=self.download_attachment(url)), ) if att_type in ("file", "document"): - return FileMessage( - provider=self.provider_config.provider, - provider_id=self.provider_config.name, - user_id=user_id, - metadata=metadata, - file=self.download_attachment(url), + return StdInboundEvent( + destination=destination, + sender_id=user_id, + message=FileMessage(file=self.download_attachment(url)), ) return None - def standardize_events(self, events: list[FacebookMessagingEvent]) -> list[BaseMessage]: - std_events: list[BaseMessage] = [] + def standardize_events(self, events: list[FacebookMessagingEvent]) -> list[StdInboundEvent]: + result: list[StdInboundEvent] = [] for event in events: - std_event = self.event_mapper(event) - if std_event: - std_events.append(std_event) - return std_events + mapped = self.event_mapper(event) + if mapped: + result.append(mapped) + return result - def extract_messages(self, body: bytes, headers: dict) -> list[BaseMessage]: + def extract_messages(self, body: bytes, headers: dict) -> list[StdInboundEvent]: signature = headers.get("X-Hub-Signature-256", "") or headers.get( "x-hub-signature-256", "" ) diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py index d609986dd..403632bf4 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py @@ -8,21 +8,29 @@ MessagingApi, MessagingApiBlob, PushMessageRequest, - ReplyMessageRequest, ShowLoadingAnimationRequest, ) +from linebot.v3.messaging.exceptions import NotFoundException from linebot.v3.webhook import WebhookParser from linebot.v3.webhooks import Event as LineEvent -from linebot.v3.webhooks import FileMessageContent, ImageMessageContent, TextMessageContent +from linebot.v3.webhooks import ( + FileMessageContent, + GroupSource, + ImageMessageContent, + RoomSource, + TextMessageContent, +) from linebot.v3.webhooks import MessageEvent as LineMessageEvent from werkzeug.wrappers import Response from raven.omni_channel_chat.models.message import ( - BaseMessage, + ChatDestination, FileContent, FileMessage, ImageMessage, - UserInfo, + StdInboundEvent, + StdMessage, + UserDisplay, ) from raven.omni_channel_chat.models.message import ( TextMessage as StdTextMessage, @@ -45,48 +53,53 @@ def __init__(self, config): channel_secret=self.provider_config.line_channel_secret, ) - def handle_frappe_api(self, callback: Callable[[BaseMessage], None]) -> Response: + def handle_frappe_api( + self, callback: Callable[[ChatDestination, str, StdMessage], None] + ) -> Response: request = frappe.local.request body: bytes = request.get_data() headers: dict = dict(request.headers) return self.handle_webhook(body=body, headers=headers, callback=callback) - def get_user_info(self, user_id: str) -> UserInfo: + def get_user_info( + self, + user_id: str, + destination: "ChatDestination", + ) -> UserDisplay: with ApiClient(self.config) as api_client: - profile = MessagingApi(api_client).get_profile(user_id) - return UserInfo( - user_id=profile.user_id, - display_name=profile.display_name, - picture_url=profile.picture_url, - ) - - def show_typing(self, user_id: str) -> None: + api = MessagingApi(api_client) + if destination and destination.type == "Group": + try: + profile = api.get_group_member_profile(destination.destination_id, user_id) + return UserDisplay( + name=profile.display_name, + icon_url=profile.picture_url, + ) + except NotFoundException: + return UserDisplay(name=user_id, icon_url=None) + try: + profile = api.get_profile(user_id) + return UserDisplay( + name=profile.display_name, + icon_url=profile.picture_url, + ) + except NotFoundException: + return UserDisplay(name=user_id, icon_url=None) + + def show_typing(self, destination_id: str) -> None: with ApiClient(self.config) as api_client: MessagingApi(api_client).show_loading_animation( - ShowLoadingAnimationRequest(chatId=user_id, loadingSeconds=60) + ShowLoadingAnimationRequest(chatId=destination_id, loadingSeconds=60) ) - def send_reply(self, message: BaseMessage) -> None: - reply_token = (message.metadata or {}).get("reply_token") - line_msg = message.to_provider() - if reply_token: - try: - with ApiClient(self.config) as api_client: - MessagingApi(api_client).reply_message( - ReplyMessageRequest(reply_token=reply_token, messages=[line_msg]) - ) - return - except Exception: - pass - with ApiClient(self.config) as api_client: - MessagingApi(api_client).push_message( - PushMessageRequest(to=message.user_id, messages=[line_msg]) - ) + def send_reply(self, destination_id: str, message: StdMessage) -> None: + self.send_message(destination_id, message) - def send_message(self, message: BaseMessage) -> None: + def send_message(self, destination_id: str, message: StdMessage) -> None: + line_msg = message.to_provider(provider_type=self.provider_config.provider) with ApiClient(self.config) as api_client: MessagingApi(api_client).push_message( - PushMessageRequest(to=message.user_id, messages=[message.to_line()]) + PushMessageRequest(to=destination_id, messages=[line_msg]) ) def download_attachment(self, message_id: str, file_name: str | None = None) -> FileContent: @@ -94,52 +107,53 @@ def download_attachment(self, message_id: str, file_name: str | None = None) -> content = bytes(MessagingApiBlob(api_client).get_message_content(message_id)) return FileContent(file_name=file_name or "attachment", file_content=content) - def event_mapper(self, event: LineEvent) -> BaseMessage | None: + def event_mapper(self, event: LineEvent) -> StdInboundEvent | None: if not isinstance(event, LineMessageEvent): return None msg = event.message - metadata = {"message_id": msg.id, "reply_token": event.reply_token} - user_id = event.source.user_id + source = event.source + + if isinstance(source, GroupSource): + destination = ChatDestination(type="Group", destination_id=source.group_id) + elif isinstance(source, RoomSource): + destination = ChatDestination(type="Group", destination_id=source.room_id) + else: + destination = ChatDestination(type="User", destination_id=source.user_id) + + provider_user = source.user_id if isinstance(msg, TextMessageContent): - return StdTextMessage( - provider=self.provider_config.provider, - provider_id=self.provider_config.name, - user_id=user_id, - metadata=metadata, - text=msg.text, + return StdInboundEvent( + destination=destination, + sender_id=provider_user, + message=StdTextMessage(text=msg.text), ) if isinstance(msg, ImageMessageContent): - return ImageMessage( - provider=self.provider_config.provider, - provider_id=self.provider_config.name, - user_id=user_id, - metadata=metadata, - file=self.download_attachment(msg.id, f"{msg.id}.jpg"), + return StdInboundEvent( + destination=destination, + sender_id=provider_user, + message=ImageMessage(file=self.download_attachment(msg.id, f"{msg.id}.jpg")), ) - if isinstance(msg, FileMessageContent): - return FileMessage( - provider=self.provider_config.provider, - provider_id=self.provider_config.name, - user_id=user_id, - metadata=metadata, - file=self.download_attachment(msg.id, msg.file_name), + return StdInboundEvent( + destination=destination, + sender_id=provider_user, + message=FileMessage(file=self.download_attachment(msg.id, msg.file_name)), ) return None - def standardize_events(self, events: list[LineEvent]) -> list[BaseMessage]: - std_events: list[BaseMessage] = [] + def standardize_events(self, events: list[LineEvent]) -> list[StdInboundEvent]: + result: list[StdInboundEvent] = [] for event in events: - std_event = self.event_mapper(event) - if std_event: - std_events.append(std_event) - return std_events + mapped = self.event_mapper(event) + if mapped: + result.append(mapped) + return result - def extract_messages(self, body: bytes, headers: dict) -> list[BaseMessage]: + def extract_messages(self, body: bytes, headers: dict) -> list[StdInboundEvent]: signature = headers.get("x-line-signature", "") or headers.get("X-Line-Signature", "") try: events = self.parser.parse(body=body.decode(), signature=signature, as_payload=False) diff --git a/raven/omni_channel_chat/models/message.py b/raven/omni_channel_chat/models/message.py index 5e3a8d803..eec9efd49 100644 --- a/raven/omni_channel_chat/models/message.py +++ b/raven/omni_channel_chat/models/message.py @@ -1,6 +1,6 @@ from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any, Callable, Literal from linebot.v3.messaging import ( ImageMessage as LineImageMessage, @@ -22,30 +22,42 @@ MessageType = RavenMessage.message_type # --- -# BASE MESSAGE CLASSES +# IDENTITY / ROUTING TYPES # --- @dataclass(kw_only=True) -class UserInfo: +class ChatDestination: + type: Literal["Group", "User"] + destination_id: str + + +@dataclass(kw_only=True) +class RavenUserId: user_id: str - display_name: str - picture_url: str | None = None + user_type: Literal["Raven User", "Raven Bot"] @dataclass(kw_only=True) -class SenderInfo: +class UserDisplay: name: str icon_url: str | None = None +@dataclass(kw_only=True) +class StdInboundEvent: + destination: ChatDestination + sender_id: str + message: "StdMessage" + + # --- # PROVIDER MESSAGE MIXIN # --- class LineMessageMixin: - sender: "SenderInfo | None" + sender: "UserDisplay | None" def build_line_sender(self) -> LineSender | None: if self.sender is not None: @@ -54,15 +66,8 @@ def build_line_sender(self) -> LineSender | None: @dataclass(kw_only=True) -class BaseMessage(ABC, LineMessageMixin): - provider: "ProviderType" - - provider_id: str - user_id: str - - sender: SenderInfo | None = None - - metadata: dict | None = None +class StdMessage(ABC, LineMessageMixin): + sender: UserDisplay | None = None @property @abstractmethod @@ -74,11 +79,11 @@ def type(self) -> "MessageType": def provider_mapping(self) -> dict["ProviderType", Callable[[], dict]]: """Mapping of provider to a callable that converts the message to the provider format.""" - def to_provider(self) -> Any: - if self.provider not in self.provider_mapping: + def to_provider(self, provider_type: "ProviderType") -> Any: + if provider_type not in self.provider_mapping: raise NotImplementedError("Provider not implemented.") - return self.provider_mapping[self.provider]() + return self.provider_mapping[provider_type]() @dataclass(kw_only=True) @@ -101,7 +106,7 @@ class FileContent: @dataclass(kw_only=True) -class TextMessage(BaseMessage): +class TextMessage(StdMessage): text: str @property @@ -123,7 +128,7 @@ def provider_mapping(self): @dataclass(kw_only=True) -class FileMessage(BaseMessage): +class FileMessage(StdMessage): file: File @property @@ -158,7 +163,7 @@ def provider_mapping(self): @dataclass(kw_only=True) -class ImageMessage(BaseMessage): +class ImageMessage(StdMessage): file: File @property diff --git a/raven/omni_channel_chat/omni_channel_raven_connector.py b/raven/omni_channel_chat/omni_channel_raven_connector.py index 4003fc99b..68b0832cf 100644 --- a/raven/omni_channel_chat/omni_channel_raven_connector.py +++ b/raven/omni_channel_chat/omni_channel_raven_connector.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING import frappe from frappe import _ @@ -8,13 +8,15 @@ get_omni_channel_chat_provider, ) from raven.omni_channel_chat.models.message import ( - BaseMessage, + ChatDestination, FileContent, FileMessage, FileUrl, ImageMessage, - SenderInfo, + RavenUserId, + StdMessage, TextMessage, + UserDisplay, ) if TYPE_CHECKING: @@ -54,22 +56,22 @@ def get_provider_from_channel(channel_name: str) -> "Provider": def get_provider_pk(self, provider_id: str) -> str: return f"{self.provider_prefix}_{provider_id}" - def get_channel_name(self, raven_user: "RavenUser") -> str: - return f"{self.provider.provider_config.name}_{raven_user.name}" + def get_channel_name(self, destination_id: str) -> str: + return f"{self.provider.provider_config.name}_{destination_id}" - def get_sender_info(self, owner: str) -> "SenderInfo | None": - raven_user = cast( - "dict | None", - frappe.db.get_value("Raven User", owner, ["full_name", "user_image"], as_dict=True), + def get_sender_info(self, owner: str) -> "UserDisplay | None": + raven_user = frappe.db.get_value( + "Raven User", owner, ["full_name", "user_image"], as_dict=True ) if not raven_user: return None - avatar_url = cast("str | None", raven_user["user_image"]) - if avatar_url and avatar_url.startswith("/"): - avatar_url = get_url(avatar_url) - return SenderInfo(name=raven_user["full_name"] or "", icon_url=avatar_url or None) + icon_url = raven_user["user_image"] + if icon_url and icon_url.startswith("/"): + icon_url = get_url(icon_url) + return UserDisplay(name=raven_user["full_name"] or "", icon_url=icon_url or None) def get_user_id(self, channel_id: str) -> str: + """Return the provider user_id for a 1:1 customer channel via social login lookup.""" channel_user = frappe.db.get_value( doctype="Raven Channel", filters=channel_id, @@ -90,19 +92,23 @@ def get_user_id(self, channel_id: str) -> str: return user_id + def get_destination_id(self, channel_id: str) -> str | None: + """Return the provider destination_id (user_id or group_id) for outbound routing.""" + destination_id = frappe.db.get_value( + doctype="Raven Channel", + filters=channel_id, + fieldname="omni_channel_destination_id", + ) + return destination_id + def raven_to_std_msg( self, raven_message: "RavenMessage", - user_id: str, - sender: "SenderInfo | None", - ) -> BaseMessage: - provider_id = self.provider.provider_config.name + sender: "UserDisplay | None", + ) -> StdMessage: msg_type = raven_message.message_type if msg_type == "Text": return TextMessage( - provider=self.provider.provider_config.provider, - provider_id=provider_id, - user_id=user_id, text=raven_message.content or "", sender=sender, ) @@ -111,18 +117,12 @@ def raven_to_std_msg( file_url = get_url(file_url) if msg_type == "Image": return ImageMessage( - provider=self.provider.provider_config.provider, - provider_id=provider_id, - user_id=user_id, - file=FileUrl(url=file_url or ""), + file=FileUrl(url=file_url), sender=sender, ) if msg_type == "File": return FileMessage( - provider=self.provider.provider_config.provider, - provider_id=provider_id, - user_id=user_id, - file=FileUrl(url=file_url or ""), + file=FileUrl(url=file_url), sender=sender, ) @@ -179,16 +179,18 @@ def get_or_create_customer_user(self, user_id: str | None, provider_id: str) -> return self.create_customer_user(user_id=user_id, provider_id=provider_id) - def create_raven_user(self, user: "User", user_id: str) -> "RavenUser": - user_info = self.provider.get_user_info(user_id=user_id) + def create_raven_user( + self, user: "User", user_id: str, destination: "ChatDestination | None" = None + ) -> "RavenUser": + user_info = self.provider.get_user_info(user_id=user_id, destination=destination) raven_user = frappe.new_doc("Raven User") raven_user.update( { "type": "Customer", "user": user.name, - "full_name": user_info.display_name, - "user_image": user_info.picture_url, + "full_name": user_info.name, + "user_image": user_info.icon_url, "enabled": True, } ) @@ -196,7 +198,9 @@ def create_raven_user(self, user: "User", user_id: str) -> "RavenUser": return raven_user - def get_or_create_raven_user(self, user: "User", user_id: str) -> "RavenUser": + def get_or_create_raven_user( + self, user: "User", user_id: str, destination: "ChatDestination | None" = None + ) -> "RavenUser": raven_user_pk = frappe.db.get_value( doctype="Raven User", filters={"user": user.name}, @@ -206,10 +210,12 @@ def get_or_create_raven_user(self, user: "User", user_id: str) -> "RavenUser": if raven_user_pk: return frappe.get_doc("Raven User", raven_user_pk) - return self.create_raven_user(user=user, user_id=user_id) + return self.create_raven_user(user=user, user_id=user_id, destination=destination) - def create_channel(self, raven_user: "RavenUser") -> "RavenChannel": - channel_name = self.get_channel_name(raven_user) + def create_channel(self, destination: ChatDestination) -> "RavenChannel": + channel_name = self.get_channel_name( + destination_id=destination.destination_id, + ) channel = frappe.new_doc("Raven Channel") channel.update( @@ -217,8 +223,8 @@ def create_channel(self, raven_user: "RavenUser") -> "RavenChannel": "channel_name": channel_name, "id": channel_name, "type": "Public", - "customer_user": raven_user.user, "omni_channel_chat_provider": self.provider.provider_config.name, + "omni_channel_destination_id": destination.destination_id, "is_customer": True, "enabled": True, "workspace": self.provider.provider_config.raven_workspace, @@ -228,8 +234,10 @@ def create_channel(self, raven_user: "RavenUser") -> "RavenChannel": return channel - def get_or_create_channel(self, raven_user: "RavenUser") -> "RavenChannel": - channel_name = self.get_channel_name(raven_user) + def get_or_create_channel(self, destination: ChatDestination) -> "RavenChannel": + channel_name = self.get_channel_name( + destination_id=destination.destination_id, + ) channel_pk = frappe.db.get_value( doctype="Raven Channel", @@ -240,17 +248,25 @@ def get_or_create_channel(self, raven_user: "RavenUser") -> "RavenChannel": if channel_pk: return frappe.get_doc("Raven Channel", channel_pk) - return self.create_channel(raven_user) + return self.create_channel(destination=destination) + + def create_raven_message( + self, + raven_channel: "RavenChannel", + message: StdMessage, + sender_user: "User | None" = None, + provider_metadata: dict | None = None, + ) -> None: + owner = sender_user.name if sender_user else raven_channel.customer_user - def create_raven_message(self, raven_channel: "RavenChannel", message: BaseMessage) -> None: doc = frappe.new_doc(doctype="Raven Message") doc.update( { "channel_id": raven_channel.name, "message_type": message.type, "is_customer_message": True, - "owner": raven_channel.customer_user, - "omni_channel_msg_meta": message.metadata, + "owner": owner, + "omni_channel_msg_meta": provider_metadata, } ) @@ -272,46 +288,56 @@ def create_raven_message(self, raven_channel: "RavenChannel", message: BaseMessa doc.insert(ignore_permissions=True) - # ── provider → Raven (inbound) ───────────────────────────── + # ── Provider Message → Raven Message (inbound) ───────────────────────────── - def handle_inbound(self, message: BaseMessage) -> None: + def handle_inbound( + self, + destination: ChatDestination, + sender_id: str, + std_message: StdMessage, + ) -> None: """Inbound: turn a provider webhook payload into a Raven message. Creates the Frappe user, Raven user, and channel on first contact, then appends the message to the channel. - - Returns the Raven channel the message was posted to. """ - user_id = message.user_id - user = self.get_or_create_customer_user( - user_id=user_id, - provider_id=message.provider_id, + user_id=sender_id, + provider_id=self.provider.provider_config.name, ) - frappe.set_user(user.name) - - raven_user = self.get_or_create_raven_user( + self.get_or_create_raven_user( user=user, - user_id=user_id, + user_id=sender_id, + destination=destination, ) + + frappe.set_user(user.name) + raven_channel = self.get_or_create_channel( - raven_user=raven_user, + destination=destination, ) + + provider_metadata = { + "provider_user_id": sender_id, + "destination_id": destination.destination_id, + "destination_type": destination.type, + } + self.create_raven_message( raven_channel=raven_channel, - message=message, + message=std_message, + sender_user=user, + provider_metadata=provider_metadata, ) - # ── Raven → provider (outbound) ──────────────────────────── + # ── Raven Message → Provider Message (outbound) ──────────────────────────── def handle_outbound(self, raven_message: "RavenMessage") -> None: """Outbound: forward a staff Raven message to the customer on the external provider. - Handles guard conditions, channel/provider resolution, message payload building - (text + sender avatar), reply-context fetch (e.g. LINE reply token), and dispatch. - Does nothing if the message is from a customer, a bot, or is a system message, - or if the channel is not an omni-channel customer channel. + Skips messages that are from customers, bots, or system/poll types, + and channels that are not omni-channel customer channels. """ if ( raven_message.is_customer_message @@ -320,11 +346,16 @@ def handle_outbound(self, raven_message: "RavenMessage") -> None: ): return - user_id = self.get_user_id(channel_id=raven_message.channel_id) - sender = self.get_sender_info(owner=raven_message.owner) - outbound_msg = self.raven_to_std_msg( - raven_message=raven_message, - sender=sender, - user_id=user_id, + channel = frappe.db.get_value( + doctype="Raven Channel", + filters=raven_message.channel_id, + fieldname=["is_customer", "omni_channel_destination_id"], + as_dict=True, ) - self.provider.send_reply(message=outbound_msg) + if not channel or not channel.is_customer: + return + + destination_id = self.get_destination_id(raven_message.channel_id) + sender = self.get_sender_info(owner=raven_message.owner) + outbound_msg = self.raven_to_std_msg(raven_message=raven_message, sender=sender) + self.provider.send_message(destination_id=destination_id, message=outbound_msg) diff --git a/raven/raven_channel_management/doctype/raven_channel/raven_channel.json b/raven/raven_channel_management/doctype/raven_channel/raven_channel.json index 61aa491e9..da278f54d 100644 --- a/raven/raven_channel_management/doctype/raven_channel/raven_channel.json +++ b/raven/raven_channel_management/doctype/raven_channel/raven_channel.json @@ -30,6 +30,7 @@ "dm_user_2", "customer_user", "omni_channel_chat_provider", + "omni_channel_destination_id", "column_break_eckt", "last_message_details", "section_break_acpc", @@ -250,6 +251,13 @@ "fieldtype": "Link", "label": "Omni Channel Chat Provider", "options": "Omni Channel Chat Provider" + }, + { + "depends_on": "eval:doc.is_customer;", + "fieldname": "omni_channel_destination_id", + "fieldtype": "Data", + "label": "Omni Channel Destination ID", + "read_only": 1 } ], "index_web_pages_for_search": 1, From b74728b76733b2962c17d3e13e2307733d391964 Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Wed, 22 Apr 2026 07:44:07 +0000 Subject: [PATCH 34/36] feat: support line group --- .../omni_channel_raven_connector.py | 65 +++++++++++++++++-- .../doctype/raven_channel/raven_channel.json | 11 +--- .../doctype/raven_channel/raven_channel.py | 2 +- .../doctype/raven_message/raven_message.json | 9 ++- .../doctype/raven_message/raven_message.py | 2 +- 5 files changed, 69 insertions(+), 20 deletions(-) diff --git a/raven/omni_channel_chat/omni_channel_raven_connector.py b/raven/omni_channel_chat/omni_channel_raven_connector.py index 68b0832cf..d72050d76 100644 --- a/raven/omni_channel_chat/omni_channel_raven_connector.py +++ b/raven/omni_channel_chat/omni_channel_raven_connector.py @@ -59,16 +59,16 @@ def get_provider_pk(self, provider_id: str) -> str: def get_channel_name(self, destination_id: str) -> str: return f"{self.provider.provider_config.name}_{destination_id}" - def get_sender_info(self, owner: str) -> "UserDisplay | None": + def get_sender_info(self, sender: RavenUserId) -> "UserDisplay | None": raven_user = frappe.db.get_value( - "Raven User", owner, ["full_name", "user_image"], as_dict=True + "Raven User", sender.user_id, ["full_name", "user_image"], as_dict=True ) if not raven_user: return None icon_url = raven_user["user_image"] if icon_url and icon_url.startswith("/"): icon_url = get_url(icon_url) - return UserDisplay(name=raven_user["full_name"] or "", icon_url=icon_url or None) + return UserDisplay(name=raven_user["full_name"], icon_url=icon_url) def get_user_id(self, channel_id: str) -> str: """Return the provider user_id for a 1:1 customer channel via social login lookup.""" @@ -256,20 +256,34 @@ def create_raven_message( message: StdMessage, sender_user: "User | None" = None, provider_metadata: dict | None = None, + raven_user: "RavenUserId | None" = None, + raven_message_data: dict | None = None, ) -> None: - owner = sender_user.name if sender_user else raven_channel.customer_user - doc = frappe.new_doc(doctype="Raven Message") doc.update( { "channel_id": raven_channel.name, "message_type": message.type, "is_customer_message": True, - "owner": owner, "omni_channel_msg_meta": provider_metadata, } ) + if raven_user is not None: + if raven_user.user_type == "Raven User": + doc.update( + { + "owner": raven_user.user_id, + } + ) + elif raven_user.user_type == "Raven Bot": + doc.update( + { + "is_bot_message": True, + "bot": raven_user.user_id, + } + ) + if isinstance(message, TextMessage): doc.text = message.text elif isinstance(message, (ImageMessage, FileMessage)) and isinstance( @@ -286,6 +300,8 @@ def create_raven_message( file_doc.insert(ignore_permissions=True) doc.file = file_doc.file_url + doc.update(raven_message_data or {}) + doc.insert(ignore_permissions=True) # ── Provider Message → Raven Message (inbound) ───────────────────────────── @@ -343,6 +359,7 @@ def handle_outbound(self, raven_message: "RavenMessage") -> None: raven_message.is_customer_message or raven_message.is_bot_message or raven_message.message_type in ("System", "Poll") + or raven_message.omni_channel_skip_push_to_provider ): return @@ -356,6 +373,40 @@ def handle_outbound(self, raven_message: "RavenMessage") -> None: return destination_id = self.get_destination_id(raven_message.channel_id) - sender = self.get_sender_info(owner=raven_message.owner) + sender = self.get_sender_info( + owner=RavenUserId(user_type="Raven User", user_id=raven_message.owner) + ) outbound_msg = self.raven_to_std_msg(raven_message=raven_message, sender=sender) self.provider.send_message(destination_id=destination_id, message=outbound_msg) + + # ── Inject ───────────────────────────────────────────────────────────────── + + def handle_inject( + self, + destination: ChatDestination, + std_message: StdMessage, + raven_user: RavenUserId, + ) -> None: + """Handle programmatically injected messages, e.g. from a bot or workflow. + + Saves the message to the Raven channel. The after_insert hook on RavenMessage + automatically calls handle_outbound which pushes the message to the provider. + """ + raven_channel = self.get_or_create_channel( + destination=destination, + ) + + self.create_raven_message( + raven_channel=raven_channel, + message=std_message, + raven_user=raven_user, + raven_message_data={"omni_channel_skip_push_to_provider": True}, + ) + + sender_info = self.get_sender_info(sender=raven_user) + std_message.sender = sender_info + + self.provider.send_message( + destination_id=destination.destination_id, + message=std_message, + ) diff --git a/raven/raven_channel_management/doctype/raven_channel/raven_channel.json b/raven/raven_channel_management/doctype/raven_channel/raven_channel.json index da278f54d..1ef8dea26 100644 --- a/raven/raven_channel_management/doctype/raven_channel/raven_channel.json +++ b/raven/raven_channel_management/doctype/raven_channel/raven_channel.json @@ -28,7 +28,6 @@ "last_message_timestamp", "dm_user_1", "dm_user_2", - "customer_user", "omni_channel_chat_provider", "omni_channel_destination_id", "column_break_eckt", @@ -237,14 +236,6 @@ "label": "Is Customer", "set_only_once": 1 }, - { - "depends_on": "eval:doc.is_customer;", - "fieldname": "customer_user", - "fieldtype": "Link", - "label": "Customer", - "options": "Raven User", - "read_only": 1 - }, { "depends_on": "eval:doc.is_customer;", "fieldname": "omni_channel_chat_provider", @@ -271,7 +262,7 @@ "link_fieldname": "channel_id" } ], - "modified": "2026-04-19 14:41:55.728586", + "modified": "2026-04-22 13:32:23.357346", "modified_by": "Administrator", "module": "Raven Channel Management", "name": "Raven Channel", diff --git a/raven/raven_channel_management/doctype/raven_channel/raven_channel.py b/raven/raven_channel_management/doctype/raven_channel/raven_channel.py index a92072307..0e09083ba 100644 --- a/raven/raven_channel_management/doctype/raven_channel/raven_channel.py +++ b/raven/raven_channel_management/doctype/raven_channel/raven_channel.py @@ -20,7 +20,6 @@ class RavenChannel(Document): channel_description: DF.SmallText | None channel_name: DF.Data - customer_user: DF.Link | None dm_user_1: DF.Link | None dm_user_2: DF.Link | None is_ai_thread: DF.Check @@ -36,6 +35,7 @@ class RavenChannel(Document): linked_doctype: DF.Link | None linked_document: DF.DynamicLink | None omni_channel_chat_provider: DF.Link | None + omni_channel_destination_id: DF.Data | None openai_thread_id: DF.Data | None pinned_messages: DF.Table[RavenPinnedMessages] pinned_messages_string: DF.SmallText | None diff --git a/raven/raven_messaging/doctype/raven_message/raven_message.json b/raven/raven_messaging/doctype/raven_message/raven_message.json index b5536c5d5..69a24d5b4 100644 --- a/raven/raven_messaging/doctype/raven_message/raven_message.json +++ b/raven/raven_messaging/doctype/raven_message/raven_message.json @@ -38,6 +38,7 @@ "hide_link_preview", "notification", "is_customer_message", + "omni_channel_skip_push_to_provider", "omni_channel_msg_meta" ], "fields": [ @@ -221,11 +222,17 @@ "fieldname": "omni_channel_msg_meta", "fieldtype": "JSON", "label": "Omni-Channel Message Meta" + }, + { + "default": "0", + "fieldname": "omni_channel_skip_push_to_provider", + "fieldtype": "Check", + "label": "Omni-channel Skip Push to Provider" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2026-04-19 16:05:30.311091", + "modified": "2026-04-22 13:42:16.280165", "modified_by": "Administrator", "module": "Raven Messaging", "name": "Raven Message", diff --git a/raven/raven_messaging/doctype/raven_message/raven_message.py b/raven/raven_messaging/doctype/raven_message/raven_message.py index c92a10dff..1dd5ae7e1 100644 --- a/raven/raven_messaging/doctype/raven_message/raven_message.py +++ b/raven/raven_messaging/doctype/raven_message/raven_message.py @@ -34,7 +34,6 @@ class RavenMessage(Document): if TYPE_CHECKING: from frappe.types import DF - from raven.raven_messaging.doctype.raven_mention.raven_mention import RavenMention blurhash: DF.SmallText | None @@ -62,6 +61,7 @@ class RavenMessage(Document): message_type: DF.Literal["Text", "Image", "File", "Poll", "System"] notification: DF.Data | None omni_channel_msg_meta: DF.JSON | None + omni_channel_skip_push_to_provider: DF.Check poll_id: DF.Link | None replied_message_details: DF.JSON | None text: DF.LongText | None From 5d585609e37fc9a28a8bc8274f00b300460d134b Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Wed, 22 Apr 2026 09:53:51 +0000 Subject: [PATCH 35/36] fix: ui logic --- .../src/components/feature/chat-header/ChannelHeader.tsx | 6 +++--- .../components/layout/Sidebar/OmniChannelSidebarBody.tsx | 2 +- .../src/types/RavenChannelManagement/RavenChannel.ts | 4 ++-- frontend/src/utils/channel/ChannelListProvider.tsx | 2 +- raven/api/raven_channel.py | 2 +- .../omni_channel_chat_provider/provider/base_provider.py | 4 ++++ .../provider/facebook_provider.py | 3 +++ .../omni_channel_chat_provider/provider/line_provider.py | 7 +++++++ raven/omni_channel_chat/omni_channel_raven_connector.py | 7 +++++++ .../doctype/raven_channel/raven_channel.json | 9 ++++++++- .../doctype/raven_channel/raven_channel.py | 1 + 11 files changed, 38 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/feature/chat-header/ChannelHeader.tsx b/frontend/src/components/feature/chat-header/ChannelHeader.tsx index bdcbc9947..5117c4ac9 100644 --- a/frontend/src/components/feature/chat-header/ChannelHeader.tsx +++ b/frontend/src/components/feature/chat-header/ChannelHeader.tsx @@ -30,11 +30,11 @@ export const ChannelHeader = ({ channelData }: ChannelHeaderProps) => { const { users } = useContext(UserListContext) const customerUser = useMemo( - () => users.find(u => u.name === channelData.customer_user), - [users, channelData.customer_user] + () => users.find(u => u.name === channelData.omni_channel_raven_user), + [users, channelData.omni_channel_raven_user] ) - const isOmniChannel = !!channelData.customer_user + const isOmniChannel = !!channelData.omni_channel_raven_user const displayName = customerUser?.full_name ?? channelData.channel_name const providerIcon = channelData.omni_channel_provider ? PROVIDER_ICONS[channelData.omni_channel_provider] : null const providerDisplayName = channelData.omni_channel_display_name ?? channelData.omni_channel_chat_provider diff --git a/frontend/src/components/layout/Sidebar/OmniChannelSidebarBody.tsx b/frontend/src/components/layout/Sidebar/OmniChannelSidebarBody.tsx index 8a949c7eb..1aa674000 100644 --- a/frontend/src/components/layout/Sidebar/OmniChannelSidebarBody.tsx +++ b/frontend/src/components/layout/Sidebar/OmniChannelSidebarBody.tsx @@ -134,7 +134,7 @@ const OmniChannelItemRow = ({ channel, showProvider }: { channel: OmniChannelIte const { users } = useContext(UserListContext) const { channelID } = useParams() - const customerUser = useMemo(() => users.find(u => u.name === channel.customer_user), [users, channel.customer_user]) + const customerUser = useMemo(() => users.find(u => u.name === channel.omni_channel_raven_user), [users, channel.omni_channel_raven_user]) const displayName = customerUser?.full_name ?? channel.channel_name const showUnread = channel.unread_count > 0 && channelID !== channel.name diff --git a/frontend/src/types/RavenChannelManagement/RavenChannel.ts b/frontend/src/types/RavenChannelManagement/RavenChannel.ts index df340f73c..e2a14659d 100644 --- a/frontend/src/types/RavenChannelManagement/RavenChannel.ts +++ b/frontend/src/types/RavenChannelManagement/RavenChannel.ts @@ -51,8 +51,8 @@ export interface RavenChannel{ thread_bot?: string /** Is Customer : Check */ is_customer?: 0 | 1 - /** Customer : Link - Raven User */ - customer_user?: string /** Omni Channel Chat Provider : Link - Omni Channel Chat Provider */ omni_channel_chat_provider?: string + /** Raven User for Omni Channel : Link - Raven User */ + omni_channel_raven_user?: string } \ No newline at end of file diff --git a/frontend/src/utils/channel/ChannelListProvider.tsx b/frontend/src/utils/channel/ChannelListProvider.tsx index 7ab18529e..68c392bf9 100644 --- a/frontend/src/utils/channel/ChannelListProvider.tsx +++ b/frontend/src/utils/channel/ChannelListProvider.tsx @@ -14,7 +14,7 @@ export type UnreadCountData = UnreadChannelCountItem[] export type ChannelListItem = Pick & { member_id: string, omni_channel_display_name?: string, omni_channel_provider?: string } + 'omni_channel_chat_provider' | 'omni_channel_raven_user'> & { member_id: string, omni_channel_display_name?: string, omni_channel_provider?: string } export interface DMChannelListItem extends ChannelListItem { peer_user_id: string, diff --git a/raven/api/raven_channel.py b/raven/api/raven_channel.py index 07cbd8723..1d50f75ef 100644 --- a/raven/api/raven_channel.py +++ b/raven/api/raven_channel.py @@ -64,8 +64,8 @@ def get_channel_list(hide_archived: bool = False): channel.last_message_details, channel.pinned_messages_string, channel.workspace, - channel.customer_user, channel.omni_channel_chat_provider, + channel.omni_channel_raven_user, omni_provider.display_name.as_("omni_channel_display_name"), omni_provider.provider.as_("omni_channel_provider"), channel_member.name.as_("member_id"), diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py index 6d465906d..1dc65b9b1 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py @@ -46,6 +46,10 @@ def handle_frappe_api( def get_user_info(self, user_id: str, destination: "ChatDestination") -> UserDisplay: """Fetch user display info from the provider's platform.""" + @abstractmethod + def get_destination_display_name(self, destination: "ChatDestination") -> UserDisplay: + """Return a human-readable name for the destination (e.g. group name). None if unsupported.""" + @abstractmethod def download_attachment(self, url: str, file_name: str | None = None) -> FileContent: """Download a file from the given URL and return its content and a file name.""" diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py index 9a76f58ba..f5d9b2c4c 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py @@ -74,6 +74,9 @@ def verify_signature(self, body: bytes, signature_header: str) -> bool: expected = hmac.new(self.config.app_secret.encode(), body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature_header.removeprefix("sha256=")) + def get_destination_display_name(self, destination: ChatDestination) -> UserDisplay: + return self.get_user_info(destination.destination_id, destination) + def get_user_info(self, user_id: str, destination: "ChatDestination") -> UserDisplay: with httpx.Client() as client: response = client.get( diff --git a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py index 403632bf4..38a5d8cae 100644 --- a/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py @@ -61,6 +61,13 @@ def handle_frappe_api( headers: dict = dict(request.headers) return self.handle_webhook(body=body, headers=headers, callback=callback) + def get_destination_display_name(self, destination: "ChatDestination") -> UserDisplay: + if destination.type != "Group": + return self.get_user_info(destination.destination_id, destination) + with ApiClient(self.config) as api_client: + summary = MessagingApi(api_client).get_group_summary(destination.destination_id) + return UserDisplay(name=summary.group_name, icon_url=None) + def get_user_info( self, user_id: str, diff --git a/raven/omni_channel_chat/omni_channel_raven_connector.py b/raven/omni_channel_chat/omni_channel_raven_connector.py index d72050d76..118b77a87 100644 --- a/raven/omni_channel_chat/omni_channel_raven_connector.py +++ b/raven/omni_channel_chat/omni_channel_raven_connector.py @@ -232,6 +232,13 @@ def create_channel(self, destination: ChatDestination) -> "RavenChannel": ) channel.insert(ignore_permissions=True) + channel_display = self.provider.get_destination_display_name(destination=destination) + channel.update( + { + "channel_name": channel_display.name, + } + ) + return channel def get_or_create_channel(self, destination: ChatDestination) -> "RavenChannel": diff --git a/raven/raven_channel_management/doctype/raven_channel/raven_channel.json b/raven/raven_channel_management/doctype/raven_channel/raven_channel.json index 1ef8dea26..34331274d 100644 --- a/raven/raven_channel_management/doctype/raven_channel/raven_channel.json +++ b/raven/raven_channel_management/doctype/raven_channel/raven_channel.json @@ -30,6 +30,7 @@ "dm_user_2", "omni_channel_chat_provider", "omni_channel_destination_id", + "omni_channel_raven_user", "column_break_eckt", "last_message_details", "section_break_acpc", @@ -249,6 +250,12 @@ "fieldtype": "Data", "label": "Omni Channel Destination ID", "read_only": 1 + }, + { + "fieldname": "omni_channel_raven_user", + "fieldtype": "Link", + "label": "Omni Channel Raven User", + "options": "Raven User" } ], "index_web_pages_for_search": 1, @@ -262,7 +269,7 @@ "link_fieldname": "channel_id" } ], - "modified": "2026-04-22 13:32:23.357346", + "modified": "2026-04-22 16:37:37.130387", "modified_by": "Administrator", "module": "Raven Channel Management", "name": "Raven Channel", diff --git a/raven/raven_channel_management/doctype/raven_channel/raven_channel.py b/raven/raven_channel_management/doctype/raven_channel/raven_channel.py index 0e09083ba..fc4a845e8 100644 --- a/raven/raven_channel_management/doctype/raven_channel/raven_channel.py +++ b/raven/raven_channel_management/doctype/raven_channel/raven_channel.py @@ -36,6 +36,7 @@ class RavenChannel(Document): linked_document: DF.DynamicLink | None omni_channel_chat_provider: DF.Link | None omni_channel_destination_id: DF.Data | None + omni_channel_raven_user: DF.Link | None openai_thread_id: DF.Data | None pinned_messages: DF.Table[RavenPinnedMessages] pinned_messages_string: DF.SmallText | None From aa41c8835704798998d21eaa03f40a6f85166637 Mon Sep 17 00:00:00 2001 From: Poranut Chollavorn Date: Wed, 22 Apr 2026 13:36:23 +0000 Subject: [PATCH 36/36] fix: channel creation method --- .../omni_channel_raven_connector.py | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/raven/omni_channel_chat/omni_channel_raven_connector.py b/raven/omni_channel_chat/omni_channel_raven_connector.py index 118b77a87..ac54197b7 100644 --- a/raven/omni_channel_chat/omni_channel_raven_connector.py +++ b/raven/omni_channel_chat/omni_channel_raven_connector.py @@ -212,11 +212,17 @@ def get_or_create_raven_user( return self.create_raven_user(user=user, user_id=user_id, destination=destination) - def create_channel(self, destination: ChatDestination) -> "RavenChannel": + def create_channel( + self, destination: ChatDestination, raven_user: "RavenUser | None" = None + ) -> "RavenChannel": channel_name = self.get_channel_name( destination_id=destination.destination_id, ) + omni_channel_raven_user = None + if raven_user and destination.type == "User": + omni_channel_raven_user = raven_user.name + channel = frappe.new_doc("Raven Channel") channel.update( { @@ -225,6 +231,7 @@ def create_channel(self, destination: ChatDestination) -> "RavenChannel": "type": "Public", "omni_channel_chat_provider": self.provider.provider_config.name, "omni_channel_destination_id": destination.destination_id, + "omni_channel_raven_user": omni_channel_raven_user, "is_customer": True, "enabled": True, "workspace": self.provider.provider_config.raven_workspace, @@ -238,10 +245,13 @@ def create_channel(self, destination: ChatDestination) -> "RavenChannel": "channel_name": channel_display.name, } ) + channel.save(ignore_permissions=True) return channel - def get_or_create_channel(self, destination: ChatDestination) -> "RavenChannel": + def get_or_create_channel( + self, destination: ChatDestination, raven_user: "RavenUser | None" = None + ) -> "RavenChannel": channel_name = self.get_channel_name( destination_id=destination.destination_id, ) @@ -255,7 +265,7 @@ def get_or_create_channel(self, destination: ChatDestination) -> "RavenChannel": if channel_pk: return frappe.get_doc("Raven Channel", channel_pk) - return self.create_channel(destination=destination) + return self.create_channel(destination=destination, raven_user=raven_user) def create_raven_message( self, @@ -329,7 +339,7 @@ def handle_inbound( provider_id=self.provider.provider_config.name, ) - self.get_or_create_raven_user( + raven_user = self.get_or_create_raven_user( user=user, user_id=sender_id, destination=destination, @@ -339,6 +349,7 @@ def handle_inbound( raven_channel = self.get_or_create_channel( destination=destination, + raven_user=raven_user, ) provider_metadata = { @@ -381,7 +392,7 @@ def handle_outbound(self, raven_message: "RavenMessage") -> None: destination_id = self.get_destination_id(raven_message.channel_id) sender = self.get_sender_info( - owner=RavenUserId(user_type="Raven User", user_id=raven_message.owner) + sender=RavenUserId(user_type="Raven User", user_id=raven_message.owner) ) outbound_msg = self.raven_to_std_msg(raven_message=raven_message, sender=sender) self.provider.send_message(destination_id=destination_id, message=outbound_msg)