feat: per-account DM allowlist with primary bot inheritance

- Add allow_from field to TelegramAccount struct and frontend interface
- Read/write allowFrom per-account in get/save_telegram_accounts
- Auto-inherit allowFrom from primary bot when creating new accounts
- Per-account DM Users UI in expanded bot settings (add/remove user IDs)
- Pre-populate new accounts with primary bot's allowFrom list
This commit is contained in:
MrFadiAi
2026-02-16 21:21:49 +01:00
parent fc8b9a93dd
commit cacc2dfaee
2 changed files with 110 additions and 1 deletions
+42
View File
@@ -1626,6 +1626,8 @@ pub struct TelegramAccount {
pub exclusive_topics: Option<Vec<String>>,
pub groups: Option<serde_json::Value>,
pub primary: Option<bool>,
#[serde(alias = "allowFrom", alias = "allow_from")]
pub allow_from: Option<Vec<String>>,
}
/// Get all Telegram bot accounts
@@ -1668,6 +1670,13 @@ pub async fn get_telegram_accounts() -> Result<Vec<TelegramAccount>, String> {
},
groups: acct_val.get("groups").cloned(),
primary: None, // Will be set below
allow_from: acct_val.get("allowFrom")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| {
if let Some(s) = v.as_str() { Some(s.to_string()) }
else if let Some(n) = v.as_i64() { Some(n.to_string()) }
else { None }
}).collect()),
});
}
}
@@ -1685,6 +1694,13 @@ pub async fn get_telegram_accounts() -> Result<Vec<TelegramAccount>, String> {
exclusive_topics: None,
groups: config.pointer("/channels/telegram/groups").cloned(),
primary: None,
allow_from: config.pointer("/channels/telegram/allowFrom")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| {
if let Some(s) = v.as_str() { Some(s.to_string()) }
else if let Some(n) = v.as_i64() { Some(n.to_string()) }
else { None }
}).collect()),
});
}
}
@@ -1769,6 +1785,32 @@ pub async fn save_telegram_account(account: TelegramAccount) -> Result<String, S
// Fix for validation error: dmPolicy="open" requires allowFrom to include "*"
if dp == "open" {
acct_obj["allowFrom"] = json!(["*"]);
} else if let Some(ref af) = account.allow_from {
if !af.is_empty() {
// Convert string IDs to numbers where possible for Core compatibility
let allow_vals: Vec<serde_json::Value> = af.iter().map(|id| {
if let Ok(n) = id.parse::<i64>() { json!(n) } else { json!(id) }
}).collect();
acct_obj["allowFrom"] = json!(allow_vals);
}
} else {
// Auto-inherit from primary bot if no explicit allow_from provided
let primary_id = load_manager_config()
.unwrap_or(json!({}))
.pointer("/primaryBotAccount")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if let Some(pid) = primary_id {
if pid != account.id {
// Read primary account's allowFrom
if let Some(primary_allow) = config.pointer(&format!("/channels/telegram/accounts/{}/allowFrom", pid))
.and_then(|v| v.as_array()) {
if !primary_allow.is_empty() && primary_allow.iter().any(|v| v.as_str() != Some("*")) {
acct_obj["allowFrom"] = json!(primary_allow);
}
}
}
}
}
}
if let Some(sm) = &account.stream_mode {
+68 -1
View File
@@ -252,6 +252,7 @@ export function Channels() {
exclusive_topics?: string[];
groups?: Record<string, unknown>;
primary?: boolean;
allow_from?: string[];
}
const [telegramAccounts, setTelegramAccounts] = useState<TelegramAccountInfo[]>([]);
const [showAddAccountDialog, setShowAddAccountDialog] = useState(false);
@@ -1350,6 +1351,69 @@ export function Channels() {
);
})()}
{/* Per-account DM Allowed Users */}
{(acct.dm_policy === 'pairing' || acct.dm_policy === 'allowlist') && (() => {
const dmUsers = acct.allow_from || [];
const updateDmUsers = (newList: string[]) => {
const updated = telegramAccounts.map(a => a.id === acct.id ? { ...a, allow_from: newList } : a);
setTelegramAccounts(updated);
};
return (
<div className="p-3 bg-dark-600 rounded-lg border border-dark-500 space-y-2">
<label className="text-xs text-gray-400 font-semibold">Allowed DM Users (User ID)</label>
<div className="flex gap-2">
<input
type="text"
placeholder="e.g. 123456789"
className="input-base text-xs flex-1"
id={`dm-user-${acct.id}`}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const val = (e.target as HTMLInputElement).value.trim();
if (val && !dmUsers.includes(val)) {
updateDmUsers([...dmUsers, val]);
(e.target as HTMLInputElement).value = '';
}
}
}}
/>
<button
onClick={() => {
const input = document.getElementById(`dm-user-${acct.id}`) as HTMLInputElement;
const val = input?.value.trim();
if (val && !dmUsers.includes(val)) {
updateDmUsers([...dmUsers, val]);
input.value = '';
}
}}
className="btn-secondary p-1.5"
>
<Plus size={14} />
</button>
</div>
<div className="space-y-1 max-h-32 overflow-y-auto">
{dmUsers.map(id => (
<div key={id} className="flex items-center justify-between text-xs bg-dark-500 px-2.5 py-1 rounded-lg border border-dark-400">
<span className="font-mono text-gray-300">{id}</span>
<button
onClick={() => updateDmUsers(dmUsers.filter(u => u !== id))}
className="text-gray-500 hover:text-red-400"
>
<Trash2 size={12} />
</button>
</div>
))}
{dmUsers.length === 0 && (
<p className="text-[10px] text-gray-500 italic text-center py-1">
{acct.dm_policy === 'pairing' ? 'Users added via pairing flow. You can also add manually.' : 'No users allowed. Add user IDs above.'}
</p>
)}
</div>
<p className="text-[10px] text-gray-500">Saved per-account as <code className="px-1 py-0.5 bg-dark-500 rounded">allowFrom</code>. Inherited from primary bot if empty.</p>
</div>
);
})()}
<div className="flex items-center gap-2 mt-2 pt-2 border-t border-dark-400">
<input
type="checkbox"
@@ -1412,7 +1476,10 @@ export function Channels() {
<button
onClick={async () => {
if (newAccountId && newAccountToken) {
await handleSaveAccount({ id: newAccountId, bot_token: newAccountToken });
// Pre-populate allow_from from primary bot
const primaryBot = telegramAccounts.find(a => a.primary);
const inheritedAllowFrom = primaryBot?.allow_from?.filter(id => id !== '*');
await handleSaveAccount({ id: newAccountId, bot_token: newAccountToken, allow_from: inheritedAllowFrom && inheritedAllowFrom.length > 0 ? inheritedAllowFrom : undefined });
setNewAccountId('');
setNewAccountToken('');
setShowAddAccountDialog(false);