Skip to content

Commit 5d62552

Browse files
committed
feat: add instagram integration
1 parent eed04ff commit 5d62552

5 files changed

Lines changed: 232 additions & 8 deletions

File tree

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
11
// Copyright (c) 2026, The Commit Company (Algocode Technologies Pvt. Ltd.) and contributors
22
// For license information, please see license.txt
33

4-
// frappe.ui.form.on("Omni Channel Chat Provider", {
5-
// refresh(frm) {
4+
frappe.ui.form.on("Omni Channel Chat Provider", {
5+
refresh(frm) {
6+
set_instagram_banner(frm);
7+
},
8+
provider(frm) {
9+
set_instagram_banner(frm);
10+
},
11+
});
612

7-
// },
8-
// });
13+
function set_instagram_banner(frm) {
14+
if (frm.doc.provider === "instagram") {
15+
frm.set_intro(__("To receive webhooks, your app must be in published state."), "yellow");
16+
} else {
17+
frm.set_intro("");
18+
}
19+
}

raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.json

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,19 @@
1515
"conf_facebook_sec",
1616
"fb_page_access_token",
1717
"fb_verify_token",
18-
"fb_app_secret"
18+
"fb_app_secret",
19+
"conf_instagram_sec",
20+
"ig_page_access_token",
21+
"ig_verify_token",
22+
"ig_app_secret"
1923
],
2024
"fields": [
2125
{
2226
"fieldname": "provider",
2327
"fieldtype": "Select",
2428
"in_list_view": 1,
2529
"label": "Provider",
26-
"options": "\nline\nfacebook",
30+
"options": "\nline\nfacebook\ninstagram",
2731
"reqd": 1
2832
},
2933
{
@@ -91,6 +95,33 @@
9195
"label": "App Secret",
9296
"length": 512,
9397
"mandatory_depends_on": "eval:doc.provider==\"facebook\""
98+
},
99+
{
100+
"depends_on": "eval:doc.provider==\"instagram\"",
101+
"fieldname": "conf_instagram_sec",
102+
"fieldtype": "Section Break",
103+
"label": "Config - Instagram"
104+
},
105+
{
106+
"fieldname": "ig_page_access_token",
107+
"fieldtype": "Password",
108+
"label": "Page Access Token",
109+
"length": 512,
110+
"mandatory_depends_on": "eval:doc.provider==\"instagram\""
111+
},
112+
{
113+
"fieldname": "ig_verify_token",
114+
"fieldtype": "Password",
115+
"label": "Verify Token",
116+
"length": 512,
117+
"mandatory_depends_on": "eval:doc.provider==\"instagram\""
118+
},
119+
{
120+
"fieldname": "ig_app_secret",
121+
"fieldtype": "Password",
122+
"label": "App Secret",
123+
"length": 512,
124+
"mandatory_depends_on": "eval:doc.provider==\"instagram\""
94125
}
95126
],
96127
"grid_page_length": 50,

raven/omni_channel_chat/doctype/omni_channel_chat_provider/omni_channel_chat_provider.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from frappe import _
66
from frappe.model.document import Document
77

8-
from .provider import FacebookProvider, LineProvider, Provider
8+
from .provider import FacebookProvider, InstagramProvider, LineProvider, Provider
99

1010

1111
class OmniChannelChatProvider(Document):
@@ -21,9 +21,12 @@ class OmniChannelChatProvider(Document):
2121
fb_app_secret: DF.Password | None
2222
fb_page_access_token: DF.Password | None
2323
fb_verify_token: DF.Password | None
24+
ig_app_secret: DF.Password | None
25+
ig_page_access_token: DF.Password | None
26+
ig_verify_token: DF.Password | None
2427
line_channel_access_token: DF.Password | None
2528
line_channel_secret: DF.Password | None
26-
provider: DF.Literal["", "line", "facebook"]
29+
provider: DF.Literal["", "line", "facebook", "instagram"]
2730
raven_workspace: DF.Link
2831
# end: auto-generated types
2932

