Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion crates/openfang-api/src/channel_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1576,7 +1576,15 @@ pub async fn start_channel_bridge_with_config(

// DingTalk (webhook mode)
if let Some(ref dt_config) = config.dingtalk {
if let Some(token) = read_token(&dt_config.access_token_env, "DingTalk") {
if dt_config.mode == "stream" {
if let Some(client_id) = read_token(&dt_config.client_id_env, "DingTalk (client_id)") {
let client_secret =
read_token(&dt_config.client_secret_env, "DingTalk (client_secret)")
.unwrap_or_default();
let adapter = Arc::new(DingTalkAdapter::new_stream(client_id, client_secret));
adapters.push((adapter, dt_config.default_agent.clone()));
}
} else if let Some(token) = read_token(&dt_config.access_token_env, "DingTalk") {
let secret = read_token(&dt_config.secret_env, "DingTalk (secret)").unwrap_or_default();
let adapter = Arc::new(DingTalkAdapter::new(token, secret, dt_config.webhook_port));
adapters.push((adapter, dt_config.default_agent.clone()));
Expand Down
72 changes: 65 additions & 7 deletions crates/openfang-api/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use openfang_kernel::OpenFangKernel;
use openfang_runtime::kernel_handle::KernelHandle;
use openfang_runtime::tool_runner::builtin_tool_definitions;
use openfang_types::agent::{AgentId, AgentIdentity, AgentManifest};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, LazyLock};
use std::time::Instant;

Expand Down Expand Up @@ -1893,18 +1893,21 @@ const CHANNEL_REGISTRY: &[ChannelMeta] = &[
},
ChannelMeta {
name: "dingtalk", display_name: "DingTalk", icon: "DT",
description: "DingTalk Robot API adapter",
description: "DingTalk Robot API adapter (webhook or stream mode)",
category: "enterprise", difficulty: "Easy", setup_time: "~3 min",
quick_setup: "Paste your webhook token and signing secret",
quick_setup: "Choose webhook or stream mode, then paste credentials",
setup_type: "form",
fields: &[
ChannelField { key: "access_token_env", label: "Access Token", field_type: FieldType::Secret, env_var: Some("DINGTALK_ACCESS_TOKEN"), required: true, placeholder: "abc123...", advanced: false },
ChannelField { key: "secret_env", label: "Signing Secret", field_type: FieldType::Secret, env_var: Some("DINGTALK_SECRET"), required: true, placeholder: "SEC...", advanced: false },
ChannelField { key: "mode", label: "Mode", field_type: FieldType::Text, env_var: None, required: false, placeholder: "webhook or stream", advanced: false },
ChannelField { key: "access_token_env", label: "Access Token (webhook)", field_type: FieldType::Secret, env_var: Some("DINGTALK_ACCESS_TOKEN"), required: false, placeholder: "abc123...", advanced: false },
ChannelField { key: "secret_env", label: "Signing Secret (webhook)", field_type: FieldType::Secret, env_var: Some("DINGTALK_SECRET"), required: false, placeholder: "SEC...", advanced: false },
ChannelField { key: "webhook_port", label: "Webhook Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "8457", advanced: true },
ChannelField { key: "client_id_env", label: "Client ID / AppKey (stream)", field_type: FieldType::Secret, env_var: Some("DINGTALK_CLIENT_ID"), required: false, placeholder: "dingxxx...", advanced: false },
ChannelField { key: "client_secret_env", label: "Client Secret / AppSecret (stream)", field_type: FieldType::Secret, env_var: Some("DINGTALK_CLIENT_SECRET"), required: false, placeholder: "abc123...", advanced: false },
ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true },
],
setup_steps: &["Create a robot in your DingTalk group", "Copy the token and signing secret", "Paste them below"],
config_template: "[channels.dingtalk]\naccess_token_env = \"DINGTALK_ACCESS_TOKEN\"\nsecret_env = \"DINGTALK_SECRET\"",
setup_steps: &["Create a robot in DingTalk", "Choose mode: webhook (needs public IP) or stream (no public IP needed)", "For webhook: copy token and signing secret", "For stream: copy Client ID and Client Secret from the app page"],
config_template: "[channels.dingtalk]\nmode = \"stream\"\nclient_id_env = \"DINGTALK_CLIENT_ID\"\nclient_secret_env = \"DINGTALK_CLIENT_SECRET\"",
},
ChannelMeta {
name: "dingtalk_stream", display_name: "DingTalk Stream", icon: "DS",
Expand Down Expand Up @@ -2575,8 +2578,63 @@ pub async fn configure_channel(
let secrets_path = home.join("secrets.env");
let config_path = home.join("config.toml");
let mut config_fields: HashMap<String, (String, FieldType)> = HashMap::new();
let mut allowed_keys: Option<HashSet<&'static str>> = None;

if name == "dingtalk" {
let mode = match fields.get("mode").and_then(|v| v.as_str()) {
Some("webhook") | None => "webhook",
Some("stream") => "stream",
Some(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "DingTalk mode must be 'webhook' or 'stream'"})),
)
}
};

let allowed: HashSet<&'static str> = if mode == "stream" {
["mode", "default_agent", "client_id_env", "client_secret_env"]
.into_iter()
.collect()
} else {
[
"mode",
"default_agent",
"access_token_env",
"secret_env",
"webhook_port",
]
.into_iter()
.collect()
};
allowed_keys = Some(allowed);
config_fields.insert("mode".to_string(), (mode.to_string(), FieldType::Text));

let inactive_secret_keys = if mode == "stream" {
["DINGTALK_ACCESS_TOKEN", "DINGTALK_SECRET"]
} else {
["DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"]
};
for env_key in inactive_secret_keys {
if let Err(e) = remove_secret_env(&secrets_path, env_key) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Failed to remove stale secret: {e}")})),
);
}
unsafe {
std::env::remove_var(env_key);
}
}
}

