diff --git a/frontend/src/components/feature/chat-header/ChannelHeader.tsx b/frontend/src/components/feature/chat-header/ChannelHeader.tsx index acf01669f..5117c4ac9 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.omni_channel_raven_user), + [users, channelData.omni_channel_raven_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 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..1aa674000 --- /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.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 + + 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..e2a14659d 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 + /** 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 4ad03da28..68c392bf9 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' | + '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 d3c9e8847..1d50f75ef 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.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"), ) .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"), ) 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/README.md b/raven/omni_channel_chat/README.md new file mode 100644 index 000000000..d996f6e16 --- /dev/null +++ b/raven/omni_channel_chat/README.md @@ -0,0 +1,43 @@ +## 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 + +### 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/__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..8e08e4a15 --- /dev/null +++ b/raven/omni_channel_chat/api/webhooks.py @@ -0,0 +1,38 @@ +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_provider, +) +from raven.omni_channel_chat.omni_channel_raven_connector import OmniChannelRavenConnector + + +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. + 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.handle/g9ju6k0e8r`, + this returns `g9ju6k0e8r`. + """ + request = frappe.local.request + return request.path.rstrip("/").rsplit("/", 1)[-1] + + +@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() + provider = get_omni_channel_chat_provider(slug=slug) + connector = OmniChannelRavenConnector(provider=provider) + + return provider.handle_frappe_api(callback=connector.handle_inbound) 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..b037e85c3 --- /dev/null +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.js @@ -0,0 +1,6 @@ +// 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..3b68ad71b --- /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": "line\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-21 10:26:22.036135", + "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..a74d9cae5 --- /dev/null +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.py @@ -0,0 +1,57 @@ +# 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_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/__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..1dc65b9b1 --- /dev/null +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/base_provider.py @@ -0,0 +1,79 @@ +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Callable, Generic, TypeVar + +from werkzeug.wrappers import Response + +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 ( + OmniChannelChatProvider, + ) + +ProviderWebhookEvent = TypeVar("ProviderWebhookEvent") + + +class Provider(ABC, Generic[ProviderWebhookEvent]): + provider_config: "OmniChannelChatProvider" + + def __init__(self, config: "OmniChannelChatProvider"): + self.provider_config = config + self.provider_config.decode_password_field() + + def handle_webhook( + self, + body: bytes, + headers: dict, + callback: Callable[[ChatDestination, str, StdMessage], None], + ) -> Response: + 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[[ChatDestination, str, StdMessage], None] + ) -> Response: + """Extract data from frappe request and pass to `handle_webhook`.""" + + @abstractmethod + 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.""" + + @abstractmethod + def show_typing(self, destination_id: str) -> None: + """Show a typing / loading indicator.""" + + @abstractmethod + 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, destination_id: str, message: StdMessage) -> None: + """Send an outbound message (push).""" + + @abstractmethod + 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[StdInboundEvent]: + """Standardize a list of provider-specific webhook events into StdInboundMessage tuples.""" + + @abstractmethod + 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 new file mode 100644 index 000000000..f5d9b2c4c --- /dev/null +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/facebook_provider.py @@ -0,0 +1,187 @@ +import hashlib +import hmac +import json +from dataclasses import dataclass +from typing import Any, Callable + +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 ( + ChatDestination, + FileContent, + FileMessage, + ImageMessage, + StdInboundEvent, + StdMessage, + TextMessage, + UserDisplay, +) + +# 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/v25.0" + + def __init__(self, config): + super().__init__(config=config) + 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, callback: Callable[[ChatDestination, str, StdMessage], None] + ) -> Response: + request = frappe.local.request + + if request.method == "GET": + 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, callback=callback) + 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.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( + f"{self.fb_api_url}/{user_id}", + params={ + "fields": "name,picture", + "access_token": self.config.page_access_token, + }, + ) + response.raise_for_status() + data = response.json() + return UserDisplay( + name=data.get("name") or "", + icon_url=data.get("picture", {}).get("data", {}).get("url"), + ) + + 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": destination_id}, "sender_action": "typing_on"}, + ) + + def send_reply(self, destination_id: str, message: StdMessage) -> None: + self.send_message(destination_id, message) + + 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": destination_id}, + "message": message.to_provider(provider_type=self.provider_config.provider), + }, + ) + + 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 = file_name or "attachment" + if "filename=" in content_disposition: + file_name = content_disposition.split("filename=")[-1].strip('" ') + return FileContent(file_name=file_name, file_content=response.content) + + def event_mapper(self, event: FacebookMessagingEvent) -> StdInboundEvent | None: + message = event.get("message") + if message is None: + return None + + user_id = event["sender"]["id"] + destination = ChatDestination(type="User", destination_id=user_id) + + if "text" in message: + return StdInboundEvent( + destination=destination, + sender_id=user_id, + message=TextMessage(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": + return StdInboundEvent( + destination=destination, + sender_id=user_id, + message=ImageMessage(file=self.download_attachment(url)), + ) + if att_type in ("file", "document"): + 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[StdInboundEvent]: + result: list[StdInboundEvent] = [] + for event in events: + mapped = self.event_mapper(event) + if mapped: + result.append(mapped) + return result + + def extract_messages(self, body: bytes, headers: dict) -> list[StdInboundEvent]: + 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 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..38a5d8cae --- /dev/null +++ b/raven/omni_channel_chat/doctype/omni_channel_chat_provider/provider/line_provider.py @@ -0,0 +1,169 @@ +from typing import Callable + +import frappe +from linebot.v3.exceptions import InvalidSignatureError +from linebot.v3.messaging import ( + ApiClient, + Configuration, + MessagingApi, + MessagingApiBlob, + PushMessageRequest, + 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, + 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 ( + ChatDestination, + FileContent, + FileMessage, + ImageMessage, + StdInboundEvent, + StdMessage, + UserDisplay, +) +from raven.omni_channel_chat.models.message import ( + TextMessage as StdTextMessage, +) + +from . import Provider + + +class LineProvider(Provider[LineEvent]): + 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, + ) + + 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_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, + destination: "ChatDestination", + ) -> UserDisplay: + with ApiClient(self.config) as api_client: + 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=destination_id, loadingSeconds=60) + ) + + def send_reply(self, destination_id: str, message: StdMessage) -> None: + self.send_message(destination_id, message) + + 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=destination_id, messages=[line_msg]) + ) + + def download_attachment(self, message_id: str, file_name: str | None = None) -> FileContent: + with ApiClient(self.config) as api_client: + 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) -> StdInboundEvent | None: + if not isinstance(event, LineMessageEvent): + return None + + msg = event.message + 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 StdInboundEvent( + destination=destination, + sender_id=provider_user, + message=StdTextMessage(text=msg.text), + ) + + if isinstance(msg, ImageMessageContent): + 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 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[StdInboundEvent]: + result: list[StdInboundEvent] = [] + for event in events: + mapped = self.event_mapper(event) + if mapped: + result.append(mapped) + return result + + 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) + except InvalidSignatureError: + frappe.throw("Invalid LINE signature", frappe.PermissionError) + return 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.py b/raven/omni_channel_chat/models/message.py new file mode 100644 index 000000000..eec9efd49 --- /dev/null +++ b/raven/omni_channel_chat/models/message.py @@ -0,0 +1,197 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Callable, Literal + +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 ( + OmniChannelChatProvider, + ) + from raven.raven_messaging.doctype.raven_message.raven_message import RavenMessage + + ProviderType = OmniChannelChatProvider.provider + MessageType = RavenMessage.message_type + +# --- +# IDENTITY / ROUTING TYPES +# --- + + +@dataclass(kw_only=True) +class ChatDestination: + type: Literal["Group", "User"] + destination_id: str + + +@dataclass(kw_only=True) +class RavenUserId: + user_id: str + user_type: Literal["Raven User", "Raven Bot"] + + +@dataclass(kw_only=True) +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: "UserDisplay | 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 StdMessage(ABC, LineMessageMixin): + sender: UserDisplay | 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[[], dict]]: + """Mapping of provider to a callable that converts the message to the provider format.""" + + 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[provider_type]() + + +@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 +# --- + + +@dataclass(kw_only=True) +class TextMessage(StdMessage): + text: str + + @property + def type(self): + return "Text" + + def to_line(self): + return LineTextMessage(text=self.text, sender=self.build_line_sender()) + + def to_facebook(self): + return {"text": self.text} + + @property + def provider_mapping(self): + return { + "line": self.to_line, + "facebook": self.to_facebook, + } + + +@dataclass(kw_only=True) +class FileMessage(StdMessage): + file: File + + @property + def type(self): + return "File" + + def to_line(self): + if isinstance(self.file, FileUrl): + 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, "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, + } + + +@dataclass(kw_only=True) +class ImageMessage(StdMessage): + 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 new file mode 100644 index 000000000..ac54197b7 --- /dev/null +++ b/raven/omni_channel_chat/omni_channel_raven_connector.py @@ -0,0 +1,430 @@ +from typing import TYPE_CHECKING + +import frappe +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 ( + ChatDestination, + FileContent, + FileMessage, + FileUrl, + ImageMessage, + RavenUserId, + StdMessage, + TextMessage, + UserDisplay, +) + +if TYPE_CHECKING: + from frappe.core.doctype.user.user import User + + 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, + ) + from raven.raven_messaging.doctype.raven_message.raven_message import RavenMessage + + +class OmniChannelRavenConnector: + provider_prefix = "occ" + + def __init__(self, provider: "Provider"): + self.provider = provider + + @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_provider_pk(self, provider_id: str) -> str: + return f"{self.provider_prefix}_{provider_id}" + + def get_channel_name(self, destination_id: str) -> str: + return f"{self.provider.provider_config.name}_{destination_id}" + + def get_sender_info(self, sender: RavenUserId) -> "UserDisplay | None": + raven_user = frappe.db.get_value( + "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"], 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.""" + 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": self.get_provider_pk(self.provider.provider_config.name), + "parent": channel_user, + }, + fieldname="userid", + ) + + if not user_id: + frappe.throw(_("User ID not found for channel {0}").format(channel_id)) + + 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", + sender: "UserDisplay | None", + ) -> StdMessage: + msg_type = raven_message.message_type + if msg_type == "Text": + return TextMessage( + 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( + file=FileUrl(url=file_url), + sender=sender, + ) + if msg_type == "File": + return FileMessage( + file=FileUrl(url=file_url), + 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) + hostname = self.get_hostname_without_port() + + email = f"{username}@{self.provider_prefix}.{hostname}" + + 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_pk, + "userid": user_id, + "parent": user.name, + "parenttype": "User", + "parentfield": "social_logins", + } + ) + user_social_login.insert(ignore_permissions=True) + + return user + + 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 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, 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.name, + "user_image": user_info.icon_url, + "enabled": True, + } + ) + raven_user.insert(ignore_permissions=True) + + return raven_user + + 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}, + fieldname="name", + ) + + if raven_user_pk: + return frappe.get_doc("Raven User", raven_user_pk) + + return self.create_raven_user(user=user, user_id=user_id, destination=destination) + + 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( + { + "channel_name": channel_name, + "id": channel_name, + "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, + } + ) + channel.insert(ignore_permissions=True) + + channel_display = self.provider.get_destination_display_name(destination=destination) + channel.update( + { + "channel_name": channel_display.name, + } + ) + channel.save(ignore_permissions=True) + + return channel + + 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, + ) + + channel_pk = frappe.db.get_value( + doctype="Raven Channel", + filters=channel_name, + fieldname="name", + ) + + if channel_pk: + return frappe.get_doc("Raven Channel", channel_pk) + + return self.create_channel(destination=destination, raven_user=raven_user) + + def create_raven_message( + self, + raven_channel: "RavenChannel", + message: StdMessage, + sender_user: "User | None" = None, + provider_metadata: dict | None = None, + raven_user: "RavenUserId | None" = None, + raven_message_data: dict | None = None, + ) -> None: + doc = frappe.new_doc(doctype="Raven Message") + doc.update( + { + "channel_id": raven_channel.name, + "message_type": message.type, + "is_customer_message": True, + "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( + message.file, FileContent + ): + file_doc = frappe.get_doc( + { + "doctype": "File", + "file_name": message.file.file_name, + "content": message.file.file_content, + "is_private": 0, + } + ) + 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) ───────────────────────────── + + 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. + """ + user = self.get_or_create_customer_user( + user_id=sender_id, + provider_id=self.provider.provider_config.name, + ) + + raven_user = self.get_or_create_raven_user( + user=user, + user_id=sender_id, + destination=destination, + ) + + frappe.set_user(user.name) + + raven_channel = self.get_or_create_channel( + destination=destination, + raven_user=raven_user, + ) + + 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=std_message, + sender_user=user, + provider_metadata=provider_metadata, + ) + + # ── 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. + + 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 + or raven_message.is_bot_message + or raven_message.message_type in ("System", "Poll") + or raven_message.omni_channel_skip_push_to_provider + ): + return + + channel = frappe.db.get_value( + doctype="Raven Channel", + filters=raven_message.channel_id, + fieldname=["is_customer", "omni_channel_destination_id"], + as_dict=True, + ) + if not channel or not channel.is_customer: + return + + destination_id = self.get_destination_id(raven_message.channel_id) + sender = self.get_sender_info( + 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) + + # ── 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/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/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"] 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..34331274d 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,16 @@ "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", + "omni_channel_chat_provider", + "omni_channel_destination_id", + "omni_channel_raven_user", "column_break_eckt", "last_message_details", "section_break_acpc", @@ -223,6 +227,35 @@ "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": "omni_channel_chat_provider", + "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 + }, + { + "fieldname": "omni_channel_raven_user", + "fieldtype": "Link", + "label": "Omni Channel Raven User", + "options": "Raven User" } ], "index_web_pages_for_search": 1, @@ -236,7 +269,7 @@ "link_fieldname": "channel_id" } ], - "modified": "2026-01-08 12:58:41.748067", + "modified": "2026-04-22 16:37:37.130387", "modified_by": "Administrator", "module": "Raven Channel Management", "name": "Raven Channel", @@ -268,10 +301,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..fc4a845e8 100644 --- a/raven/raven_channel_management/doctype/raven_channel/raven_channel.py +++ b/raven/raven_channel_management/doctype/raven_channel/raven_channel.py @@ -16,7 +16,6 @@ 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 @@ -25,6 +24,7 @@ class RavenChannel(Document): 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 @@ -34,6 +34,9 @@ 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 + 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 @@ -249,7 +252,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.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(" ", "-") 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..69a24d5b4 100644 --- a/raven/raven_messaging/doctype/raven_message/raven_message.json +++ b/raven/raven_messaging/doctype/raven_message/raven_message.json @@ -36,7 +36,10 @@ "is_bot_message", "bot", "hide_link_preview", - "notification" + "notification", + "is_customer_message", + "omni_channel_skip_push_to_provider", + "omni_channel_msg_meta" ], "fields": [ { @@ -208,11 +211,28 @@ "fieldname": "links", "fieldtype": "Small Text", "label": "Links" + }, + { + "default": "0", + "fieldname": "is_customer_message", + "fieldtype": "Check", + "label": "Is Customer Message" + }, + { + "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-01-31 13:55:57.988161", + "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 0403b2814..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 @@ -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 @@ -60,6 +60,8 @@ 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 + omni_channel_skip_push_to_provider: DF.Check poll_id: DF.Link | None replied_message_details: DF.JSON | None text: DF.LongText | None @@ -163,7 +165,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 +176,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 +217,10 @@ def after_insert(self): self.handle_ai_message() self.send_push_notification() + # 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): @@ -307,13 +316,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 +396,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 +516,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 +562,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() @@ -673,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 @@ -685,7 +704,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 +718,13 @@ def on_trash(self): channel_doc.remove(pinned_row) channel_doc.save() + def push_message_to_omni_channel_chat_provider(self) -> None: + from raven.omni_channel_chat.omni_channel_raven_connector import OmniChannelRavenConnector + + provider = OmniChannelRavenConnector.get_provider_from_channel(self.channel_id) + connector = OmniChannelRavenConnector(provider=provider) + connector.handle_outbound(raven_message=self) + def on_doctype_update(): """