@@ -44,6 +47,8 @@ def get_provider(self) -> Provider:
4447
return LineProvider(config=self)
4548
elif self.provider == "facebook":
4649
return FacebookProvider(config=self)
50+
elif self.provider == "instagram":
51+
return InstagramProvider(config=self)
4752
else:
4853
frappe.throw(_("Provider not implemented."))
4954

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
from .base_provider import Provider as Provider
22
from .facebook_provider import FacebookProvider as FacebookProvider
3+
from .instagram_provider import InstagramProvider as InstagramProvider
34
from .line_provider import LineProvider as LineProvider
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import hashlib
2+
import hmac
3+
import json
4+
from typing import Any
5+
6+
import frappe
7+
import httpx
8+
from werkzeug.wrappers import Response
9+
10+
from raven.omni_channel_chat.doctype.omni_channel_chat_provider.provider import (
11+
Provider,
12+
)
13+
14+
InstagramMessagingEvent = dict[str, Any]
15+
16+
17+
class InstagramProvider(Provider[InstagramMessagingEvent, dict]):
18+
IG_API_URL = "https://graph.instagram.com/v25.0/me/messages"
19+
20+
def __init__(self, config):
21+
super().__init__(config=config)
22+
self._page_access_token = self.provider_config.ig_page_access_token
23+
self._app_secret = self.provider_config.ig_app_secret
24+
self._verify_token = self.provider_config.ig_verify_token
25+
26+
def handle_frappe_api(self) -> Response:
27+
request = frappe.local.request
28+
29+
if request.method == "GET":
30+
mode = frappe.form_dict.get("hub.mode")
31+
verify_token = frappe.form_dict.get("hub.verify_token")
32+
challenge = frappe.form_dict.get("hub.challenge", "0")
33+
34+
if mode == "subscribe" and verify_token == self._verify_token:
35+
return Response(challenge, status=200, content_type="text/plain")
36+
else:
37+
frappe.throw("Verification failed", frappe.PermissionError)
38+
39+
body: bytes = request.get_data()
40+
headers: dict = dict(request.headers)
41+
42+
return self.handle_webhook(body=body, headers=headers)
43+
44+
def _verify_signature(self, body: bytes, signature_header: str) -> bool:
45+
if not signature_header.startswith("sha256="):
46+
return False
47+
expected = hmac.new(self._app_secret.encode(), body, hashlib.sha256).hexdigest()
48+
return hmac.compare_digest(expected, signature_header.removeprefix("sha256="))
49+
50+
def get_user_info(self, user_id: str) -> dict:
51+
with httpx.Client() as client:
52+
response = client.get(
53+
f"https://graph.facebook.com/v22.0/{user_id}",
54+
params={
55+
"fields": "name,profile_pic",
56+
"access_token": self._page_access_token,
57+
},
58+
)
59+
response.raise_for_status()
60+
data = response.json()
61+
return {
62+
"user_id": user_id,
63+
"display_name": data.get("name"),
64+
"picture_url": data.get("profile_pic"),
65+
}
66+
67+
def show_typing(self, user_id: str) -> None:
68+
with httpx.Client() as client:
69+
client.post(
70+
self.IG_API_URL,
71+
params={"access_token": self._page_access_token},
72+
json={"recipient": {"id": user_id}, "sender_action": "typing_on"},
73+
)
74+
75+
def send_reply(self, user_id: str, message: dict, context: Any) -> None:
76+
self.send_message(user_id=user_id, message=message)
77+
78+
def send_message(self, user_id: str, message: dict) -> None:
79+
msg_type = message.get("type", "Text")
80+
if msg_type == "Image":
81+
ig_message = {
82+
"attachment": {
83+
"type": "image",
84+
"payload": {"url": message["file_url"], "is_reusable": True},
85+
}
86+
}
87+
elif msg_type == "File":
88+
ig_message = {
89+
"attachment": {
90+
"type": "file",
91+
"payload": {"url": message["file_url"], "is_reusable": True},
92+
}
93+
}
94+
else:
95+
ig_message = {"text": message["text"]}
96+
with httpx.Client() as client:
97+
client.post(
98+
self.IG_API_URL,
99+
params={"access_token": self._page_access_token},
100+
json={"recipient": {"id": user_id}, "message": ig_message},
101+
)
102+
103+
def _download_attachment(self, url: str, default_name: str) -> tuple[bytes, str]:
104+
with httpx.Client() as client:
105+
response = client.get(url)
106+
response.raise_for_status()
107+
content_disposition = response.headers.get("content-disposition", "")
108+
file_name = default_name
109+
if "filename=" in content_disposition:
110+
file_name = content_disposition.split("filename=")[-1].strip('" ')
111+
return response.content, file_name
112+
113+
def event_mapper(self, event: InstagramMessagingEvent) -> dict | None:
114+
message = event.get("message")
115+
if message is None:
116+
return None
117+
118+
mid = message.get("mid")
119+
120+
if "text" in message:
121+
return {
122+
"provider": self.provider_config.provider,
123+
"user_id": event["sender"]["id"],
124+
"message": {"type": "Text", "text": message["text"]},
125+
"message_metadata": {"mid": mid},
126+
}
127+
128+
for attachment in message.get("attachments") or []:
129+
att_type = attachment.get("type")
130+
url = attachment.get("payload", {}).get("url")
131+
if not url:
132+
continue
133+
if att_type == "image":
134+
content, file_name = self._download_attachment(url, f"{mid or 'image'}.jpg")
135+
return {
136+
"provider": self.provider_config.provider,
137+
"user_id": event["sender"]["id"],
138+
"message": {"type": "Image", "file_name": file_name, "file_content": content},
139+
"message_metadata": {"mid": mid},
140+
}
141+
if att_type in ("file", "video", "audio"):
142+
content, file_name = self._download_attachment(url, mid or "file")
143+
return {
144+
"provider": self.provider_config.provider,
145+
"user_id": event["sender"]["id"],
146+
"message": {"type": "File", "file_name": file_name, "file_content": content},
147+
"message_metadata": {"mid": mid},
148+
}
149+
150+
return None
151+
152+
def standardize_events(self, events: list[InstagramMessagingEvent]) -> list[dict]:
153+
std_events: list[dict] = []
154+
for event in events:
155+
std_event = self.event_mapper(event)
156+
if std_event:
157+
std_events.append(std_event)
158+
return std_events
159+
160+
def extract_messages(self, body: bytes, headers: dict) -> list[dict]:
161+
signature = headers.get("X-Hub-Signature-256", "") or headers.get(
162+
"x-hub-signature-256", ""
163+
)
164+
if not self._verify_signature(body, signature):
165+
frappe.throw("Invalid Instagram signature", frappe.PermissionError)
166+
167+
payload = json.loads(body)
168+
if payload.get("object") != "instagram":
169+
frappe.throw("Not an Instagram event", frappe.ValidationError)
170+
171+
messaging_events: list[InstagramMessagingEvent] = [
172+
messaging_event
173+
for entry in payload.get("entry", [])
174+
for messaging_event in entry.get("messaging", [])
175+
]
176+
return self.standardize_events(messaging_events)

0 commit comments

Comments
 (0)