mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-06-12 11:10:27 +08:00
v0.8.0: Ollama兼容、Git自动安装、Gitee镜像、会话重命名、消息渠道Agent绑定、仪表盘重设计、环境检测实时生效、#44修复
This commit is contained in:
@@ -53,7 +53,7 @@ const PLATFORM_REGISTRY = {
|
||||
'进入<b>权限管理</b>,参照 <a href="https://open.larkoffice.com/document/server-docs/application-scope/scope-list" target="_blank" style="color:var(--accent);text-decoration:underline">权限列表</a> 开通所需权限(<code>im:message</code> 等)',
|
||||
'进入<b>事件订阅</b>,选择<b>使用长连接(WebSocket)</b>模式,订阅<b>接收消息</b>和<b>卡片回调</b>事件。如有 user access token 开关请打开',
|
||||
'将 App ID 和 App Secret 填入下方表单,校验后保存。ClawPanel 会自动安装飞书插件并写入配置',
|
||||
'保存后在飞书中向机器人发消息,获取配对码,然后在终端执行 <code>openclaw pairing approve feishu <配对码> --notify</code> 完成绑定',
|
||||
'保存后在飞书中向机器人发消息,获取配对码;你可以直接在下方“配对审批”区域粘贴配对码完成绑定,也可以在终端执行 <code>openclaw pairing approve feishu <配对码> --notify</code>',
|
||||
],
|
||||
guideFooter: '<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">国际版 Lark 用户请将域名切换为 <b>lark</b>。详细教程:<a href="https://www.feishu.cn/content/article/7613711414611463386" target="_blank" style="color:var(--accent);text-decoration:underline">OpenClaw 飞书官方插件使用指南</a></div>',
|
||||
fields: [
|
||||
@@ -62,6 +62,35 @@ const PLATFORM_REGISTRY = {
|
||||
{ key: 'domain', label: '域名', placeholder: 'feishu(国际版选 lark)', required: false },
|
||||
],
|
||||
pluginRequired: '@openclaw/feishu@latest',
|
||||
pluginId: 'feishu',
|
||||
pairingChannel: 'feishu',
|
||||
pairingNotify: true,
|
||||
},
|
||||
dingtalk: {
|
||||
label: '钉钉',
|
||||
iconName: 'message-square',
|
||||
desc: '钉钉企业内部应用 + 机器人 Stream 模式接入',
|
||||
guide: [
|
||||
'前往 <a href="https://open-dev.dingtalk.com/" target="_blank" style="color:var(--accent);text-decoration:underline">钉钉开放平台</a> 创建企业内部应用,并添加<b>机器人</b>能力',
|
||||
'消息接收模式必须选择 <b>Stream 模式</b>,不要选 Webhook',
|
||||
'在<b>凭证与基础信息</b>页面复制 <b>Client ID</b> 和 <b>Client Secret</b>;如 Gateway 开启了鉴权,请按 <code>gateway.auth.mode</code> 填写 <b>Gateway Token</b> 或 <b>Gateway Password</b>',
|
||||
'在<b>权限管理</b>中至少确认已开通 <code>Card.Streaming.Write</code>、<code>Card.Instance.Write</code>、<code>qyapi_robot_sendmsg</code>,如需文档能力再补文档相关权限',
|
||||
'先在钉钉侧<b>发布应用版本</b>,并确认<b>应用可见范围</b>包含你自己和测试成员;否则私聊或加群时可能搜不到机器人',
|
||||
'回到 ClawPanel 保存。首次保存会自动安装插件,后续保存只更新配置;如果本机已配置 Gateway 鉴权,系统会自动带出对应的 Token 或 Password',
|
||||
'私聊测试时,可在钉钉客户端搜索应用/机器人名称,或从工作台进入应用后发起对话;若找不到,优先检查“已发布”和“可见范围”',
|
||||
'如果机器人首次私聊返回的是<b>配对码</b>,你可以直接在下方“配对审批”区域粘贴配对码完成授权,也可以在终端执行 <code>openclaw pairing approve dingtalk-connector <配对码></code>',
|
||||
'群聊测试时,先进入目标群 → <b>群设置</b> → <b>智能群助手 / 机器人</b> → <b>添加机器人</b>,搜索并添加该机器人;回群后建议用 <code>@机器人</code> 再发消息,如仍不响应再检查连接器的 <code>groupPolicy</code> 是否被设为 <code>disabled</code>',
|
||||
],
|
||||
guideFooter: '<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">参考资料:<a href="https://open.dingtalk.com/document/dingstart/install-openclaw-locally" target="_blank" style="color:var(--accent);text-decoration:underline">本地安装 OpenClaw</a>、<a href="https://open.dingtalk.com/document/orgapp/use-group-robots" target="_blank" style="color:var(--accent);text-decoration:underline">添加机器人到钉钉群</a>。排障重点:405 通常是 <code>chatCompletions</code> 未启用,401 通常是 Gateway 鉴权字段不匹配。</div>',
|
||||
fields: [
|
||||
{ key: 'clientId', label: 'Client ID', placeholder: 'dingxxxxxxxxxx', required: true },
|
||||
{ key: 'clientSecret', label: 'Client Secret', placeholder: '应用密钥', secret: true, required: true },
|
||||
{ key: 'gatewayToken', label: 'Gateway Token', placeholder: '如已开启 Gateway token 鉴权则填写', required: false },
|
||||
{ key: 'gatewayPassword', label: 'Gateway Password', placeholder: '与 token 二选一,可选', secret: true, required: false },
|
||||
],
|
||||
pluginRequired: '@dingtalk-real-ai/dingtalk-connector',
|
||||
pluginId: 'dingtalk-connector',
|
||||
pairingChannel: 'dingtalk-connector',
|
||||
},
|
||||
discord: {
|
||||
label: 'Discord',
|
||||
@@ -90,7 +119,7 @@ export async function render() {
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">消息渠道</h1>
|
||||
<p class="page-desc">内置 QQ 机器人,并支持 Telegram、Discord 等外部消息渠道接入</p>
|
||||
<p class="page-desc">支持 QQ、Telegram、Discord、飞书、钉钉等消息渠道接入</p>
|
||||
</div>
|
||||
<div id="platforms-configured" style="margin-bottom:var(--space-lg)"></div>
|
||||
<div class="config-section">
|
||||
@@ -117,6 +146,11 @@ async function loadPlatforms(page, state) {
|
||||
toast('加载平台列表失败: ' + e, 'error')
|
||||
state.configured = []
|
||||
}
|
||||
// 加载 bindings 信息
|
||||
try {
|
||||
const config = await api.readOpenclawConfig()
|
||||
state.bindings = Array.isArray(config?.bindings) ? config.bindings : []
|
||||
} catch { state.bindings = [] }
|
||||
renderConfigured(page, state)
|
||||
renderAvailable(page, state)
|
||||
}
|
||||
@@ -138,11 +172,15 @@ function renderConfigured(page, state) {
|
||||
const reg = PLATFORM_REGISTRY[p.id]
|
||||
const label = reg?.label || p.id
|
||||
const ic = icon(reg?.iconName || 'radio', 22)
|
||||
const channelKey = getChannelBindingKey(p.id)
|
||||
const binding = (state.bindings || []).find(b => b.match?.channel === channelKey)
|
||||
const boundAgent = binding?.agentId || 'main'
|
||||
return `
|
||||
<div class="platform-card ${p.enabled ? 'active' : 'inactive'}" data-pid="${p.id}">
|
||||
<div class="platform-card-header">
|
||||
<span class="platform-emoji">${ic}</span>
|
||||
<span class="platform-name">${label}</span>
|
||||
${boundAgent !== 'main' ? `<span style="font-size:var(--font-size-xs);color:var(--accent);background:var(--accent-muted);padding:1px 6px;border-radius:10px">→ ${escapeAttr(boundAgent)}</span>` : ''}
|
||||
<span class="platform-status-dot ${p.enabled ? 'on' : 'off'}"></span>
|
||||
</div>
|
||||
<div class="platform-card-actions">
|
||||
@@ -214,16 +252,47 @@ async function openConfigDialog(pid, page, state) {
|
||||
// 尝试加载已有配置
|
||||
let existing = {}
|
||||
let isEdit = false
|
||||
let agents = []
|
||||
let currentBinding = ''
|
||||
try {
|
||||
const res = await api.readPlatformConfig(pid)
|
||||
if (res?.exists && res.values) {
|
||||
if (res?.values) {
|
||||
existing = res.values
|
||||
}
|
||||
if (res?.exists) {
|
||||
isEdit = true
|
||||
}
|
||||
} catch {}
|
||||
// 加载 Agent 列表和当前 binding
|
||||
try {
|
||||
agents = await api.listAgents()
|
||||
} catch {}
|
||||
try {
|
||||
const config = await api.readOpenclawConfig()
|
||||
const bindings = config?.bindings || []
|
||||
const channelKey = getChannelBindingKey(pid)
|
||||
const found = bindings.find(b => b.match?.channel === channelKey)
|
||||
if (found) currentBinding = found.agentId || ''
|
||||
} catch {}
|
||||
|
||||
const formId = 'platform-form-' + Date.now()
|
||||
|
||||
// Agent 绑定选择器
|
||||
const agentOptions = agents.map(a => {
|
||||
const label = a.identityName ? a.identityName.split(',')[0].trim() : a.id
|
||||
return `<option value="${escapeAttr(a.id)}" ${a.id === currentBinding ? 'selected' : ''}>${a.id}${a.id !== label ? ' — ' + label : ''}</option>`
|
||||
}).join('')
|
||||
const agentBindingHtml = `
|
||||
<div class="form-group">
|
||||
<label class="form-label">绑定 Agent</label>
|
||||
<select class="form-input" name="__agentBinding">
|
||||
<option value="" ${!currentBinding ? 'selected' : ''}>默认(main)</option>
|
||||
${agentOptions}
|
||||
</select>
|
||||
<div class="form-hint">选择该渠道消息路由到哪个 Agent 处理。留空则使用默认 Agent(main)</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
const fieldsHtml = reg.fields.map((f, i) => {
|
||||
const val = existing[f.key] || ''
|
||||
return `
|
||||
@@ -249,12 +318,28 @@ async function openConfigDialog(pid, page, state) {
|
||||
</details>
|
||||
` : ''
|
||||
|
||||
const pairingHtml = reg.pairingChannel ? `
|
||||
<div style="margin-top:var(--space-md);padding:12px 14px;background:var(--bg-tertiary);border-radius:var(--radius-md)">
|
||||
<div style="font-weight:600;font-size:var(--font-size-sm);margin-bottom:6px">配对审批</div>
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-secondary);line-height:1.7;margin-bottom:8px">当机器人提示 <code>access not configured</code>、<code>Pairing code</code> 或要求执行 <code>openclaw pairing approve</code> 时,可直接在这里完成批准。</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<input class="form-input" name="pairingCode" placeholder="例如 R3ZFPWZP" style="flex:1;min-width:180px">
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="btn-pairing-list">查看待审批</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" id="btn-pairing-approve">批准配对码</button>
|
||||
</div>
|
||||
<div id="pairing-result" style="margin-top:8px"></div>
|
||||
</div>
|
||||
` : ''
|
||||
|
||||
const content = `
|
||||
${guideHtml}
|
||||
${!isEdit && (existing.gatewayToken || existing.gatewayPassword) ? `<div style="background:var(--bg-tertiary);color:var(--text-secondary);padding:8px 14px;border-radius:var(--radius-md);font-size:var(--font-size-sm);margin-bottom:var(--space-md)">已从当前 Gateway 鉴权配置中自动带出 ${existing.gatewayToken ? 'Token' : 'Password'},通常无需手填</div>` : ''}
|
||||
${isEdit ? `<div style="background:var(--accent-muted);color:var(--accent);padding:8px 14px;border-radius:var(--radius-md);font-size:var(--font-size-sm);margin-bottom:var(--space-md)">当前已有配置,修改后点击保存即可覆盖</div>` : ''}
|
||||
<form id="${formId}">
|
||||
${fieldsHtml}
|
||||
${agentBindingHtml}
|
||||
</form>
|
||||
${pairingHtml}
|
||||
<div id="verify-result" style="margin-top:var(--space-sm)"></div>
|
||||
`
|
||||
|
||||
@@ -304,6 +389,60 @@ async function openConfigDialog(pid, page, state) {
|
||||
const btnVerify = modal.querySelector('#btn-verify')
|
||||
const btnSave = modal.querySelector('#btn-save')
|
||||
const resultEl = modal.querySelector('#verify-result')
|
||||
const pairingInput = modal.querySelector('input[name="pairingCode"]')
|
||||
const pairingResultEl = modal.querySelector('#pairing-result')
|
||||
const btnPairingList = modal.querySelector('#btn-pairing-list')
|
||||
const btnPairingApprove = modal.querySelector('#btn-pairing-approve')
|
||||
|
||||
if (btnPairingList && pairingResultEl) {
|
||||
btnPairingList.onclick = async () => {
|
||||
btnPairingList.disabled = true
|
||||
btnPairingList.textContent = '读取中...'
|
||||
pairingResultEl.innerHTML = ''
|
||||
try {
|
||||
const output = await api.pairingListChannel(reg.pairingChannel)
|
||||
pairingResultEl.innerHTML = `
|
||||
<div style="background:var(--bg-secondary);border:1px solid var(--border-primary);border-radius:var(--radius-md);padding:10px 12px">
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-bottom:6px">待审批请求</div>
|
||||
<pre style="margin:0;white-space:pre-wrap;word-break:break-word;font-size:12px;color:var(--text-secondary);font-family:var(--font-mono)">${escapeAttr(output || '暂无待审批请求')}</pre>
|
||||
</div>`
|
||||
} catch (e) {
|
||||
pairingResultEl.innerHTML = `<div style="color:var(--error);font-size:var(--font-size-sm)">读取失败: ${escapeAttr(String(e))}</div>`
|
||||
} finally {
|
||||
btnPairingList.disabled = false
|
||||
btnPairingList.textContent = '查看待审批'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (btnPairingApprove && pairingInput && pairingResultEl) {
|
||||
btnPairingApprove.onclick = async () => {
|
||||
const code = pairingInput.value.trim().toUpperCase()
|
||||
if (!code) {
|
||||
toast('请输入配对码', 'warning')
|
||||
pairingInput.focus()
|
||||
return
|
||||
}
|
||||
btnPairingApprove.disabled = true
|
||||
btnPairingApprove.textContent = '批准中...'
|
||||
pairingResultEl.innerHTML = ''
|
||||
try {
|
||||
const output = await api.pairingApproveChannel(reg.pairingChannel, code, !!reg.pairingNotify)
|
||||
pairingResultEl.innerHTML = `
|
||||
<div style="background:var(--success-muted);color:var(--success);padding:10px 14px;border-radius:var(--radius-md);font-size:var(--font-size-sm)">
|
||||
${icon('check', 14)} 配对已批准
|
||||
<div style="margin-top:6px;font-size:12px;white-space:pre-wrap;word-break:break-word;color:var(--text-secondary)">${escapeAttr(output || '操作完成')}</div>
|
||||
</div>`
|
||||
pairingInput.value = ''
|
||||
toast('配对已批准', 'success')
|
||||
} catch (e) {
|
||||
pairingResultEl.innerHTML = `<div style="background:var(--error-muted, #fee2e2);color:var(--error);padding:10px 14px;border-radius:var(--radius-md);font-size:var(--font-size-sm)">批准失败: ${escapeAttr(String(e))}</div>`
|
||||
} finally {
|
||||
btnPairingApprove.disabled = false
|
||||
btnPairingApprove.textContent = '批准配对码'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
btnVerify.onclick = async () => {
|
||||
const form = collectForm()
|
||||
@@ -356,57 +495,77 @@ async function openConfigDialog(pid, page, state) {
|
||||
try {
|
||||
// 如果需要安装插件,先安装并显示日志
|
||||
if (reg.pluginRequired) {
|
||||
btnSave.textContent = '安装插件中...'
|
||||
resultEl.innerHTML = `
|
||||
<div style="background:var(--bg-tertiary);border-radius:var(--radius-md);padding:12px;margin-top:var(--space-sm)">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||
${icon('download', 14)}
|
||||
<span style="font-size:var(--font-size-sm);font-weight:600">安装插件</span>
|
||||
<span id="plugin-progress-text" style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-left:auto">0%</span>
|
||||
const pluginId = reg.pluginId || pid
|
||||
const pluginStatus = await api.getChannelPluginStatus(pluginId)
|
||||
if (!pluginStatus?.installed) {
|
||||
btnSave.textContent = '安装插件中...'
|
||||
resultEl.innerHTML = `
|
||||
<div style="background:var(--bg-tertiary);border-radius:var(--radius-md);padding:12px;margin-top:var(--space-sm)">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||
${icon('download', 14)}
|
||||
<span style="font-size:var(--font-size-sm);font-weight:600">安装插件</span>
|
||||
<span id="plugin-progress-text" style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-left:auto">0%</span>
|
||||
</div>
|
||||
<div style="height:4px;background:var(--bg-secondary);border-radius:2px;overflow:hidden;margin-bottom:8px">
|
||||
<div id="plugin-progress-bar" style="height:100%;background:var(--accent);width:0%;transition:width 0.3s"></div>
|
||||
</div>
|
||||
<div id="plugin-log-box" style="font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);max-height:120px;overflow-y:auto;line-height:1.6;white-space:pre-wrap;word-break:break-all"></div>
|
||||
</div>
|
||||
<div style="height:4px;background:var(--bg-secondary);border-radius:2px;overflow:hidden;margin-bottom:8px">
|
||||
<div id="plugin-progress-bar" style="height:100%;background:var(--accent);width:0%;transition:width 0.3s"></div>
|
||||
</div>
|
||||
<div id="plugin-log-box" style="font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);max-height:120px;overflow-y:auto;line-height:1.6;white-space:pre-wrap;word-break:break-all"></div>
|
||||
</div>
|
||||
`
|
||||
const logBox = resultEl.querySelector('#plugin-log-box')
|
||||
const progressBar = resultEl.querySelector('#plugin-progress-bar')
|
||||
const progressText = resultEl.querySelector('#plugin-progress-text')
|
||||
`
|
||||
const logBox = resultEl.querySelector('#plugin-log-box')
|
||||
const progressBar = resultEl.querySelector('#plugin-progress-bar')
|
||||
const progressText = resultEl.querySelector('#plugin-progress-text')
|
||||
let unlistenLog, unlistenProgress
|
||||
try {
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
unlistenLog = await listen('plugin-log', (e) => {
|
||||
logBox.textContent += e.payload + '\n'
|
||||
logBox.scrollTop = logBox.scrollHeight
|
||||
})
|
||||
unlistenProgress = await listen('plugin-progress', (e) => {
|
||||
const pct = e.payload
|
||||
progressBar.style.width = pct + '%'
|
||||
progressText.textContent = pct + '%'
|
||||
})
|
||||
} catch {}
|
||||
|
||||
// 监听 Tauri 事件
|
||||
let unlistenLog, unlistenProgress
|
||||
try {
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
unlistenLog = await listen('plugin-log', (e) => {
|
||||
logBox.textContent += e.payload + '\n'
|
||||
logBox.scrollTop = logBox.scrollHeight
|
||||
})
|
||||
unlistenProgress = await listen('plugin-progress', (e) => {
|
||||
const pct = e.payload
|
||||
progressBar.style.width = pct + '%'
|
||||
progressText.textContent = pct + '%'
|
||||
})
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
await api.installQqbotPlugin()
|
||||
} catch (e) {
|
||||
toast('插件安装失败: ' + e, 'error')
|
||||
btnSave.disabled = false
|
||||
btnVerify.disabled = false
|
||||
btnSave.textContent = isEdit ? '保存' : '接入并保存'
|
||||
try {
|
||||
if (pid === 'qqbot') {
|
||||
await api.installQqbotPlugin()
|
||||
} else {
|
||||
await api.installChannelPlugin(reg.pluginRequired, pluginId)
|
||||
}
|
||||
} catch (e) {
|
||||
toast('插件安装失败: ' + e, 'error')
|
||||
btnSave.disabled = false
|
||||
btnVerify.disabled = false
|
||||
btnSave.textContent = isEdit ? '保存' : '接入并保存'
|
||||
if (unlistenLog) unlistenLog()
|
||||
if (unlistenProgress) unlistenProgress()
|
||||
return
|
||||
}
|
||||
if (unlistenLog) unlistenLog()
|
||||
if (unlistenProgress) unlistenProgress()
|
||||
return
|
||||
} else {
|
||||
resultEl.innerHTML = `
|
||||
<div style="background:var(--accent-muted);color:var(--accent);padding:10px 14px;border-radius:var(--radius-md);font-size:var(--font-size-sm)">
|
||||
${icon('check', 14)} 已检测到插件,无需重复安装,本次仅更新配置
|
||||
</div>`
|
||||
}
|
||||
if (unlistenLog) unlistenLog()
|
||||
if (unlistenProgress) unlistenProgress()
|
||||
}
|
||||
|
||||
// 写入配置
|
||||
btnSave.textContent = '写入配置...'
|
||||
await api.saveMessagingPlatform(pid, form)
|
||||
|
||||
// 写入 Agent 绑定到 openclaw.json bindings
|
||||
const selectedAgent = modal.querySelector('select[name="__agentBinding"]')?.value || ''
|
||||
try {
|
||||
await saveChannelBinding(pid, selectedAgent)
|
||||
} catch (e) {
|
||||
console.warn('[channels] 保存 Agent 绑定失败:', e)
|
||||
}
|
||||
|
||||
toast(`${reg.label} 配置已保存,Gateway 正在重载`, 'success')
|
||||
modal.close?.() || modal.remove?.()
|
||||
await loadPlatforms(page, state)
|
||||
@@ -420,6 +579,37 @@ async function openConfigDialog(pid, page, state) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 将平台 ID 映射为 openclaw bindings 中的 channel key */
|
||||
function getChannelBindingKey(pid) {
|
||||
const map = {
|
||||
qqbot: 'qqbot',
|
||||
telegram: 'telegram',
|
||||
discord: 'discord',
|
||||
feishu: 'feishu',
|
||||
dingtalk: 'dingtalk-connector',
|
||||
}
|
||||
return map[pid] || pid
|
||||
}
|
||||
|
||||
/** 保存渠道→Agent 绑定到 openclaw.json 的 bindings 数组 */
|
||||
async function saveChannelBinding(pid, agentId) {
|
||||
const config = await api.readOpenclawConfig()
|
||||
if (!config) return
|
||||
const channelKey = getChannelBindingKey(pid)
|
||||
let bindings = Array.isArray(config.bindings) ? [...config.bindings] : []
|
||||
|
||||
// 移除该渠道的旧绑定
|
||||
bindings = bindings.filter(b => b.match?.channel !== channelKey)
|
||||
|
||||
// 如果选了非空 Agent 且不是 main,添加新绑定
|
||||
if (agentId && agentId !== 'main') {
|
||||
bindings.push({ match: { channel: channelKey }, agentId })
|
||||
}
|
||||
|
||||
config.bindings = bindings
|
||||
await api.writeOpenclawConfig(config)
|
||||
}
|
||||
|
||||
function escapeAttr(str) {
|
||||
return (str || '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user