for field_def in meta.fields {
if let Some(allowed) = &allowed_keys {
if !allowed.contains(field_def.key) {
continue;
}
}

let value = fields
.get(field_def.key)
.and_then(|v| v.as_str())
Expand Down
15 changes: 15 additions & 0 deletions crates/openfang-api/static/index_body.html
Original file line number Diff line number Diff line change
Expand Up @@ -2107,6 +2107,21 @@ <h3 style="display:flex;align-items:center;gap:0.5rem">
</ol>
</details>

<!-- DingTalk mode selector -->
<div x-show="isDingTalkChannel()" class="mb-3">
<label class="text-sm" style="display:block;margin-bottom:0.5rem">Connection mode</label>
<div class="flex gap-2" style="flex-wrap:wrap">
<label class="btn btn-ghost btn-sm" :class="{ 'btn-primary': dingTalkMode() === 'webhook' }" style="display:flex;align-items:center;gap:0.4rem;cursor:pointer">
<input type="radio" name="dingtalk-mode" value="webhook" :checked="dingTalkMode() === 'webhook'" @change="updateDingTalkMode('webhook')">
<span>Webhook</span>
</label>
<label class="btn btn-ghost btn-sm" :class="{ 'btn-primary': dingTalkMode() === 'stream' }" style="display:flex;align-items:center;gap:0.4rem;cursor:pointer">
<input type="radio" name="dingtalk-mode" value="stream" :checked="dingTalkMode() === 'stream'" @change="updateDingTalkMode('stream')">
<span>Stream</span>
</label>
</div>
</div>

<!-- Basic fields only (the minimum to get started) -->
<template x-for="f in (isQrChannel() && showBusinessApi) ? advancedFields() : basicFields()" :key="f.key">
<div style="margin-bottom:0.75rem">
Expand Down
69 changes: 62 additions & 7 deletions crates/openfang-api/static/js/pages/channels.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,35 @@ function channelsPage() {
return configured.length + '/' + all.length;
},

basicFields() {
isDingTalkChannel() {
return this.setupModal && this.setupModal.name === 'dingtalk';
},

dingTalkMode() {
if (!this.isDingTalkChannel()) return '';
return this.formValues.mode === 'stream' ? 'stream' : 'webhook';
},

visibleFields() {
if (!this.setupModal || !this.setupModal.fields) return [];
return this.setupModal.fields.filter(function(f) { return !f.advanced; });
var fields = this.setupModal.fields;
if (!this.isDingTalkChannel()) return fields;
var mode = this.dingTalkMode();
return fields.filter(function(f) {
if (f.key === 'mode' || f.key === 'default_agent') return true;
if (mode === 'stream') {
return f.key === 'client_id_env' || f.key === 'client_secret_env';
}
return f.key === 'access_token_env' || f.key === 'secret_env' || f.key === 'webhook_port';
});
},

basicFields() {
return this.visibleFields().filter(function(f) { return !f.advanced; });
},

advancedFields() {
if (!this.setupModal || !this.setupModal.fields) return [];
return this.setupModal.fields.filter(function(f) { return f.advanced; });
return this.visibleFields().filter(function(f) { return f.advanced; });
},

hasAdvanced() {
Expand Down Expand Up @@ -150,6 +171,9 @@ function channelsPage() {
}
});
}
if (ch.name === 'dingtalk') {
vals.mode = vals.mode === 'stream' ? 'stream' : 'webhook';
}
this.formValues = vals;
this.showAdvanced = false;
this.showBusinessApi = false;
Expand All @@ -162,6 +186,39 @@ function channelsPage() {
}
},

updateDingTalkMode(mode) {
if (!this.isDingTalkChannel()) return;
this.formValues.mode = mode === 'stream' ? 'stream' : 'webhook';
if (this.formValues.mode === 'stream') {
delete this.formValues.access_token_env;
delete this.formValues.secret_env;
delete this.formValues.webhook_port;
} else {
delete this.formValues.client_id_env;
delete this.formValues.client_secret_env;
}
},

buildChannelPayload() {
if (!this.isDingTalkChannel()) {
return { fields: this.formValues };
}
var mode = this.dingTalkMode();
var payloadFields = { mode: mode };
if (this.formValues.default_agent !== undefined && this.formValues.default_agent !== null) {
payloadFields.default_agent = this.formValues.default_agent;
}
if (mode === 'stream') {
if (this.formValues.client_id_env !== undefined) payloadFields.client_id_env = this.formValues.client_id_env;
if (this.formValues.client_secret_env !== undefined) payloadFields.client_secret_env = this.formValues.client_secret_env;
} else {
if (this.formValues.access_token_env !== undefined) payloadFields.access_token_env = this.formValues.access_token_env;
if (this.formValues.secret_env !== undefined) payloadFields.secret_env = this.formValues.secret_env;
if (this.formValues.webhook_port !== undefined) payloadFields.webhook_port = this.formValues.webhook_port;
}
return { fields: payloadFields };
},

// ── QR Code Flow (WhatsApp Web style) ──────────────────────────

resetQR() {
Expand Down Expand Up @@ -230,9 +287,7 @@ function channelsPage() {
var name = this.setupModal.name;
this.configuring = true;
try {
await OpenFangAPI.post('/api/channels/' + name + '/configure', {
fields: this.formValues
});
await OpenFangAPI.post('/api/channels/' + name + '/configure', this.buildChannelPayload());
this.setupStep = 2;
// Auto-test after save
try {
Expand Down
Loading