mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(composio): collect WABA ID before WhatsApp Business OAuth flow (#1550)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
Steven Enamakel
parent
3bfe53aaa9
commit
706edf5f44
@@ -13,7 +13,7 @@
|
||||
* keep the Skills page badge in sync too, so the card reflects the new
|
||||
* state as soon as the modal closes.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { type ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import {
|
||||
@@ -73,6 +73,9 @@ export default function ComposioConnectModal({
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connectUrl, setConnectUrl] = useState<string | null>(null);
|
||||
// WhatsApp Business requires a WABA ID before the OAuth flow can start.
|
||||
const [wabaId, setWabaId] = useState('');
|
||||
const needsWabaId = toolkit.slug === 'whatsapp';
|
||||
const [activeConnection, setActiveConnection] = useState<ComposioConnection | undefined>(
|
||||
connection
|
||||
);
|
||||
@@ -188,11 +191,16 @@ export default function ComposioConnectModal({
|
||||
}, []);
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
if (needsWabaId && !wabaId.trim()) {
|
||||
setError('Please enter your WhatsApp Business Account ID (WABA ID) to continue.');
|
||||
return;
|
||||
}
|
||||
setPhase('authorizing');
|
||||
setError(null);
|
||||
setConnectUrl(null);
|
||||
try {
|
||||
const resp = await authorize(toolkit.slug);
|
||||
const extraParams = needsWabaId ? { waba_id: wabaId.trim() } : undefined;
|
||||
const resp = await authorize(toolkit.slug, extraParams);
|
||||
setConnectUrl(resp.connectUrl);
|
||||
await openUrl(resp.connectUrl);
|
||||
setPhase('waiting');
|
||||
@@ -202,7 +210,7 @@ export default function ComposioConnectModal({
|
||||
setPhase('error');
|
||||
setError(`Authorization failed: ${msg}`);
|
||||
}
|
||||
}, [startPolling, toolkit.slug]);
|
||||
}, [needsWabaId, startPolling, toolkit.slug, wabaId]);
|
||||
|
||||
// Fetch the stored scope pref whenever the modal lands in the
|
||||
// 'connected' phase. Re-fetching each time we transition (rather
|
||||
@@ -360,6 +368,35 @@ export default function ComposioConnectModal({
|
||||
admin toggles.
|
||||
</p>
|
||||
</div>
|
||||
{needsWabaId && (
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="waba-id-input"
|
||||
className="block text-xs font-medium text-stone-700">
|
||||
WhatsApp Business Account ID (WABA ID)
|
||||
<span className="ml-1 text-coral-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="waba-id-input"
|
||||
type="text"
|
||||
value={wabaId}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
setWabaId(e.target.value);
|
||||
if (error) setError(null);
|
||||
}}
|
||||
placeholder="e.g. 123456789012345"
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 placeholder:text-stone-400 focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-100"
|
||||
/>
|
||||
<p className="text-[11px] leading-relaxed text-stone-400">
|
||||
Find it via <span className="font-mono">GET /me/businesses</span> then{' '}
|
||||
<span className="font-mono">
|
||||
GET /{business_id}/owned_whatsapp_business_accounts
|
||||
</span>{' '}
|
||||
using your Meta access token.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{error && phase === 'idle' && <p className="text-[11px] text-coral-600">{error}</p>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleConnect()}
|
||||
|
||||
@@ -73,11 +73,17 @@ export async function listTools(toolkits?: string[]): Promise<ComposioToolsRespo
|
||||
* Begin an OAuth handoff for `toolkit`. The returned `connectUrl`
|
||||
* must be opened in a browser for the user to complete the flow.
|
||||
* The core publishes a `ComposioConnectionCreated` event on success.
|
||||
*
|
||||
* `extraParams` is merged into the backend request body. Required for
|
||||
* toolkits that need additional fields (e.g. `whatsapp` needs `waba_id`).
|
||||
*/
|
||||
export async function authorize(toolkit: string): Promise<ComposioAuthorizeResponse> {
|
||||
export async function authorize(
|
||||
toolkit: string,
|
||||
extraParams?: Record<string, string>
|
||||
): Promise<ComposioAuthorizeResponse> {
|
||||
const raw = await callCoreRpc<unknown>({
|
||||
method: 'openhuman.composio_authorize',
|
||||
params: { toolkit },
|
||||
params: extraParams ? { toolkit, extra_params: extraParams } : { toolkit },
|
||||
});
|
||||
return unwrapCliEnvelope<ComposioAuthorizeResponse>(raw);
|
||||
}
|
||||
|
||||
@@ -65,13 +65,39 @@ impl ComposioClient {
|
||||
/// `POST /agent-integrations/composio/authorize` — begin an OAuth
|
||||
/// handoff for `toolkit` and return the hosted `connectUrl` the user
|
||||
/// must open in a browser.
|
||||
pub async fn authorize(&self, toolkit: &str) -> Result<ComposioAuthorizeResponse> {
|
||||
///
|
||||
/// `extra_params` is an optional JSON object whose key/value pairs are
|
||||
/// merged into the request body. Some toolkits (e.g. `whatsapp`) require
|
||||
/// additional fields (e.g. `waba_id`) that Composio will reject the
|
||||
/// authorization without.
|
||||
pub async fn authorize(
|
||||
&self,
|
||||
toolkit: &str,
|
||||
extra_params: Option<serde_json::Value>,
|
||||
) -> Result<ComposioAuthorizeResponse> {
|
||||
let toolkit = toolkit.trim();
|
||||
if toolkit.is_empty() {
|
||||
anyhow::bail!("composio.authorize: toolkit must not be empty");
|
||||
}
|
||||
tracing::debug!(toolkit = %toolkit, "[composio] authorize");
|
||||
let body = json!({ "toolkit": toolkit });
|
||||
tracing::debug!(toolkit = %toolkit, has_extra_params = extra_params.is_some(), "[composio] authorize");
|
||||
let mut body = serde_json::json!({ "toolkit": toolkit });
|
||||
if let Some(extra) = extra_params {
|
||||
const RESERVED: &[&str] = &["toolkit", "toolkit_version", "auth", "client_id"];
|
||||
let extra_obj = extra.as_object().ok_or_else(|| {
|
||||
anyhow::anyhow!("composio.authorize: extra_params must be a JSON object")
|
||||
})?;
|
||||
let obj = body.as_object_mut().ok_or_else(|| {
|
||||
anyhow::anyhow!("composio.authorize: internal payload must be an object")
|
||||
})?;
|
||||
for (k, v) in extra_obj {
|
||||
if RESERVED.contains(&k.as_str()) {
|
||||
anyhow::bail!(
|
||||
"composio.authorize: extra_params cannot override reserved key '{k}'"
|
||||
);
|
||||
}
|
||||
obj.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
self.inner
|
||||
.post::<ComposioAuthorizeResponse>("/agent-integrations/composio/authorize", &body)
|
||||
.await
|
||||
|
||||
@@ -41,13 +41,50 @@ async fn authorize_rejects_empty_toolkit() {
|
||||
"test".into(),
|
||||
));
|
||||
let client = ComposioClient::new(inner);
|
||||
let err = client.authorize(" ").await.unwrap_err();
|
||||
let err = client.authorize(" ", None).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("toolkit must not be empty"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `authorize()` must reject a non-object `extra_params` before making any HTTP call.
|
||||
#[tokio::test]
|
||||
async fn authorize_rejects_non_object_extra_params() {
|
||||
let inner = Arc::new(crate::openhuman::integrations::IntegrationClient::new(
|
||||
"http://127.0.0.1:0".into(),
|
||||
"test".into(),
|
||||
));
|
||||
let client = ComposioClient::new(inner);
|
||||
let err = client
|
||||
.authorize("whatsapp", Some(serde_json::json!("waba-123")))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("extra_params must be a JSON object"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `authorize()` must reject an `extra_params` object that tries to override a reserved key.
|
||||
#[tokio::test]
|
||||
async fn authorize_rejects_reserved_key_override() {
|
||||
let inner = Arc::new(crate::openhuman::integrations::IntegrationClient::new(
|
||||
"http://127.0.0.1:0".into(),
|
||||
"test".into(),
|
||||
));
|
||||
let client = ComposioClient::new(inner);
|
||||
let err = client
|
||||
.authorize("whatsapp", Some(serde_json::json!({ "toolkit": "gmail" })))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("cannot override reserved key"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `delete_connection()` likewise must reject empty connection ids.
|
||||
#[tokio::test]
|
||||
async fn delete_connection_rejects_empty_id() {
|
||||
@@ -191,11 +228,36 @@ async fn authorize_posts_toolkit_and_returns_connect_url() {
|
||||
);
|
||||
let base = start_mock_backend(app).await;
|
||||
let client = build_client_for(base);
|
||||
let resp = client.authorize("gmail").await.unwrap();
|
||||
let resp = client.authorize("gmail", None).await.unwrap();
|
||||
assert!(resp.connect_url.contains("gmail"));
|
||||
assert_eq!(resp.connection_id, "conn-abc");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authorize_forwards_extra_params_and_returns_connect_url() {
|
||||
let app = Router::new().route(
|
||||
"/agent-integrations/composio/authorize",
|
||||
post(|Json(body): Json<Value>| async move {
|
||||
// Assert extra_params are forwarded alongside toolkit.
|
||||
assert_eq!(body["toolkit"].as_str(), Some("whatsapp"));
|
||||
assert_eq!(body["waba_id"].as_str(), Some("waba-123"));
|
||||
Json(json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"connectUrl": "https://composio.example/whatsapp/consent",
|
||||
"connectionId": "conn-xyz"
|
||||
}
|
||||
}))
|
||||
}),
|
||||
);
|
||||
let base = start_mock_backend(app).await;
|
||||
let client = build_client_for(base);
|
||||
let extra = serde_json::json!({ "waba_id": "waba-123" });
|
||||
let resp = client.authorize("whatsapp", Some(extra)).await.unwrap();
|
||||
assert!(resp.connect_url.contains("whatsapp"));
|
||||
assert_eq!(resp.connection_id, "conn-xyz");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_tools_filters_pass_through_as_csv_query_param() {
|
||||
let app = Router::new().route(
|
||||
|
||||
@@ -97,11 +97,12 @@ pub async fn composio_list_connections(
|
||||
pub async fn composio_authorize(
|
||||
config: &Config,
|
||||
toolkit: &str,
|
||||
extra_params: Option<serde_json::Value>,
|
||||
) -> OpResult<RpcOutcome<ComposioAuthorizeResponse>> {
|
||||
tracing::debug!(toolkit = %toolkit, "[composio] rpc authorize");
|
||||
tracing::debug!(toolkit = %toolkit, has_extra_params = extra_params.is_some(), "[composio] rpc authorize");
|
||||
let client = resolve_client(config)?;
|
||||
let resp = client
|
||||
.authorize(toolkit)
|
||||
.authorize(toolkit, extra_params)
|
||||
.await
|
||||
.map_err(|e| format!("[composio] authorize failed: {e:#}"))?;
|
||||
|
||||
|
||||
@@ -69,7 +69,9 @@ async fn composio_list_connections_errors_without_session() {
|
||||
async fn composio_authorize_errors_without_session() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let err = composio_authorize(&config, "gmail").await.unwrap_err();
|
||||
let err = composio_authorize(&config, "gmail", None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("composio unavailable"));
|
||||
}
|
||||
|
||||
@@ -291,7 +293,7 @@ async fn composio_authorize_via_mock_publishes_event_and_returns_url() {
|
||||
let base = start_mock_backend(app).await;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = config_with_backend(&tmp, base);
|
||||
let outcome = composio_authorize(&config, "gmail").await.unwrap();
|
||||
let outcome = composio_authorize(&config, "gmail", None).await.unwrap();
|
||||
assert_eq!(outcome.value.connect_url, "https://x");
|
||||
assert_eq!(outcome.value.connection_id, "c1");
|
||||
}
|
||||
|
||||
@@ -188,12 +188,22 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
namespace: "composio",
|
||||
function: "authorize",
|
||||
description: "Begin an OAuth handoff for a toolkit and return the hosted connect URL.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "toolkit",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Toolkit slug to authorize (must be in the backend allowlist).",
|
||||
required: true,
|
||||
}],
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "toolkit",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Toolkit slug to authorize (must be in the backend allowlist).",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "extra_params",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Optional JSON object of additional auth fields forwarded to Composio \
|
||||
(e.g. {\"waba_id\": \"...\") for toolkits that require them). \
|
||||
Reserved keys (toolkit, toolkit_version, auth, client_id) are rejected.",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![
|
||||
FieldSchema {
|
||||
name: "connectUrl",
|
||||
@@ -601,7 +611,8 @@ fn handle_authorize(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let toolkit = read_required_non_empty(¶ms, "toolkit")?;
|
||||
to_json(super::ops::composio_authorize(&config, &toolkit).await?)
|
||||
let extra_params = params.get("extra_params").cloned();
|
||||
to_json(super::ops::composio_authorize(&config, &toolkit, extra_params).await?)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -422,7 +422,7 @@ impl Tool for ComposioAuthorizeTool {
|
||||
));
|
||||
}
|
||||
tracing::debug!(toolkit = %toolkit, "[composio] tool authorize.execute");
|
||||
match self.client.authorize(&toolkit).await {
|
||||
match self.client.authorize(&toolkit, None).await {
|
||||
Ok(resp) => {
|
||||
crate::core::event_bus::publish_global(
|
||||
crate::core::event_bus::DomainEvent::ComposioConnectionCreated {
|
||||
|
||||
Reference in New Issue
Block a user