Parrel agents

This commit is contained in:
MrFadiAi
2026-02-15 23:37:59 +01:00
parent 908ce3582e
commit 1eb734062f
7 changed files with 1814 additions and 349 deletions
+126
View File
@@ -0,0 +1,126 @@
# Multi-Agent Telegram Setup — Multi-Bot Accounts
Route different forum topics (or groups) to different AI agents by using **separate Telegram bots**, each bound to its own agent.
---
## How It Works
```
@researchbot (Account: researchbot) ──→ Agent: research
@coderbot (Account: coderbot) ──→ Agent: coder
```
Each bot is a separate Telegram account in OpenClaw. You add both bots to the same group, and each responds independently with its own agent/personality/memory.
---
## Step 1: Create Bots in BotFather
1. Open Telegram → search **@BotFather**
2. Send `/newbot` → name it "Research Bot" → username: `orbex_research_bot`
3. Copy the **bot token**
4. Repeat for a second bot: "Code Bot" → `orbex_code_bot`
5. For each bot: send `/setprivacy` → select the bot → `Disable` (so it can see group messages)
---
## Step 2: Add Bot Accounts in Channels
1. Open **OpenClaw Manager****Channels****Telegram**
2. Scroll down to **Bot Accounts** section
3. Click **+ Add Bot**:
- Account ID: `researchbot`
- Bot Token: *(paste token from step 1)*
- Click **Add**
4. Click **+ Add Bot** again:
- Account ID: `coderbot`
- Bot Token: *(paste second token)*
- Click **Add**
5. Expand each account and set **Group Policy**`open` (or `allowlist`)
6. Click **Save Account** on each
---
## Step 3: Create Agents
1. Go to **Agents****+ Add Agent**
2. Create agent `research` (Agent ID: `research`)
3. Create agent `coder` (Agent ID: `coder`)
4. Save each
---
## Step 4: Create Routing Rules
1. In **Routing Rules****+ Add Rule**
2. First rule:
- **Route To Agent**: `research`
- **Channel**: `telegram`
- **Account ID**: `researchbot`
- **Peer Match**: Any
- Click **Add Rule**
3. Second rule:
- **Route To Agent**: `coder`
- **Channel**: `telegram`
- **Account ID**: `coderbot`
- **Peer Match**: Any
- Click **Add Rule**
---
## Step 5: Add Bots to Your Group
1. Open your Telegram group
2. Add both bots as members: `@orbex_research_bot` and `@orbex_code_bot`
3. Make both bots **admins** (so they can see all messages)
---
## Step 6: Verify openclaw.json
Check `~/.openclaw/openclaw.json`:
```json
{
"agents": {
"list": [
{ "id": "research" },
{ "id": "coder" }
]
},
"bindings": [
{ "agentId": "research", "match": { "channel": "telegram", "accountId": "researchbot" } },
{ "agentId": "coder", "match": { "channel": "telegram", "accountId": "coderbot" } }
],
"channels": {
"telegram": {
"accounts": {
"researchbot": { "botToken": "TOKEN_1", "groupPolicy": "open" },
"coderbot": { "botToken": "TOKEN_2", "groupPolicy": "open" }
}
}
}
}
```
---
## Step 7: Restart & Test
1. **Restart OpenClaw**
2. In your Telegram group, send `@orbex_research_bot what are you?`
3. Then send `@orbex_code_bot what are you?`
4. Each should respond with its own personality
---
## Quick Reference
| Task | Where |
|---|---|
| Create Telegram bots | BotFather → `/newbot` |
| Add bot accounts | Channels → Telegram → Bot Accounts |
| Create agents | Agents → + Add Agent |
| Route account → agent | Agents → + Add Rule → set Account ID |
| Per-account groups | Channels → expand account → Group Policy |
+28
View File
@@ -0,0 +1,28 @@
{
"providers": {
"glm": {
"baseUrl": "https://open.bigmodel.cn/api/paas/v4",
"apiKey": "3e911333b0dc462dba0b4ce886303cce.4WF3QN7zfirCWXxS",
"models": [
{
"id": "glm-5",
"name": "glm-5",
"api": "openai-completions",
"reasoning": false,
"input": [
"text",
"image"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 200000,
"maxTokens": 8192
}
]
}
}
}
+492 -31
View File
@@ -1581,6 +1581,247 @@ pub async fn clear_channel_config(channel_id: String) -> Result<String, String>
}
}
// ============ Telegram Multi-Account Management ============
/// Telegram account info for frontend
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TelegramAccount {
pub id: String,
#[serde(alias = "botToken", alias = "bot_token")]
pub bot_token: String,
#[serde(alias = "groupPolicy", alias = "group_policy")]
pub group_policy: Option<String>,
#[serde(alias = "dmPolicy", alias = "dm_policy")]
pub dm_policy: Option<String>,
#[serde(alias = "streamMode", alias = "stream_mode")]
pub stream_mode: Option<String>,
#[serde(alias = "exclusiveTopics", alias = "exclusive_topics")]
pub exclusive_topics: Option<Vec<String>>,
pub groups: Option<serde_json::Value>,
}
/// Get all Telegram bot accounts
#[command]
pub async fn get_telegram_accounts() -> Result<Vec<TelegramAccount>, String> {
info!("[Telegram Accounts] Getting accounts...");
let config = load_openclaw_config()?;
let mut accounts = Vec::new();
// Check for multi-account structure: channels.telegram.accounts
if let Some(accts) = config.pointer("/channels/telegram/accounts").and_then(|v| v.as_object()) {
for (id, acct_val) in accts {
accounts.push(TelegramAccount {
id: id.clone(),
bot_token: acct_val.get("botToken").and_then(|v| v.as_str()).unwrap_or("").to_string(),
group_policy: acct_val.get("groupPolicy").and_then(|v| v.as_str()).map(|s| s.to_string()),
dm_policy: acct_val.get("dmPolicy").and_then(|v| v.as_str()).map(|s| s.to_string()),
stream_mode: acct_val.get("streamMode").and_then(|v| v.as_str()).map(|s| s.to_string()),
exclusive_topics: {
// Re-infer exclusive topics from group config
// Logic: If a group has requireMention=true and specific topics have requireMention=false, those are exclusive topics.
let mut inferred_topics = Vec::new();
if let Some(groups_map) = acct_val.get("groups").and_then(|g| g.as_object()) {
for (_, group_val) in groups_map {
// Check if group is muted (requireMention=true)
if group_val.get("requireMention").and_then(|v| v.as_bool()).unwrap_or(false) {
if let Some(topics_map) = group_val.get("topics").and_then(|t| t.as_object()) {
for (tid, tval) in topics_map {
// Check if topic is unmuted (requireMention=false)
if !tval.get("requireMention").and_then(|v| v.as_bool()).unwrap_or(true) {
inferred_topics.push(tid.clone());
}
}
}
}
}
}
if inferred_topics.is_empty() { None } else { Some(inferred_topics) }
},
groups: acct_val.get("groups").cloned(),
});
}
}
// Fallback: single-bot config (botToken at top level)
if accounts.is_empty() {
if let Some(token) = config.pointer("/channels/telegram/botToken").and_then(|v| v.as_str()) {
if !token.is_empty() {
accounts.push(TelegramAccount {
id: "default".to_string(),
bot_token: token.to_string(),
group_policy: config.pointer("/channels/telegram/groupPolicy").and_then(|v| v.as_str()).map(|s| s.to_string()),
dm_policy: config.pointer("/channels/telegram/dmPolicy").and_then(|v| v.as_str()).map(|s| s.to_string()),
stream_mode: config.pointer("/channels/telegram/streamMode").and_then(|v| v.as_str()).map(|s| s.to_string()),
exclusive_topics: None,
groups: config.pointer("/channels/telegram/groups").cloned(),
});
}
}
}
info!("[Telegram Accounts] Found {} accounts", accounts.len());
Ok(accounts)
}
/// Save a Telegram bot account
#[command]
pub async fn save_telegram_account(account: TelegramAccount) -> Result<String, String> {
info!("[Telegram Accounts] Saving account: {}", account.id);
let mut config = load_openclaw_config()?;
// Ensure channels.telegram exists
if config.get("channels").is_none() {
config["channels"] = json!({});
}
if config["channels"].get("telegram").is_none() {
config["channels"]["telegram"] = json!({ "enabled": true });
}
// Ensure accounts object exists
if config["channels"]["telegram"].get("accounts").is_none() {
config["channels"]["telegram"]["accounts"] = json!({});
}
// Migrate single-bot to accounts if this is the first additional account
if let Some(top_token) = config["channels"]["telegram"].get("botToken").and_then(|v| v.as_str()).map(|s| s.to_string()) {
if !top_token.is_empty() {
// Move existing single-bot config to accounts["default"]
let existing = json!({
"botToken": top_token,
"groupPolicy": config["channels"]["telegram"].get("groupPolicy").cloned().unwrap_or(json!(null)),
"dmPolicy": config["channels"]["telegram"].get("dmPolicy").cloned().unwrap_or(json!(null)),
"streamMode": config["channels"]["telegram"].get("streamMode").cloned().unwrap_or(json!(null)),
"groups": config["channels"]["telegram"].get("groups").cloned().unwrap_or(json!(null)),
});
config["channels"]["telegram"]["accounts"]["default"] = existing;
// Remove top-level single-bot fields
if let Some(tg) = config["channels"]["telegram"].as_object_mut() {
tg.remove("botToken");
tg.remove("groupPolicy");
tg.remove("dmPolicy");
tg.remove("streamMode");
tg.remove("groups");
tg.remove("allowFrom");
tg.remove("groupAllowFrom");
}
}
}
// Build account object
let mut acct_obj = json!({
"botToken": account.bot_token,
});
if let Some(gp) = &account.group_policy {
acct_obj["groupPolicy"] = json!(gp);
}
if let Some(dp) = &account.dm_policy {
acct_obj["dmPolicy"] = json!(dp);
}
if let Some(sm) = &account.stream_mode {
acct_obj["streamMode"] = json!(sm);
}
// Handle groups configuration
// If exclusive_topics is set, we need to modify the group config to enforce it
// 1. Set group-level requireMention = true (default behavior: ignore everything)
// 2. Set topic-level requireMention = false for whitelisted topics (exception: auto-reply)
let mut groups_json = account.groups.clone();
if let Some(exclusive_topics) = &account.exclusive_topics {
if !exclusive_topics.is_empty() {
// We also save the raw list so the UI can reload it (using a hidden field or relying on inference)
// However, OpenClaw core rejects unknown fields. So we must ONLY output valid config.
// Strategy: The UI will need to infer exclusive topics from the config structure if we can't save the field.
// OR: We save it as a comment? No, JSON doesn't support comments.
// COMPROMISE: We will NOT save "exclusiveTopics" to the file to avoid validation errors.
// The UI will have to populate the field by checking if a group has topics configured.
// For now, let's just apply the logic to the groups logic.
if let Some(groups_map) = groups_json.as_mut().and_then(|g| g.as_object_mut()) {
for (_, group_val) in groups_map.iter_mut() {
if let Some(group_obj) = group_val.as_object_mut() {
// Enforce whitelist logic:
// 1. Group requires mention (mute general)
group_obj.insert("requireMention".to_string(), json!(true));
group_obj.insert("enabled".to_string(), json!(true));
// 2. Allow specific topics
let mut topics_map = serde_json::Map::new();
for topic_id in exclusive_topics {
let mut topic_config = serde_json::Map::new();
topic_config.insert("requireMention".to_string(), json!(false));
topics_map.insert(topic_id.clone(), json!(topic_config));
}
// 3. Explicitly block topics owned by OTHER bot accounts
// This prevents cross-talk when OpenClaw core doesn't
// fall back to group-level requireMention for unlisted topics.
if let Some(all_accts) = config.pointer("/channels/telegram/accounts").and_then(|v| v.as_object()) {
for (other_id, other_val) in all_accts {
if other_id == &account.id { continue; }
if let Some(other_groups) = other_val.get("groups").and_then(|g| g.as_object()) {
for (_, other_group) in other_groups {
if let Some(other_topics) = other_group.get("topics").and_then(|t| t.as_object()) {
for (other_tid, _) in other_topics {
if !exclusive_topics.contains(other_tid) && !topics_map.contains_key(other_tid) {
let mut block_config = serde_json::Map::new();
block_config.insert("requireMention".to_string(), json!(true));
topics_map.insert(other_tid.clone(), json!(block_config));
}
}
}
}
}
}
}
group_obj.insert("topics".to_string(), json!(topics_map));
}
}
}
}
}
if let Some(g) = groups_json {
acct_obj["groups"] = g;
}
// NOTE: We do NOT save "exclusiveTopics" field to avoid schema validation errors in OpenClaw core.
// The UI state for this field might be lost on restart unless we infer it back from the topics structure,
// but the *behavior* will be correct.
config["channels"]["telegram"]["accounts"][&account.id] = acct_obj;
// Ensure telegram is enabled and in plugins
config["channels"]["telegram"]["enabled"] = json!(true);
if config.get("plugins").is_none() {
config["plugins"] = json!({ "allow": ["telegram"], "entries": { "telegram": { "enabled": true } } });
}
save_openclaw_config(&config)?;
Ok(format!("Account '{}' saved", account.id))
}
/// Delete a Telegram bot account
#[command]
pub async fn delete_telegram_account(account_id: String) -> Result<String, String> {
info!("[Telegram Accounts] Deleting account: {}", account_id);
let mut config = load_openclaw_config()?;
if let Some(accts) = config.pointer_mut("/channels/telegram/accounts").and_then(|v| v.as_object_mut()) {
accts.remove(&account_id);
}
// Also clean up any bindings referencing this account
if let Some(bindings) = config.get_mut("bindings").and_then(|v| v.as_array_mut()) {
bindings.retain(|b| b.pointer("/match/accountId").and_then(|v| v.as_str()) != Some(&account_id));
}
save_openclaw_config(&config)?;
Ok(format!("Account '{}' deleted", account_id))
}
// ============ Feishu Plugin Management ============
/// Feishu plugin status
@@ -1683,6 +1924,14 @@ pub async fn install_feishu_plugin() -> Result<String, String> {
}
}
// ============ OpenClaw Home Directory ============
/// Get the OpenClaw home directory path (~/.openclaw)
#[command]
pub async fn get_openclaw_home_dir() -> Result<String, String> {
Ok(platform::get_config_dir())
}
// ============ Multi-Agent Routing ============
/// Agent configuration for the frontend
@@ -1690,24 +1939,26 @@ pub async fn install_feishu_plugin() -> Result<String, String> {
pub struct AgentInfo {
pub id: String,
pub workspace: Option<String>,
#[serde(rename = "agentDir")]
#[serde(alias = "agentDir", alias = "agent_dir")]
pub agent_dir: Option<String>,
pub model: Option<String>,
pub sandbox: Option<bool>,
pub heartbeat: Option<String>,
}
/// Agent binding rule
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentBinding {
#[serde(rename = "agentId")]
#[serde(alias = "agentId", alias = "agent_id")]
pub agent_id: String,
#[serde(alias = "matchRule", alias = "match_rule")]
pub match_rule: MatchRule,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatchRule {
pub channel: Option<String>,
#[serde(rename = "accountId")]
#[serde(alias = "accountId", alias = "account_id")]
pub account_id: Option<String>,
pub peer: Option<serde_json::Value>,
}
@@ -1728,21 +1979,38 @@ pub async fn get_agents_config() -> Result<AgentsConfigResponse, String> {
let mut agents = Vec::new();
let mut bindings = Vec::new();
// Read agents.list
if let Some(list) = config.pointer("/agents/list").and_then(|v| v.as_object()) {
for (id, agent_val) in list {
// Read agents.list — supports both array format (correct) and object format (legacy)
if let Some(list_arr) = config.pointer("/agents/list").and_then(|v| v.as_array()) {
// Correct format: array of { id, workspace, agentDir, model, ... }
for agent_val in list_arr {
agents.push(AgentInfo {
id: agent_val.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(),
workspace: agent_val.get("workspace").and_then(|v| v.as_str()).map(|s| s.to_string()),
agent_dir: agent_val.get("agentDir").and_then(|v| v.as_str()).map(|s| s.to_string()),
model: agent_val.pointer("/model/primary").and_then(|v| v.as_str()).map(|s| s.to_string()),
sandbox: agent_val.get("sandbox").and_then(|v| v.as_bool()),
heartbeat: agent_val.pointer("/heartbeat/every").and_then(|v| v.as_str()).map(|s| s.to_string()),
});
}
} else if let Some(list_obj) = config.pointer("/agents/list").and_then(|v| v.as_object()) {
// Legacy format: object with id as keys
for (id, agent_val) in list_obj {
agents.push(AgentInfo {
id: id.clone(),
workspace: agent_val.get("workspace").and_then(|v| v.as_str()).map(|s| s.to_string()),
agent_dir: agent_val.get("agentDir").and_then(|v| v.as_str()).map(|s| s.to_string()),
model: agent_val.pointer("/model/primary").and_then(|v| v.as_str()).map(|s| s.to_string()),
sandbox: agent_val.get("sandbox").and_then(|v| v.as_bool()),
heartbeat: agent_val.pointer("/heartbeat/every").and_then(|v| v.as_str()).map(|s| s.to_string()),
});
}
}
// Read bindings
if let Some(bindings_arr) = config.pointer("/agents/bindings").and_then(|v| v.as_array()) {
// Read bindings — check top-level first (correct), then agents.bindings (legacy)
let bindings_arr = config.get("bindings").and_then(|v| v.as_array())
.or_else(|| config.pointer("/agents/bindings").and_then(|v| v.as_array()));
if let Some(bindings_arr) = bindings_arr {
for binding_val in bindings_arr {
let empty_match = json!({});
let match_obj = binding_val.get("match").unwrap_or(&empty_match);
@@ -1768,15 +2036,13 @@ pub async fn save_agent(agent: AgentInfo) -> Result<String, String> {
info!("[Agents] Saving agent: {}", agent.id);
let mut config = load_openclaw_config()?;
// Ensure agents.list exists
if config.pointer("/agents/list").is_none() {
if config.get("agents").is_none() {
config["agents"] = json!({});
}
config["agents"]["list"] = json!({});
// Ensure agents object exists
if config.get("agents").is_none() {
config["agents"] = json!({});
}
let mut agent_obj = json!({});
// Build agent object (array element format with "id" field)
let mut agent_obj = json!({ "id": agent.id });
if let Some(workspace) = &agent.workspace {
if !workspace.is_empty() {
agent_obj["workspace"] = json!(workspace);
@@ -1795,8 +2061,83 @@ pub async fn save_agent(agent: AgentInfo) -> Result<String, String> {
if let Some(sandbox) = agent.sandbox {
agent_obj["sandbox"] = json!(sandbox);
}
if let Some(heartbeat) = &agent.heartbeat {
if !heartbeat.is_empty() {
agent_obj["heartbeat"] = json!({ "every": heartbeat });
}
}
// Migrate legacy object format to array if needed
let mut list = if let Some(arr) = config["agents"].get("list").and_then(|v| v.as_array()) {
arr.clone()
} else if let Some(obj) = config["agents"].get("list").and_then(|v| v.as_object()) {
// Convert legacy object to array
obj.iter().map(|(id, val)| {
let mut entry = val.clone();
entry["id"] = json!(id);
entry
}).collect()
} else {
Vec::new()
};
// Update or add the agent
if let Some(existing) = list.iter_mut().find(|a| a.get("id").and_then(|v| v.as_str()) == Some(&agent.id)) {
*existing = agent_obj;
} else {
list.push(agent_obj);
}
config["agents"]["list"] = json!(list);
// Auto-create binding if a Telegram bot account is available and this agent has no binding yet
let agent_id = agent.id.clone();
let available_accounts: Vec<String> = config.pointer("/channels/telegram/accounts")
.and_then(|v| v.as_object())
.map(|accts| accts.keys().cloned().collect())
.unwrap_or_default();
if !available_accounts.is_empty() {
// Check if this agent already has ANY binding
let has_existing_binding = config.get("bindings")
.and_then(|v| v.as_array())
.map(|bindings| bindings.iter().any(|b| {
b.get("agentId").and_then(|v| v.as_str()) == Some(&agent_id)
}))
.unwrap_or(false);
if !has_existing_binding {
// Find accounts already bound to other agents
let bound_accounts: Vec<String> = config.get("bindings")
.and_then(|v| v.as_array())
.map(|bindings| bindings.iter().filter_map(|b| {
b.get("match").and_then(|m| m.get("accountId")).and_then(|v| v.as_str()).map(|s| s.to_string())
}).collect())
.unwrap_or_default();
// Prefer: exact match > substring match > first unbound account > first account
let best_account = available_accounts.iter()
.find(|a| **a == agent_id) // exact match
.or_else(|| available_accounts.iter().find(|a| a.contains(&agent_id) || agent_id.contains(a.as_str()))) // substring
.or_else(|| available_accounts.iter().find(|a| !bound_accounts.contains(a))) // unbound
.or_else(|| available_accounts.first()) // fallback
.cloned();
if let Some(account_id) = best_account {
info!("[Agents] Auto-creating binding for agent '{}' → account '{}'", agent_id, account_id);
if config.get("bindings").is_none() {
config["bindings"] = json!([]);
}
if let Some(bindings) = config.get_mut("bindings").and_then(|v| v.as_array_mut()) {
bindings.push(json!({
"agentId": agent_id,
"match": { "channel": "telegram", "accountId": account_id }
}));
}
}
}
}
config["agents"]["list"][&agent.id] = agent_obj;
save_openclaw_config(&config)?;
Ok(format!("Agent '{}' saved", agent.id))
}
@@ -1807,12 +2148,16 @@ pub async fn delete_agent(agent_id: String) -> Result<String, String> {
info!("[Agents] Deleting agent: {}", agent_id);
let mut config = load_openclaw_config()?;
// Remove from agents.list
if let Some(list) = config.pointer_mut("/agents/list").and_then(|v| v.as_object_mut()) {
list.remove(&agent_id);
// Remove from agents.list (array format)
if let Some(list) = config.pointer_mut("/agents/list").and_then(|v| v.as_array_mut()) {
list.retain(|a| a.get("id").and_then(|v| v.as_str()) != Some(&agent_id));
}
// Remove related bindings
// Remove related bindings (top-level)
if let Some(bindings) = config.get_mut("bindings").and_then(|v| v.as_array_mut()) {
bindings.retain(|b| b.get("agentId").and_then(|v| v.as_str()) != Some(&agent_id));
}
// Also clean legacy agents.bindings
if let Some(bindings) = config.pointer_mut("/agents/bindings").and_then(|v| v.as_array_mut()) {
bindings.retain(|b| b.get("agentId").and_then(|v| v.as_str()) != Some(&agent_id));
}
@@ -1828,12 +2173,22 @@ pub async fn save_agent_binding(binding: AgentBinding) -> Result<String, String>
info!("[Agents] Saving binding for agent: {}", binding.agent_id);
let mut config = load_openclaw_config()?;
// Ensure agents.bindings array exists
if config.get("agents").is_none() {
config["agents"] = json!({});
// Ensure top-level bindings array exists
if config.get("bindings").is_none() {
config["bindings"] = json!([]);
}
if config["agents"].get("bindings").is_none() {
config["agents"]["bindings"] = json!([]);
// Migrate legacy agents.bindings to top-level if present
if let Some(legacy) = config.pointer("/agents/bindings").and_then(|v| v.as_array()).map(|a| a.clone()) {
if let Some(top) = config.get_mut("bindings").and_then(|v| v.as_array_mut()) {
for b in legacy {
top.push(b);
}
}
// Remove legacy location
if let Some(agents) = config.get_mut("agents").and_then(|v| v.as_object_mut()) {
agents.remove("bindings");
}
}
let mut match_obj = json!({});
@@ -1852,7 +2207,7 @@ pub async fn save_agent_binding(binding: AgentBinding) -> Result<String, String>
"match": match_obj
});
if let Some(bindings) = config.pointer_mut("/agents/bindings").and_then(|v| v.as_array_mut()) {
if let Some(bindings) = config.get_mut("bindings").and_then(|v| v.as_array_mut()) {
bindings.push(binding_obj);
}
@@ -1866,18 +2221,124 @@ pub async fn delete_agent_binding(index: usize) -> Result<String, String> {
info!("[Agents] Deleting binding at index: {}", index);
let mut config = load_openclaw_config()?;
if let Some(bindings) = config.pointer_mut("/agents/bindings").and_then(|v| v.as_array_mut()) {
// Try top-level bindings first (correct location)
if let Some(bindings) = config.get_mut("bindings").and_then(|v| v.as_array_mut()) {
if index < bindings.len() {
bindings.remove(index);
save_openclaw_config(&config)?;
return Ok(format!("Binding at index {} deleted", index));
} else {
return Err(format!("Binding index {} out of range", index));
}
} else {
return Err("No bindings found".to_string());
}
save_openclaw_config(&config)?;
Ok(format!("Binding at index {} deleted", index))
// Fallback to legacy agents.bindings
if let Some(bindings) = config.pointer_mut("/agents/bindings").and_then(|v| v.as_array_mut()) {
if index < bindings.len() {
bindings.remove(index);
save_openclaw_config(&config)?;
return Ok(format!("Binding at index {} deleted", index));
} else {
return Err(format!("Binding index {} out of range", index));
}
}
Err("No bindings found".to_string())
}
// ============ Agent System Prompt ============
/// Read the system prompt (SYSTEM.md) for an agent
#[command]
pub async fn get_agent_system_prompt(agent_id: String, workspace: Option<String>) -> Result<String, String> {
let base = workspace.unwrap_or_else(|| platform::get_config_dir());
let sep = if cfg!(windows) { "\\" } else { "/" };
let path = format!("{}{}agents{}{}{}SYSTEM.md", base, sep, sep, agent_id, sep);
info!("[Agents] Reading system prompt from: {}", path);
if std::path::Path::new(&path).exists() {
std::fs::read_to_string(&path)
.map_err(|e| format!("Failed to read system prompt: {}", e))
} else {
Ok(String::new())
}
}
/// Save the system prompt (SYSTEM.md) for an agent
#[command]
pub async fn save_agent_system_prompt(agent_id: String, workspace: Option<String>, content: String) -> Result<String, String> {
let base = workspace.unwrap_or_else(|| platform::get_config_dir());
let sep = if cfg!(windows) { "\\" } else { "/" };
let dir = format!("{}{}agents{}{}", base, sep, sep, agent_id);
let path = format!("{}{}SYSTEM.md", dir, sep);
info!("[Agents] Writing system prompt to: {}", path);
// Create directory if it doesn't exist
std::fs::create_dir_all(&dir)
.map_err(|e| format!("Failed to create agent directory: {}", e))?;
std::fs::write(&path, &content)
.map_err(|e| format!("Failed to write system prompt: {}", e))?;
Ok(format!("System prompt saved for agent '{}'", agent_id))
}
/// Test agent routing: given an account ID, find which agent handles it
#[command]
pub async fn test_agent_routing(account_id: String) -> Result<serde_json::Value, String> {
let config = load_openclaw_config()?;
// Walk through bindings to find a match
let bindings = config.get("bindings").and_then(|v| v.as_array());
if let Some(bindings) = bindings {
let empty_match = json!({});
for binding in bindings {
let match_obj = binding.get("match").unwrap_or(&empty_match);
let binding_account = match_obj.get("accountId").and_then(|v| v.as_str());
let binding_channel = match_obj.get("channel").and_then(|v| v.as_str());
// Check if this binding matches
let account_matches = binding_account.map(|a| a == account_id).unwrap_or(true); // None = catch-all
let channel_matches = binding_channel.map(|c| c == "telegram").unwrap_or(true);
if account_matches && channel_matches {
let agent_id = binding.get("agentId").and_then(|v| v.as_str()).unwrap_or("unknown");
// Find agent details
let agent_info = config.pointer("/agents/list")
.and_then(|v| v.as_array())
.and_then(|list| list.iter().find(|a| a.get("id").and_then(|v| v.as_str()) == Some(agent_id)));
// Read system prompt preview
let base = platform::get_config_dir();
let sep = if cfg!(windows) { "\\" } else { "/" };
let prompt_path = format!("{}{}agents{}{}{}SYSTEM.md", base, sep, sep, agent_id, sep);
let prompt_preview = std::fs::read_to_string(&prompt_path)
.unwrap_or_default();
let prompt_preview = if prompt_preview.len() > 200 {
format!("{}...", &prompt_preview[..200])
} else {
prompt_preview
};
return Ok(json!({
"matched": true,
"agent_id": agent_id,
"agent_dir": agent_info.and_then(|a| a.get("agentDir").and_then(|v| v.as_str())),
"model": agent_info.and_then(|a| a.pointer("/model/primary").and_then(|v| v.as_str())),
"system_prompt_preview": prompt_preview,
"binding": binding
}));
}
}
}
Ok(json!({
"matched": false,
"agent_id": "default",
"message": "No specific binding found. Messages will be handled by the default agent."
}))
}
// ============ Heartbeat & Compaction ============
+49 -9
View File
@@ -197,19 +197,59 @@ pub async fn stop_service() -> Result<String, String> {
pub async fn restart_service() -> Result<String, String> {
info!("[Service] Restarting service...");
let _ = shell::run_openclaw(&["gateway", "restart"]);
std::thread::sleep(std::time::Duration::from_secs(2));
// Step 1: Stop the service if it's running
let status = get_service_status().await?;
if status.running {
info!("[Service] Successfully restarted, PID: {:?}", status.pid);
Ok(format!("Service restarted, PID: {:?}", status.pid))
info!("[Service] Service is running, stopping first...");
let _ = shell::run_openclaw(&["gateway", "stop"]);
std::thread::sleep(std::time::Duration::from_millis(500));
// Check if stopped
let status = get_service_status().await?;
if status.running {
info!("[Service] Service still running, trying force stop...");
let _ = shell::run_openclaw(&["gateway", "stop", "--force"]);
std::thread::sleep(std::time::Duration::from_millis(500));
}
// Wait for port to be freed (max 5 seconds)
for i in 1..=10 {
if check_port_listening(SERVICE_PORT).is_none() {
info!("[Service] Port {} freed after {}ms", SERVICE_PORT, i * 500);
break;
}
if i == 10 {
return Err(format!(
"Failed to stop service: port {} still in use after 5s",
SERVICE_PORT
));
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
} else {
// Manually stop then start
let _ = stop_service().await;
std::thread::sleep(std::time::Duration::from_secs(1));
start_service().await
info!("[Service] Service was not running");
}
// Step 2: Start the service
info!("[Service] Starting gateway in background...");
shell::spawn_openclaw_gateway()
.map_err(|e| format!("Failed to start service: {}", e))?;
// Step 3: Poll and wait for port to start listening (max 15 seconds)
info!("[Service] Waiting for port {} to start listening...", SERVICE_PORT);
for i in 1..=15 {
std::thread::sleep(std::time::Duration::from_secs(1));
if let Some(pid) = check_port_listening(SERVICE_PORT) {
info!("[Service] Successfully restarted ({}s), PID: {}", i, pid);
return Ok(format!("Service restarted, PID: {}", pid));
}
if i % 3 == 0 {
debug!("[Service] Waiting... ({}s)", i);
}
}
info!("[Service] Restart timeout, port still not listening");
Err("Service restart timeout (15s), please check openclaw logs".to_string())
}
/// Get logs
+8
View File
@@ -94,11 +94,19 @@ fn main() {
skills::uninstall_skill,
skills::uninstall_clawhub,
// Multi-Agent Routing
config::get_openclaw_home_dir,
config::get_agents_config,
config::save_agent,
config::delete_agent,
config::save_agent_binding,
config::delete_agent_binding,
config::get_agent_system_prompt,
config::save_agent_system_prompt,
config::test_agent_routing,
// Telegram Multi-Account
config::get_telegram_accounts,
config::save_telegram_account,
config::delete_telegram_account,
// Heartbeat & Compaction
config::get_heartbeat_config,
config::save_heartbeat_config,
+508 -107
View File
@@ -12,8 +12,15 @@ import {
AlertCircle,
ArrowRight,
MessageSquare,
Hash,
GitMerge
GitMerge,
Copy,
Zap,
CheckCircle2,
ChevronRight,
ChevronDown,
FileText,
Bot,
Sparkles
} from 'lucide-react';
import { appLogger } from '../../lib/logger';
@@ -24,12 +31,13 @@ interface AgentInfo {
agent_dir: string | null;
model: string | null;
sandbox: boolean | null;
heartbeat: string | null;
}
interface MatchRule {
channel: string | null;
account_id: string | null;
peer: any | null; // Supports string (legacy) or object { kind: 'group', id: '...' }
peer: any | null;
}
interface AgentBinding {
@@ -42,17 +50,45 @@ interface AgentsConfigResponse {
bindings: AgentBinding[];
}
interface TelegramAccount {
id: string;
token?: string;
groups?: Record<string, any>;
exclusive_topics?: string[];
}
interface RoutingTestResult {
matched: boolean;
agent_id: string;
agent_dir?: string;
model?: string;
system_prompt_preview?: string;
message?: string;
}
export function Agents() {
const [loading, setLoading] = useState(true);
const [agents, setAgents] = useState<AgentInfo[]>([]);
const [bindings, setBindings] = useState<AgentBinding[]>([]);
const [error, setError] = useState<string | null>(null);
const [openclawHomeDir, setOpenclawHomeDir] = useState<string>('');
const [telegramAccounts, setTelegramAccounts] = useState<TelegramAccount[]>([]);
// Dialog states
const [showAgentDialog, setShowAgentDialog] = useState(false);
const [editingAgent, setEditingAgent] = useState<AgentInfo | null>(null);
const [showBindingDialog, setShowBindingDialog] = useState(false);
const [showWizardDialog, setShowWizardDialog] = useState(false);
const [saving, setSaving] = useState(false);
const [showRoutingFlow, setShowRoutingFlow] = useState(false);
// System prompt state
const [systemPrompt, setSystemPrompt] = useState('');
const [loadingPrompt, setLoadingPrompt] = useState(false);
// Routing test state
const [testResult, setTestResult] = useState<RoutingTestResult | null>(null);
const [testingAccount, setTestingAccount] = useState<string | null>(null);
// Form states
const [agentForm, setAgentForm] = useState<AgentInfo>({
@@ -60,7 +96,8 @@ export function Agents() {
workspace: null,
agent_dir: null,
model: null,
sandbox: null
sandbox: null,
heartbeat: null
});
const [bindingForm, setBindingForm] = useState<AgentBinding>({
@@ -72,9 +109,14 @@ export function Agents() {
}
});
// Peer Type State for Binding Form
const [peerType, setPeerType] = useState<'any' | 'user' | 'group'>('any');
const [peerId, setPeerId] = useState('');
// Wizard form state
const [wizardStep, setWizardStep] = useState(0);
const [wizardForm, setWizardForm] = useState({
botAccountId: '',
agentId: '',
systemPrompt: '',
model: '',
});
const fetchData = async () => {
setLoading(true);
@@ -91,15 +133,50 @@ export function Agents() {
}
};
const fetchAccounts = async () => {
try {
const accts = await invoke<TelegramAccount[]>('get_telegram_accounts');
setTelegramAccounts(accts);
} catch { /* ignore */ }
};
useEffect(() => {
fetchData();
invoke<string>('get_openclaw_home_dir').then(dir => setOpenclawHomeDir(dir)).catch(() => { });
fetchAccounts();
}, []);
// Load system prompt when editing an agent
const loadSystemPrompt = async (agentId: string, workspace: string | null) => {
setLoadingPrompt(true);
try {
const prompt = await invoke<string>('get_agent_system_prompt', {
agentId,
workspace: workspace || null
});
setSystemPrompt(prompt);
} catch {
setSystemPrompt('');
} finally {
setLoadingPrompt(false);
}
};
const handleSaveAgent = async () => {
if (!agentForm.id) return;
setSaving(true);
try {
await invoke('save_agent', { agent: agentForm });
// Save system prompt if provided
if (systemPrompt.trim()) {
await invoke('save_agent_system_prompt', {
agentId: agentForm.id,
workspace: agentForm.workspace || null,
content: systemPrompt
});
}
setShowAgentDialog(false);
fetchData();
} catch (e) {
@@ -143,6 +220,73 @@ export function Agents() {
}
};
// Clone agent handler
const handleCloneAgent = (agent: AgentInfo) => {
setEditingAgent(null);
setAgentForm({
id: `${agent.id}_copy`,
workspace: agent.workspace,
agent_dir: agent.agent_dir ? `${agent.agent_dir}_copy` : null,
model: agent.model,
sandbox: agent.sandbox,
heartbeat: agent.heartbeat
});
setSystemPrompt('');
// Load original system prompt for cloning
loadSystemPrompt(agent.id, agent.workspace);
setShowAgentDialog(true);
};
// Test routing handler
const handleTestRouting = async (accountId: string) => {
setTestingAccount(accountId);
try {
const result = await invoke<RoutingTestResult>('test_agent_routing', { accountId });
setTestResult(result);
} catch (e) {
setError(String(e));
} finally {
setTestingAccount(null);
}
};
// Wizard submit handler
const handleWizardSubmit = async () => {
setSaving(true);
try {
// 1. Save agent
const agent: AgentInfo = {
id: wizardForm.agentId,
workspace: openclawHomeDir || null,
agent_dir: `agents/${wizardForm.agentId}`,
model: wizardForm.model || null,
sandbox: null,
heartbeat: null
};
await invoke('save_agent', { agent });
// 2. Save system prompt if provided
if (wizardForm.systemPrompt.trim()) {
await invoke('save_agent_system_prompt', {
agentId: wizardForm.agentId,
workspace: openclawHomeDir || null,
content: wizardForm.systemPrompt
});
}
// Note: save_agent auto-creates binding if matching account exists
setShowWizardDialog(false);
setWizardStep(0);
setWizardForm({ botAccountId: '', agentId: '', systemPrompt: '', model: '' });
fetchData();
} catch (e) {
setError(String(e));
} finally {
setSaving(false);
}
};
if (loading && !agents.length) {
return (
<div className="flex items-center justify-center h-full">
@@ -154,6 +298,64 @@ export function Agents() {
return (
<div className="h-full overflow-y-auto scroll-container pr-2 space-y-8">
{/* Visual Routing Diagram */}
{bindings.length > 0 && (
<section>
<button
onClick={() => setShowRoutingFlow(!showRoutingFlow)}
className="w-full flex items-center justify-between mb-4 group cursor-pointer"
>
<h2 className="text-xl font-semibold text-white flex items-center gap-2">
<Sparkles className="text-amber-400" size={24} />
Routing Flow
</h2>
<div className="flex items-center gap-2 text-gray-500 group-hover:text-gray-300 transition-colors">
<span className="text-xs">{showRoutingFlow ? 'Hide' : 'Show'}</span>
<ChevronDown size={16} className={`transition-transform ${showRoutingFlow ? 'rotate-180' : ''}`} />
</div>
</button>
<AnimatePresence>
{showRoutingFlow && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="bg-dark-700 rounded-xl border border-dark-600 p-5 overflow-x-auto">
<div className="flex flex-col gap-3">
{bindings.map((binding, idx) => {
const agent = agents.find(a => a.id === binding.agent_id);
return (
<div key={idx} className="flex items-center gap-0 text-sm">
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-blue-500/10 border border-blue-500/30 text-blue-300 min-w-[120px]">
<Bot size={14} />
<span className="font-medium">{binding.match_rule?.account_id || 'Any'}</span>
</div>
<ChevronRight size={16} className="text-gray-600 mx-1 flex-shrink-0" />
<div className="px-3 py-2 rounded-lg bg-dark-600 border border-dark-500 text-gray-400 text-xs">
{binding.match_rule?.channel || 'any'}
</div>
<ChevronRight size={16} className="text-gray-600 mx-1 flex-shrink-0" />
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-claw-500/10 border border-claw-500/30 text-claw-300 min-w-[120px]">
<Users size={14} />
<span className="font-medium">{binding.agent_id}</span>
</div>
<ChevronRight size={16} className="text-gray-600 mx-1 flex-shrink-0" />
<div className="px-3 py-2 rounded-lg bg-purple-500/10 border border-purple-500/30 text-purple-300 text-xs max-w-[200px] truncate">
{agent?.model || 'Default Model'}
</div>
</div>
);
})}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</section>
)}
{/* Agents Section */}
<section>
<div className="flex items-center justify-between mb-4">
@@ -164,17 +366,36 @@ export function Agents() {
</h2>
<p className="text-sm text-gray-500">Manage agent definitions and overrides</p>
</div>
<button
onClick={() => {
setEditingAgent(null);
setAgentForm({ id: '', workspace: null, agent_dir: null, model: null, sandbox: null });
setShowAgentDialog(true);
}}
className="btn-primary flex items-center gap-2"
>
<Plus size={16} />
Add Agent
</button>
<div className="flex gap-2">
<button
onClick={() => {
setWizardStep(0);
setWizardForm({
botAccountId: telegramAccounts[0]?.id || '',
agentId: '',
systemPrompt: '',
model: ''
});
setShowWizardDialog(true);
}}
className="btn-secondary flex items-center gap-2"
>
<Zap size={16} />
Quick Setup
</button>
<button
onClick={() => {
setEditingAgent(null);
setAgentForm({ id: '', workspace: openclawHomeDir || null, agent_dir: null, model: null, sandbox: null, heartbeat: null });
setSystemPrompt('');
setShowAgentDialog(true);
}}
className="btn-primary flex items-center gap-2"
>
<Plus size={16} />
Add Agent
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@@ -195,20 +416,31 @@ export function Agents() {
{agent.sandbox && <span className="text-xs text-amber-400 bg-amber-500/10 px-1.5 rounded">Sandbox</span>}
</div>
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-2">
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
<button
onClick={() => handleCloneAgent(agent)}
className="p-1.5 hover:bg-dark-600 rounded text-gray-400 hover:text-blue-400"
title="Clone Agent"
>
<Copy size={14} />
</button>
<button
onClick={() => {
setEditingAgent(agent);
setAgentForm(agent);
setSystemPrompt('');
loadSystemPrompt(agent.id, agent.workspace);
setShowAgentDialog(true);
}}
className="p-1.5 hover:bg-dark-600 rounded text-gray-400 hover:text-white"
title="Edit Agent"
>
<Pencil size={14} />
</button>
<button
onClick={() => handleDeleteAgent(agent.id)}
className="p-1.5 hover:bg-dark-600 rounded text-gray-400 hover:text-red-400"
title="Delete Agent"
>
<Trash2 size={14} />
</button>
@@ -223,12 +455,6 @@ export function Agents() {
</div>
</div>
)}
{agent.workspace && (
<div className="flex items-center gap-2" title="Workspace Override">
<Hash size={14} />
<span className="truncate">{agent.workspace}</span>
</div>
)}
{agent.model && (
<div className="flex items-center gap-2" title="Model Override">
<MessageSquare size={14} />
@@ -256,10 +482,8 @@ export function Agents() {
onClick={() => {
setBindingForm({
agent_id: agents[0]?.id || '',
match_rule: { channel: null, account_id: null, peer: null }
match_rule: { channel: 'telegram', account_id: telegramAccounts[0]?.id || null, peer: null }
});
setPeerType('any');
setPeerId('');
setShowBindingDialog(true);
}}
disabled={agents.length === 0}
@@ -301,13 +525,6 @@ export function Agents() {
Account: {binding.match_rule.account_id}
</span>
)}
{binding.match_rule?.peer && (
<span className="px-2 py-1 rounded bg-amber-500/20 text-amber-300 text-xs border border-amber-500/30">
{typeof binding.match_rule.peer === 'object' && binding.match_rule.peer.kind === 'group'
? `Group: ${binding.match_rule.peer.id}`
: `Peer: ${binding.match_rule.peer}`}
</span>
)}
{!binding.match_rule?.channel && !binding.match_rule?.account_id && !binding.match_rule?.peer && (
<span className="text-gray-500 italic">Catch-all</span>
)}
@@ -320,12 +537,26 @@ export function Agents() {
</div>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => handleDeleteBinding(idx)}
className="p-1.5 hover:bg-dark-500 rounded text-gray-400 hover:text-red-400 transition-colors"
>
<Trash2 size={14} />
</button>
<div className="flex items-center justify-end gap-1">
<button
onClick={() => handleTestRouting(binding.match_rule?.account_id || binding.agent_id)}
disabled={testingAccount === (binding.match_rule?.account_id || binding.agent_id)}
className="p-1.5 hover:bg-dark-500 rounded text-gray-400 hover:text-green-400 transition-colors"
title="Test Routing"
>
{testingAccount === (binding.match_rule?.account_id || binding.agent_id)
? <Loader2 className="animate-spin" size={14} />
: <Zap size={14} />
}
</button>
<button
onClick={() => handleDeleteBinding(idx)}
className="p-1.5 hover:bg-dark-500 rounded text-gray-400 hover:text-red-400 transition-colors"
title="Delete Rule"
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))
@@ -335,6 +566,54 @@ export function Agents() {
</div>
</section>
{/* Test Routing Result */}
<AnimatePresence>
{testResult && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="fixed bottom-4 right-4 z-40 max-w-md"
>
<div className={`rounded-xl border shadow-2xl p-4 ${testResult.matched ? 'bg-dark-800 border-green-500/30' : 'bg-dark-800 border-amber-500/30'}`}>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
{testResult.matched
? <CheckCircle2 size={18} className="text-green-400" />
: <AlertCircle size={18} className="text-amber-400" />
}
<span className="text-sm font-semibold text-white">Routing Test Result</span>
</div>
<button onClick={() => setTestResult(null)} className="text-gray-500 hover:text-white"><X size={14} /></button>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-gray-400">Agent:</span>
<span className="text-white font-medium">{testResult.agent_id}</span>
</div>
{testResult.model && (
<div className="flex items-center gap-2">
<span className="text-gray-400">Model:</span>
<span className="text-purple-300">{testResult.model}</span>
</div>
)}
{testResult.system_prompt_preview && (
<div>
<span className="text-gray-400 text-xs">System Prompt:</span>
<div className="mt-1 p-2 bg-dark-700 rounded text-xs text-gray-300 font-mono max-h-24 overflow-auto">
{testResult.system_prompt_preview}
</div>
</div>
)}
{testResult.message && (
<p className="text-amber-300 text-xs">{testResult.message}</p>
)}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Agent Dialog */}
<AnimatePresence>
{showAgentDialog && (
@@ -343,17 +622,17 @@ export function Agents() {
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="bg-dark-800 rounded-xl border border-dark-600 w-full max-w-md overflow-hidden"
className="bg-dark-800 rounded-xl border border-dark-600 w-full max-w-lg overflow-hidden max-h-[90vh] flex flex-col"
onClick={e => e.stopPropagation()}
>
<div className="px-6 py-4 border-b border-dark-600 flex justify-between items-center">
<div className="px-6 py-4 border-b border-dark-600 flex justify-between items-center flex-shrink-0">
<h3 className="text-lg font-semibold text-white">
{editingAgent ? 'Edit Agent' : 'Add New Agent'}
</h3>
<button onClick={() => setShowAgentDialog(false)} className="text-gray-500 hover:text-white"><X size={20} /></button>
</div>
<div className="p-6 space-y-4">
<div className="p-6 space-y-4 overflow-y-auto">
<div>
<label className="block text-sm text-gray-400 mb-1">Agent ID *</label>
<input
@@ -372,8 +651,9 @@ export function Agents() {
value={agentForm.workspace || ''}
onChange={e => setAgentForm({ ...agentForm, workspace: e.target.value || null })}
className="input-base"
placeholder="/path/to/workspace"
placeholder={openclawHomeDir || '/path/to/workspace'}
/>
<p className="text-xs text-gray-500 mt-1">Default: <code className="text-gray-400">{openclawHomeDir || '~/.openclaw'}</code></p>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Agent Directory (Optional)</label>
@@ -382,8 +662,9 @@ export function Agents() {
value={agentForm.agent_dir || ''}
onChange={e => setAgentForm({ ...agentForm, agent_dir: e.target.value || null })}
className="input-base"
placeholder="e.g. agents/investing"
placeholder="e.g. agents/coder"
/>
<p className="text-xs text-gray-500 mt-1">Subdirectory for agent-specific files. Relative to workspace.</p>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Model Override (Optional)</label>
@@ -392,9 +673,32 @@ export function Agents() {
value={agentForm.model || ''}
onChange={e => setAgentForm({ ...agentForm, model: e.target.value || null })}
className="input-base"
placeholder="provider/model-id"
placeholder="e.g. glm/glm-5"
/>
</div>
{/* System Prompt Editor */}
<div>
<label className="block text-sm text-gray-400 mb-1 flex items-center gap-2">
<FileText size={14} />
System Prompt (SYSTEM.md)
</label>
{loadingPrompt ? (
<div className="flex items-center gap-2 text-gray-500 text-sm py-4">
<Loader2 className="animate-spin" size={14} /> Loading...
</div>
) : (
<textarea
value={systemPrompt}
onChange={e => setSystemPrompt(e.target.value)}
className="input-base font-mono text-sm"
rows={6}
placeholder="You are a coding expert specializing in..."
/>
)}
<p className="text-xs text-gray-500 mt-1">Defines the agent's personality. Saved to <code className="text-gray-400">agents/{agentForm.id || '...'}/SYSTEM.md</code></p>
</div>
<div className="flex items-center gap-2 pt-2">
<input
type="checkbox"
@@ -405,9 +709,19 @@ export function Agents() {
/>
<label htmlFor="sandbox" className="text-sm text-gray-300 select-none">Enable Sandbox</label>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Heartbeat Interval (Optional)</label>
<input
type="text"
value={agentForm.heartbeat || ''}
onChange={e => setAgentForm({ ...agentForm, heartbeat: e.target.value || null })}
className="input-base"
placeholder='e.g. "1h" or "0m" to disable'
/>
</div>
</div>
<div className="px-6 py-4 border-t border-dark-600 flex justify-end gap-3">
<div className="px-6 py-4 border-t border-dark-600 flex justify-end gap-3 flex-shrink-0">
<button onClick={() => setShowAgentDialog(false)} className="btn-secondary">Cancel</button>
<button
onClick={handleSaveAgent}
@@ -452,77 +766,33 @@ export function Agents() {
</div>
<div className="pt-2 border-t border-dark-600">
<p className="text-xs text-gray-500 mb-3 uppercase font-semibold">Match Criteria (Leave empty to ignore)</p>
<p className="text-xs text-gray-500 mb-3 uppercase font-semibold">Match Criteria</p>
<div className="space-y-3">
<div>
<label className="block text-sm text-gray-400 mb-1">Channel (e.g. whatsapp)</label>
<label className="block text-sm text-gray-400 mb-1">Channel</label>
<input
type="text"
value={bindingForm.match_rule.channel || ''}
onChange={e => setBindingForm({
...bindingForm,
match_rule: { ...bindingForm.match_rule, channel: e.target.value || null }
})}
className="input-base"
placeholder="Any channel"
value={bindingForm.match_rule.channel || 'telegram'}
readOnly
className="input-base bg-dark-700 text-gray-400 cursor-not-allowed"
/>
<p className="text-xs text-gray-500 mt-1">Currently only Telegram supports multi-agent routing.</p>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Account ID (e.g. email or phone)</label>
<input
type="text"
<label className="block text-sm text-gray-400 mb-1">Bot Account *</label>
<select
value={bindingForm.match_rule.account_id || ''}
onChange={e => setBindingForm({
...bindingForm,
match_rule: { ...bindingForm.match_rule, account_id: e.target.value || null }
})}
className="input-base"
placeholder="Any account"
/>
</div>
{/* Peer Type Selection */}
<div className="space-y-2">
<label className="block text-sm text-gray-400">Peer Match</label>
<div className="flex gap-2 mb-2">
<button
onClick={() => { setPeerType('any'); setBindingForm({ ...bindingForm, match_rule: { ...bindingForm.match_rule, peer: null } }); }}
className={`px-3 py-1.5 text-xs rounded border ${peerType === 'any' ? 'bg-claw-500/20 border-claw-500 text-claw-400' : 'bg-dark-600 border-dark-500 text-gray-400'}`}
>
Any
</button>
<button
onClick={() => setPeerType('user')}
className={`px-3 py-1.5 text-xs rounded border ${peerType === 'user' ? 'bg-claw-500/20 border-claw-500 text-claw-400' : 'bg-dark-600 border-dark-500 text-gray-400'}`}
>
User ID
</button>
<button
onClick={() => setPeerType('group')}
className={`px-3 py-1.5 text-xs rounded border ${peerType === 'group' ? 'bg-claw-500/20 border-claw-500 text-claw-400' : 'bg-dark-600 border-dark-500 text-gray-400'}`}
>
Group ID
</button>
</div>
{peerType !== 'any' && (
<input
type="text"
value={peerId}
onChange={e => {
const val = e.target.value;
setPeerId(val);
if (peerType === 'user') {
setBindingForm({ ...bindingForm, match_rule: { ...bindingForm.match_rule, peer: val || null } });
} else {
setBindingForm({ ...bindingForm, match_rule: { ...bindingForm.match_rule, peer: val ? { kind: 'group', id: val } : null } });
}
}}
className="input-base"
placeholder={peerType === 'group' ? "e.g. -100123456789" : "e.g. user_123"}
/>
)}
>
<option value="">Select a bot account...</option>
{telegramAccounts.map(acct => (
<option key={acct.id} value={acct.id}>{acct.id}</option>
))}
</select>
</div>
</div>
</div>
@@ -544,8 +814,139 @@ export function Agents() {
)}
</AnimatePresence>
{/* Quick Setup Wizard Dialog */}
<AnimatePresence>
{showWizardDialog && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={() => setShowWizardDialog(false)}>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="bg-dark-800 rounded-xl border border-dark-600 w-full max-w-lg overflow-hidden"
onClick={e => e.stopPropagation()}
>
<div className="px-6 py-4 border-b border-dark-600 flex justify-between items-center">
<div className="flex items-center gap-3">
<Zap className="text-amber-400" size={20} />
<h3 className="text-lg font-semibold text-white">Quick Agent Setup</h3>
</div>
<button onClick={() => setShowWizardDialog(false)} className="text-gray-500 hover:text-white"><X size={20} /></button>
</div>
{/* Step indicators */}
<div className="px-6 pt-4 flex gap-2">
{['Bot Account', 'Agent Config', 'Personality'].map((label, i) => (
<div key={i} className="flex-1">
<div className={`h-1 rounded-full transition-colors ${i <= wizardStep ? 'bg-claw-500' : 'bg-dark-600'}`} />
<p className={`text-xs mt-1 ${i <= wizardStep ? 'text-claw-400' : 'text-gray-600'}`}>{label}</p>
</div>
))}
</div>
<div className="p-6 space-y-4 min-h-[220px]">
{wizardStep === 0 && (
<div className="space-y-4">
<p className="text-sm text-gray-300">Select the Telegram bot account to link with a new agent.</p>
<div>
<label className="block text-sm text-gray-400 mb-1">Bot Account</label>
<select
value={wizardForm.botAccountId}
onChange={e => {
const val = e.target.value;
setWizardForm({ ...wizardForm, botAccountId: val, agentId: val });
}}
className="input-base"
>
<option value="">Select a bot...</option>
{telegramAccounts.map(acct => (
<option key={acct.id} value={acct.id}>{acct.id}</option>
))}
</select>
</div>
</div>
)}
{wizardStep === 1 && (
<div className="space-y-4">
<p className="text-sm text-gray-300">Configure the agent that will handle messages for <span className="text-claw-400 font-medium">{wizardForm.botAccountId}</span>.</p>
<div>
<label className="block text-sm text-gray-400 mb-1">Agent ID</label>
<input
type="text"
value={wizardForm.agentId}
onChange={e => setWizardForm({ ...wizardForm, agentId: e.target.value })}
className="input-base"
placeholder="e.g. coder"
/>
<p className="text-xs text-gray-500 mt-1">Will be saved to <code className="text-gray-400">agents/{wizardForm.agentId || '...'}/</code></p>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Model Override (Optional)</label>
<input
type="text"
value={wizardForm.model}
onChange={e => setWizardForm({ ...wizardForm, model: e.target.value })}
className="input-base"
placeholder="Leave empty for default model"
/>
</div>
</div>
)}
{wizardStep === 2 && (
<div className="space-y-4">
<p className="text-sm text-gray-300">Define the personality for <span className="text-claw-400 font-medium">{wizardForm.agentId}</span>.</p>
<div>
<label className="block text-sm text-gray-400 mb-1 flex items-center gap-2">
<FileText size={14} />
System Prompt
</label>
<textarea
value={wizardForm.systemPrompt}
onChange={e => setWizardForm({ ...wizardForm, systemPrompt: e.target.value })}
className="input-base font-mono text-sm"
rows={6}
placeholder="You are a coding expert. You help users write clean, efficient code..."
/>
</div>
</div>
)}
</div>
<div className="px-6 py-4 border-t border-dark-600 flex justify-between">
<button
onClick={() => wizardStep === 0 ? setShowWizardDialog(false) : setWizardStep(wizardStep - 1)}
className="btn-secondary"
>
{wizardStep === 0 ? 'Cancel' : 'Back'}
</button>
{wizardStep < 2 ? (
<button
onClick={() => setWizardStep(wizardStep + 1)}
disabled={wizardStep === 0 ? !wizardForm.botAccountId : !wizardForm.agentId}
className="btn-primary flex items-center gap-2"
>
Next
<ChevronRight size={16} />
</button>
) : (
<button
onClick={handleWizardSubmit}
disabled={saving || !wizardForm.agentId}
className="btn-primary flex items-center gap-2"
>
{saving ? <Loader2 className="animate-spin" size={16} /> : <Zap size={16} />}
Create Agent & Binding
</button>
)}
</div>
</motion.div>
</div>
)}
</AnimatePresence>
{error && (
<div className="fixed bottom-4 right-4 bg-red-500 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 animate-in slide-in-from-bottom-2">
<div className="fixed bottom-4 right-4 bg-red-500 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 animate-in slide-in-from-bottom-2 z-50">
<AlertCircle size={18} />
{error}
<button onClick={() => setError(null)} className="ml-2 hover:bg-white/20 p-1 rounded"><X size={14} /></button>
+603 -202
View File
@@ -24,6 +24,8 @@ import {
AlertTriangle,
Trash2,
Plus,
Bot,
Settings,
} from 'lucide-react';
import clsx from 'clsx';
@@ -232,8 +234,33 @@ export function Channels() {
const [clearing, setClearing] = useState(false);
const [showClearConfirm, setShowClearConfirm] = useState(false);
// Per-group settings type
interface GroupSettings {
requireMention: boolean;
enabled: boolean;
groupPolicy: string;
systemPrompt: string;
}
// Telegram multi-account state
interface TelegramAccountInfo {
id: string;
bot_token: string;
group_policy?: string;
dm_policy?: string;
stream_mode?: string;
exclusive_topics?: string[];
groups?: Record<string, unknown>;
}
const [telegramAccounts, setTelegramAccounts] = useState<TelegramAccountInfo[]>([]);
const [showAddAccountDialog, setShowAddAccountDialog] = useState(false);
const [newAccountId, setNewAccountId] = useState('');
const [newAccountToken, setNewAccountToken] = useState('');
const [expandedAccount, setExpandedAccount] = useState<string | null>(null);
const [savingAccount, setSavingAccount] = useState(false);
// OpenClaw channel access control state
const [allowedGroups, setAllowedGroups] = useState<Record<string, boolean>>({}); // { groupId: requireMention }
const [allowedGroups, setAllowedGroups] = useState<Record<string, GroupSettings>>({});
const [allowFromUsers, setAllowFromUsers] = useState<string[]>([]); // allowFrom (DM user IDs)
const [groupAllowFromUsers, setGroupAllowFromUsers] = useState<string[]>([]); // groupAllowFrom (group sender IDs)
const [newGroupInput, setNewGroupInput] = useState('');
@@ -450,11 +477,16 @@ export function Channels() {
});
setConfigForm(form);
// Load groups (object: { groupId: { requireMention: bool } })
const groupsObj = (channel.config.groups as Record<string, { requireMention?: boolean }>) || {};
const groupMap: Record<string, boolean> = {};
// Load groups (object: { groupId: { requireMention, enabled, groupPolicy, systemPrompt } })
const groupsObj = (channel.config.groups as Record<string, Record<string, unknown>>) || {};
const groupMap: Record<string, GroupSettings> = {};
for (const [gid, settings] of Object.entries(groupsObj)) {
groupMap[gid] = settings?.requireMention !== false; // default true
groupMap[gid] = {
requireMention: settings?.requireMention !== false,
enabled: settings?.enabled !== false,
groupPolicy: (settings?.groupPolicy as string) || 'open',
systemPrompt: (settings?.systemPrompt as string) || '',
};
}
setAllowedGroups(groupMap);
@@ -470,11 +502,46 @@ export function Channels() {
if (channel.channel_type === 'feishu') {
checkFeishuPlugin();
}
// If Telegram, fetch accounts
if (channel.channel_type === 'telegram') {
fetchTelegramAccounts();
}
} else {
setConfigForm({});
}
};
const fetchTelegramAccounts = async () => {
try {
const accounts: TelegramAccountInfo[] = await invoke('get_telegram_accounts');
setTelegramAccounts(accounts);
} catch (e) {
console.error('Failed to fetch telegram accounts:', e);
}
};
const handleSaveAccount = async (account: TelegramAccountInfo) => {
setSavingAccount(true);
try {
await invoke('save_telegram_account', { account });
await fetchTelegramAccounts();
} catch (e) {
console.error('Failed to save telegram account:', e);
} finally {
setSavingAccount(false);
}
};
const handleDeleteAccount = async (accountId: string) => {
try {
await invoke('delete_telegram_account', { accountId });
await fetchTelegramAccounts();
} catch (e) {
console.error('Failed to delete telegram account:', e);
}
};
const handleSave = async () => {
if (!selectedChannel) return;
@@ -495,11 +562,21 @@ export function Channels() {
}
});
// Save groups as object: { "groupId": { requireMention: bool } }
// Save groups as object with all per-group settings
if (Object.keys(allowedGroups).length > 0) {
const groupsObj: Record<string, { requireMention: boolean }> = {};
for (const [gid, reqMention] of Object.entries(allowedGroups)) {
groupsObj[gid] = { requireMention: reqMention };
const groupsObj: Record<string, Record<string, unknown>> = {};
for (const [gid, settings] of Object.entries(allowedGroups)) {
const entry: Record<string, unknown> = {
requireMention: settings.requireMention,
enabled: settings.enabled,
};
if (settings.groupPolicy && settings.groupPolicy !== 'open') {
entry.groupPolicy = settings.groupPolicy;
}
if (settings.systemPrompt) {
entry.systemPrompt = settings.systemPrompt;
}
groupsObj[gid] = entry;
}
config['groups'] = groupsObj;
}
@@ -712,167 +789,279 @@ export function Channels() {
</div>
)}
<div className="space-y-4">
{currentInfo.fields.map((field) => (
<div key={field.key}>
<label className="block text-sm text-gray-400 mb-2">
{field.label}
{field.required && <span className="text-red-400 ml-1">*</span>}
{configForm[field.key] && (
<span className="ml-2 text-green-500 text-xs"></span>
)}
</label>
{/* Multi-account mode banner */}
{currentChannel.channel_type === 'telegram' && telegramAccounts.length > 0 && (
<div className="p-3 bg-blue-500/10 rounded-xl border border-blue-500/30 flex items-start gap-2 mb-4">
<Bot size={16} className="text-blue-400 mt-0.5 shrink-0" />
<p className="text-xs text-gray-300">
<strong className="text-blue-400">Multi-bot mode active.</strong> Bot Token, DM Policy, Group Policy, and Stream Mode are now configured per-account below.
</p>
</div>
)}
{field.type === 'select' ? (
<select
value={configForm[field.key] || ''}
onChange={(e) =>
setConfigForm({ ...configForm, [field.key]: e.target.value })
}
className="input-base"
>
<option value="">Please select...</option>
{field.options?.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : field.type === 'password' ? (
<div className="relative">
<div className="space-y-4">
{currentInfo.fields
.filter(field => {
// In multi-account mode, hide per-account fields (they're in Bot Accounts section)
if (currentChannel.channel_type === 'telegram' && telegramAccounts.length > 0) {
const accountFields = ['botToken', 'dmPolicy', 'groupPolicy', 'streamMode'];
return !accountFields.includes(field.key);
}
return true;
})
.map((field) => (
<div key={field.key}>
<label className="block text-sm text-gray-400 mb-2">
{field.label}
{field.required && <span className="text-red-400 ml-1">*</span>}
{configForm[field.key] && (
<span className="ml-2 text-green-500 text-xs"></span>
)}
</label>
{field.type === 'select' ? (
<select
value={configForm[field.key] || ''}
onChange={(e) =>
setConfigForm({ ...configForm, [field.key]: e.target.value })
}
className="input-base"
>
<option value="">Please select...</option>
{field.options?.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : field.type === 'password' ? (
<div className="relative">
<input
type={visiblePasswords.has(field.key) ? 'text' : 'password'}
value={configForm[field.key] || ''}
onChange={(e) =>
setConfigForm({ ...configForm, [field.key]: e.target.value })
}
placeholder={field.placeholder}
className="input-base pr-10"
/>
<button
type="button"
onClick={() => togglePasswordVisibility(field.key)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white transition-colors"
title={visiblePasswords.has(field.key) ? 'Hide' : 'Show'}
>
{visiblePasswords.has(field.key) ? (
<EyeOff size={18} />
) : (
<Eye size={18} />
)}
</button>
</div>
) : (
<input
type={visiblePasswords.has(field.key) ? 'text' : 'password'}
type={field.type}
value={configForm[field.key] || ''}
onChange={(e) =>
setConfigForm({ ...configForm, [field.key]: e.target.value })
}
placeholder={field.placeholder}
className="input-base pr-10"
className="input-base"
/>
<button
type="button"
onClick={() => togglePasswordVisibility(field.key)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white transition-colors"
title={visiblePasswords.has(field.key) ? 'Hide' : 'Show'}
>
{visiblePasswords.has(field.key) ? (
<EyeOff size={18} />
) : (
<Eye size={18} />
)}
</button>
</div>
) : (
<input
type={field.type}
value={configForm[field.key] || ''}
onChange={(e) =>
setConfigForm({ ...configForm, [field.key]: e.target.value })
}
placeholder={field.placeholder}
className="input-base"
/>
)}
)}
{/* Groups UI: shown when groupPolicy is 'allowlist' */}
{field.key === 'groupPolicy' && configForm[field.key] === 'allowlist' && (
<div className="mt-3 space-y-3">
{/* Allowed Groups */}
<div className="p-4 bg-dark-600 rounded-xl border border-dark-500">
<label className="block text-sm text-gray-400 mb-2">Allowed Groups (Chat ID)</label>
<div className="flex gap-2 mb-2">
<input
type="text"
value={newGroupInput}
onChange={(e) => setNewGroupInput(e.target.value)}
placeholder="e.g. -1001234567890"
className="input-base text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
{/* Groups UI: shown when groupPolicy is 'allowlist' */}
{field.key === 'groupPolicy' && configForm[field.key] === 'allowlist' && (
<div className="mt-3 space-y-3">
{/* Allowed Groups */}
<div className="p-4 bg-dark-600 rounded-xl border border-dark-500">
<label className="block text-sm text-gray-400 mb-2">Allowed Groups (Chat ID)</label>
<div className="flex gap-2 mb-2">
<input
type="text"
value={newGroupInput}
onChange={(e) => setNewGroupInput(e.target.value)}
placeholder="e.g. -1001234567890"
className="input-base text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (newGroupInput && !(newGroupInput in allowedGroups)) {
setAllowedGroups({ ...allowedGroups, [newGroupInput]: { requireMention: false, enabled: true, groupPolicy: 'open', systemPrompt: '' } });
setNewGroupInput('');
}
}
}}
/>
<button
onClick={() => {
if (newGroupInput && !(newGroupInput in allowedGroups)) {
setAllowedGroups({ ...allowedGroups, [newGroupInput]: false });
setAllowedGroups({ ...allowedGroups, [newGroupInput]: { requireMention: false, enabled: true, groupPolicy: 'open', systemPrompt: '' } });
setNewGroupInput('');
}
}
}}
/>
<button
onClick={() => {
if (newGroupInput && !(newGroupInput in allowedGroups)) {
setAllowedGroups({ ...allowedGroups, [newGroupInput]: false });
setNewGroupInput('');
}
}}
className="btn-secondary p-2"
>
<Plus size={16} />
</button>
}}
className="btn-secondary p-2"
>
<Plus size={16} />
</button>
</div>
<div className="space-y-2 max-h-[400px] overflow-y-auto">
{Object.entries(allowedGroups).map(([id, settings]) => (
<div key={id} className="bg-dark-500 rounded-lg border border-dark-400 overflow-hidden">
<div className="flex items-center justify-between px-3 py-2">
<span className="font-mono text-sm text-gray-300">{id}</span>
<div className="flex items-center gap-2">
<button
onClick={() => setAllowedGroups({ ...allowedGroups, [id]: { ...settings, enabled: !settings.enabled } })}
className={`text-xs px-2 py-0.5 rounded-full border transition-colors ${settings.enabled
? 'border-green-500/50 bg-green-500/10 text-green-400'
: 'border-red-500/50 bg-red-500/10 text-red-400'
}`}
>
{settings.enabled ? 'enabled' : 'disabled'}
</button>
<button
onClick={() => setAllowedGroups({ ...allowedGroups, [id]: { ...settings, requireMention: !settings.requireMention } })}
className={`text-xs px-2 py-0.5 rounded-full border transition-colors ${settings.requireMention
? 'border-yellow-500/50 bg-yellow-500/10 text-yellow-400'
: 'border-green-500/50 bg-green-500/10 text-green-400'
}`}
title={settings.requireMention ? 'Bot only responds when @mentioned' : 'Bot responds to all messages'}
>
{settings.requireMention ? '@mention' : 'all msgs'}
</button>
<button
onClick={() => {
const next = { ...allowedGroups };
delete next[id];
setAllowedGroups(next);
}}
className="text-gray-500 hover:text-red-400"
>
<Trash2 size={14} />
</button>
</div>
</div>
<div className="px-3 pb-3 space-y-2">
<div>
<label className="block text-xs text-gray-500 mb-1">Group Policy</label>
<select
value={settings.groupPolicy}
onChange={(e) => setAllowedGroups({ ...allowedGroups, [id]: { ...settings, groupPolicy: e.target.value } })}
className="input-base text-xs py-1"
>
<option value="open">open anyone can talk</option>
<option value="allowlist">allowlist only allowed users</option>
<option value="disabled">disabled no one</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">System Prompt (per-group)</label>
<textarea
value={settings.systemPrompt}
onChange={(e) => setAllowedGroups({ ...allowedGroups, [id]: { ...settings, systemPrompt: e.target.value } })}
placeholder="e.g. You are an investment analyst. Your research is in investments/."
className="input-base text-xs min-h-[60px] resize-y"
rows={2}
/>
</div>
</div>
</div>
))}
{Object.keys(allowedGroups).length === 0 && (
<div className="text-xs text-gray-500 text-center py-2 italic">
No groups added. Bot will ignore all groups.
</div>
)}
</div>
<p className="text-xs text-gray-500 mt-2">
Each group gets its own settings. Use system prompts to give each group a unique personality/domain.
</p>
</div>
<div className="space-y-1 max-h-40 overflow-y-auto">
{Object.entries(allowedGroups).map(([id, requireMention]) => (
<div key={id} className="flex items-center justify-between text-sm bg-dark-500 px-3 py-1.5 rounded-lg border border-dark-400">
<span className="font-mono text-gray-300">{id}</span>
<div className="flex items-center gap-2">
{/* Group Allowed Senders (groupAllowFrom) */}
<div className="p-4 bg-dark-600 rounded-xl border border-dark-500">
<label className="block text-sm text-gray-400 mb-2">Allowed Senders in Groups (User ID)</label>
<div className="flex gap-2 mb-2">
<input
type="text"
value={newGroupAllowFromInput}
onChange={(e) => setNewGroupAllowFromInput(e.target.value)}
placeholder="e.g. 123456789"
className="input-base text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (newGroupAllowFromInput && !groupAllowFromUsers.includes(newGroupAllowFromInput)) {
setGroupAllowFromUsers([...groupAllowFromUsers, newGroupAllowFromInput]);
setNewGroupAllowFromInput('');
}
}
}}
/>
<button
onClick={() => {
if (newGroupAllowFromInput && !groupAllowFromUsers.includes(newGroupAllowFromInput)) {
setGroupAllowFromUsers([...groupAllowFromUsers, newGroupAllowFromInput]);
setNewGroupAllowFromInput('');
}
}}
className="btn-secondary p-2"
>
<Plus size={16} />
</button>
</div>
<div className="space-y-1 max-h-40 overflow-y-auto">
{groupAllowFromUsers.map(id => (
<div key={id} className="flex items-center justify-between text-sm bg-dark-500 px-3 py-1.5 rounded-lg border border-dark-400">
<span className="font-mono text-gray-300">{id}</span>
<button
onClick={() => setAllowedGroups({ ...allowedGroups, [id]: !requireMention })}
className={`text-xs px-2 py-0.5 rounded-full border transition-colors ${requireMention
? 'border-yellow-500/50 bg-yellow-500/10 text-yellow-400'
: 'border-green-500/50 bg-green-500/10 text-green-400'
}`}
title={requireMention ? 'Bot only responds when @mentioned' : 'Bot responds to all messages'}
>
{requireMention ? '@mention required' : 'responds to all'}
</button>
<button
onClick={() => {
const next = { ...allowedGroups };
delete next[id];
setAllowedGroups(next);
}}
onClick={() => setGroupAllowFromUsers(groupAllowFromUsers.filter(u => u !== id))}
className="text-gray-500 hover:text-red-400"
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
{Object.keys(allowedGroups).length === 0 && (
<div className="text-xs text-gray-500 text-center py-2 italic">
No groups added. Bot will ignore all groups.
</div>
)}
))}
{groupAllowFromUsers.length === 0 && (
<div className="text-xs text-gray-500 text-center py-2 italic">
No senders restricted. All group members can interact.
</div>
)}
</div>
<p className="text-xs text-gray-500 mt-2">
Saved as <code className="px-1 py-0.5 bg-dark-500 rounded">channels.telegram.groupAllowFrom</code>. Numeric Telegram user IDs.
</p>
</div>
<p className="text-xs text-gray-500 mt-2">
New groups default to <code className="px-1 py-0.5 bg-dark-500 rounded">responds to all</code>. Click the badge to toggle <code className="px-1 py-0.5 bg-dark-500 rounded">requireMention</code>.
</p>
</div>
)}
{/* Group Allowed Senders (groupAllowFrom) */}
<div className="p-4 bg-dark-600 rounded-xl border border-dark-500">
<label className="block text-sm text-gray-400 mb-2">Allowed Senders in Groups (User ID)</label>
{/* DM allowFrom: shown when dmPolicy is 'pairing' or 'allowlist' */}
{field.key === 'dmPolicy' && (configForm[field.key] === 'pairing' || configForm[field.key] === 'allowlist') && (
<div className="mt-3 p-4 bg-dark-600 rounded-xl border border-dark-500">
<label className="block text-sm text-gray-400 mb-2">Allowed DM Users (User ID)</label>
<div className="flex gap-2 mb-2">
<input
type="text"
value={newGroupAllowFromInput}
onChange={(e) => setNewGroupAllowFromInput(e.target.value)}
value={newAllowFromInput}
onChange={(e) => setNewAllowFromInput(e.target.value)}
placeholder="e.g. 123456789"
className="input-base text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (newGroupAllowFromInput && !groupAllowFromUsers.includes(newGroupAllowFromInput)) {
setGroupAllowFromUsers([...groupAllowFromUsers, newGroupAllowFromInput]);
setNewGroupAllowFromInput('');
if (newAllowFromInput && !allowFromUsers.includes(newAllowFromInput)) {
setAllowFromUsers([...allowFromUsers, newAllowFromInput]);
setNewAllowFromInput('');
}
}
}}
/>
<button
onClick={() => {
if (newGroupAllowFromInput && !groupAllowFromUsers.includes(newGroupAllowFromInput)) {
setGroupAllowFromUsers([...groupAllowFromUsers, newGroupAllowFromInput]);
setNewGroupAllowFromInput('');
if (newAllowFromInput && !allowFromUsers.includes(newAllowFromInput)) {
setAllowFromUsers([...allowFromUsers, newAllowFromInput]);
setNewAllowFromInput('');
}
}}
className="btn-secondary p-2"
@@ -881,90 +1070,32 @@ export function Channels() {
</button>
</div>
<div className="space-y-1 max-h-40 overflow-y-auto">
{groupAllowFromUsers.map(id => (
{allowFromUsers.map(id => (
<div key={id} className="flex items-center justify-between text-sm bg-dark-500 px-3 py-1.5 rounded-lg border border-dark-400">
<span className="font-mono text-gray-300">{id}</span>
<button
onClick={() => setGroupAllowFromUsers(groupAllowFromUsers.filter(u => u !== id))}
onClick={() => setAllowFromUsers(allowFromUsers.filter(u => u !== id))}
className="text-gray-500 hover:text-red-400"
>
<Trash2 size={14} />
</button>
</div>
))}
{groupAllowFromUsers.length === 0 && (
{allowFromUsers.length === 0 && (
<div className="text-xs text-gray-500 text-center py-2 italic">
No senders restricted. All group members can interact.
{configForm[field.key] === 'pairing'
? 'Users will be added automatically via pairing flow.'
: 'No users allowed. Add user IDs above.'}
</div>
)}
</div>
<p className="text-xs text-gray-500 mt-2">
Saved as <code className="px-1 py-0.5 bg-dark-500 rounded">channels.telegram.groupAllowFrom</code>. Numeric Telegram user IDs.
Saved as <code className="px-1 py-0.5 bg-dark-500 rounded">channels.telegram.allowFrom</code>. Numeric Telegram user IDs.
</p>
</div>
</div>
)}
{/* DM allowFrom: shown when dmPolicy is 'pairing' or 'allowlist' */}
{field.key === 'dmPolicy' && (configForm[field.key] === 'pairing' || configForm[field.key] === 'allowlist') && (
<div className="mt-3 p-4 bg-dark-600 rounded-xl border border-dark-500">
<label className="block text-sm text-gray-400 mb-2">Allowed DM Users (User ID)</label>
<div className="flex gap-2 mb-2">
<input
type="text"
value={newAllowFromInput}
onChange={(e) => setNewAllowFromInput(e.target.value)}
placeholder="e.g. 123456789"
className="input-base text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (newAllowFromInput && !allowFromUsers.includes(newAllowFromInput)) {
setAllowFromUsers([...allowFromUsers, newAllowFromInput]);
setNewAllowFromInput('');
}
}
}}
/>
<button
onClick={() => {
if (newAllowFromInput && !allowFromUsers.includes(newAllowFromInput)) {
setAllowFromUsers([...allowFromUsers, newAllowFromInput]);
setNewAllowFromInput('');
}
}}
className="btn-secondary p-2"
>
<Plus size={16} />
</button>
</div>
<div className="space-y-1 max-h-40 overflow-y-auto">
{allowFromUsers.map(id => (
<div key={id} className="flex items-center justify-between text-sm bg-dark-500 px-3 py-1.5 rounded-lg border border-dark-400">
<span className="font-mono text-gray-300">{id}</span>
<button
onClick={() => setAllowFromUsers(allowFromUsers.filter(u => u !== id))}
className="text-gray-500 hover:text-red-400"
>
<Trash2 size={14} />
</button>
</div>
))}
{allowFromUsers.length === 0 && (
<div className="text-xs text-gray-500 text-center py-2 italic">
{configForm[field.key] === 'pairing'
? 'Users will be added automatically via pairing flow.'
: 'No users allowed. Add user IDs above.'}
</div>
)}
</div>
<p className="text-xs text-gray-500 mt-2">
Saved as <code className="px-1 py-0.5 bg-dark-500 rounded">channels.telegram.allowFrom</code>. Numeric Telegram user IDs.
</p>
</div>
)}
</div>
))}
)}
</div>
))}
{/* WhatsApp special handling: QR code login button */}
{currentChannel.channel_type === 'whatsapp' && (
@@ -1011,6 +1142,276 @@ export function Channels() {
</div>
)}
{/* Telegram Multi-Bot Accounts */}
{currentChannel.channel_type === 'telegram' && (
<div className="mt-6 p-4 bg-dark-600 rounded-xl border border-dark-500">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Bot size={18} className="text-blue-400" />
<h4 className="text-sm font-semibold text-white">Bot Accounts</h4>
<span className="text-xs text-gray-500">Multi-agent routing</span>
</div>
<button
onClick={() => setShowAddAccountDialog(true)}
className="btn-secondary text-xs flex items-center gap-1 py-1 px-2"
>
<Plus size={14} /> Add Bot
</button>
</div>
{telegramAccounts.length === 0 ? (
<div className="text-xs text-gray-500 text-center py-4 italic">
No bot accounts configured. Your existing bot token is used as a single agent.
<br />Add multiple bots to route each to a different agent.
</div>
) : (
<div className="space-y-2">
{telegramAccounts.map(acct => (
<div key={acct.id} className="bg-dark-500 rounded-lg border border-dark-400 overflow-hidden">
<div
className="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-dark-400/50 transition-colors"
onClick={() => setExpandedAccount(expandedAccount === acct.id ? null : acct.id)}
>
<div className="flex items-center gap-2">
<Bot size={14} className="text-blue-400" />
<span className="font-mono text-sm text-gray-200">{acct.id}</span>
<span className="text-xs text-gray-500 font-mono">{'•••' + acct.bot_token.slice(-6)}</span>
</div>
<div className="flex items-center gap-2">
<Settings size={14} className={expandedAccount === acct.id ? 'text-claw-400' : 'text-gray-500'} />
<button
onClick={(e) => { e.stopPropagation(); handleDeleteAccount(acct.id); }}
className="text-gray-500 hover:text-red-400"
>
<Trash2 size={14} />
</button>
</div>
</div>
{expandedAccount === acct.id && (
<div className="px-3 pb-3 space-y-3 border-t border-dark-400 pt-2">
<div>
<label className="block text-xs text-gray-500 mb-1">Bot Token</label>
<input
type="password"
value={acct.bot_token}
onChange={(e) => {
const updated = telegramAccounts.map(a => a.id === acct.id ? { ...a, bot_token: e.target.value } : a);
setTelegramAccounts(updated);
}}
className="input-base text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs text-gray-500 mb-1">Group Policy</label>
<select
value={acct.group_policy || 'open'}
onChange={(e) => {
const updated = telegramAccounts.map(a => a.id === acct.id ? { ...a, group_policy: e.target.value } : a);
setTelegramAccounts(updated);
}}
className="input-base text-xs py-1"
>
<option value="open">open</option>
<option value="allowlist">allowlist</option>
<option value="disabled">disabled</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">DM Policy</label>
<select
value={acct.dm_policy || 'pairing'}
onChange={(e) => {
const updated = telegramAccounts.map(a => a.id === acct.id ? { ...a, dm_policy: e.target.value } : a);
setTelegramAccounts(updated);
}}
className="input-base text-xs py-1"
>
<option value="pairing">pairing</option>
<option value="open">open</option>
<option value="disabled">disabled</option>
</select>
</div>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Exclusive Topics (Allowlist)</label>
<input
type="text"
value={acct.exclusive_topics?.join(', ') || ''}
onChange={(e) => {
const val = e.target.value;
const topics = val ? val.split(',').map(s => s.trim()).filter(Boolean) : undefined;
const updated = telegramAccounts.map(a => a.id === acct.id ? { ...a, exclusive_topics: topics } : a);
setTelegramAccounts(updated);
}}
placeholder="Comma-separated Topic IDs (e.g. 42, 45). Leave empty to allow all."
className="input-base text-xs"
/>
<p className="text-[10px] text-gray-500 mt-1">If set, bot will <strong>only</strong> respond in these topics and ignore all others.</p>
</div>
{/* Groups Management (shown when allowlist) */}
{acct.group_policy === 'allowlist' && (() => {
const groups = (acct.groups || {}) as Record<string, { enabled?: boolean; requireMention?: boolean; topics?: Record<string, { requireMention?: boolean }> }>;
const updateGroups = (newGroups: typeof groups) => {
const updated = telegramAccounts.map(a => a.id === acct.id ? { ...a, groups: newGroups } : a);
setTelegramAccounts(updated);
};
return (
<div className="p-3 bg-dark-600 rounded-lg border border-dark-500 space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400 font-semibold">Allowed Groups</label>
</div>
<div className="flex gap-2">
<input
type="text"
placeholder="Group Chat ID, e.g. -1003726801732"
className="input-base text-xs flex-1"
onKeyDown={(e) => {
if (e.key === 'Enter') {
const val = (e.target as HTMLInputElement).value.trim();
if (val && !(val in groups)) {
updateGroups({ ...groups, [val]: { enabled: true, requireMention: true } });
(e.target as HTMLInputElement).value = '';
}
}
}}
id={`add-group-${acct.id}`}
/>
<button
onClick={() => {
const input = document.getElementById(`add-group-${acct.id}`) as HTMLInputElement;
const val = input?.value.trim();
if (val && !(val in groups)) {
updateGroups({ ...groups, [val]: { enabled: true, requireMention: true } });
input.value = '';
}
}}
className="btn-secondary p-1.5"
>
<Plus size={14} />
</button>
</div>
{Object.entries(groups).map(([gid, gsettings]) => (
<div key={gid} className="bg-dark-700 rounded-lg border border-dark-500 overflow-hidden">
<div className="flex items-center justify-between px-2 py-1.5">
<span className="font-mono text-xs text-gray-300">{gid}</span>
<div className="flex items-center gap-1.5">
<button
onClick={() => updateGroups({ ...groups, [gid]: { ...gsettings, enabled: !gsettings.enabled } })}
className={`text-[10px] px-1.5 py-0.5 rounded-full border ${gsettings.enabled !== false
? 'border-green-500/50 bg-green-500/10 text-green-400'
: 'border-red-500/50 bg-red-500/10 text-red-400'}`}
>
{gsettings.enabled !== false ? 'on' : 'off'}
</button>
<button
onClick={() => updateGroups({ ...groups, [gid]: { ...gsettings, requireMention: !gsettings.requireMention } })}
className={`text-[10px] px-1.5 py-0.5 rounded-full border ${gsettings.requireMention
? 'border-yellow-500/50 bg-yellow-500/10 text-yellow-400'
: 'border-green-500/50 bg-green-500/10 text-green-400'}`}
title={gsettings.requireMention ? 'Only responds when @mentioned' : 'Responds to all messages'}
>
{gsettings.requireMention ? '@mention' : 'all msgs'}
</button>
<button
onClick={() => {
const next = { ...groups };
delete next[gid];
updateGroups(next);
}}
className="text-gray-500 hover:text-red-400"
>
<Trash2 size={12} />
</button>
</div>
</div>
{/* (Topic configuration moved to Exclusive Topics above) */}
<div className="px-2 pb-1">
<p className="text-[10px] text-gray-600 italic">
Use <strong>Exclusive Topics</strong> above to restrict this bot to specific topics.
</p>
</div>
</div>
))}
{Object.keys(groups).length === 0 && (
<p className="text-[10px] text-gray-500 italic text-center py-1">No groups added. Bot will ignore all groups.</p>
)}
</div>
);
})()}
<button
onClick={() => handleSaveAccount(acct)}
disabled={savingAccount}
className="btn-primary text-xs py-1 px-3 flex items-center gap-1"
>
{savingAccount ? <Loader2 size={12} className="animate-spin" /> : <Check size={12} />}
Save Account
</button>
</div>
)}
</div>
))}
</div>
)}
<p className="text-xs text-gray-500 mt-2">
Each bot account can be bound to a different agent via <strong>Agents Routing Rules</strong> using Account ID.
</p>
{/* Add Account Dialog */}
{showAddAccountDialog && (
<div className="mt-3 p-3 bg-dark-700 rounded-lg border border-claw-500/30">
<h5 className="text-sm font-medium text-white mb-2">Add Bot Account</h5>
<div className="space-y-2">
<input
type="text"
value={newAccountId}
onChange={e => setNewAccountId(e.target.value)}
placeholder="Account ID (e.g. researchbot)"
className="input-base text-sm"
/>
<input
type="password"
value={newAccountToken}
onChange={e => setNewAccountToken(e.target.value)}
placeholder="Bot Token from @BotFather"
className="input-base text-sm"
/>
<div className="flex gap-2">
<button
onClick={async () => {
if (newAccountId && newAccountToken) {
await handleSaveAccount({ id: newAccountId, bot_token: newAccountToken });
setNewAccountId('');
setNewAccountToken('');
setShowAddAccountDialog(false);
}
}}
disabled={!newAccountId || !newAccountToken || savingAccount}
className="btn-primary text-xs py-1.5 px-3"
>
{savingAccount ? 'Saving...' : 'Add'}
</button>
<button
onClick={() => { setShowAddAccountDialog(false); setNewAccountId(''); setNewAccountToken(''); }}
className="btn-secondary text-xs py-1.5 px-3"
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
)}
{/* Action buttons */}
<div className="pt-4 border-t border-dark-500 flex flex-wrap items-center gap-3">
<button