add
This commit is contained in:
@@ -93,9 +93,9 @@ install_nodejs() {
|
|||||||
install_moltbot_core() {
|
install_moltbot_core() {
|
||||||
log_info "正在安装 Moltbot..."
|
log_info "正在安装 Moltbot..."
|
||||||
|
|
||||||
if command -v moltbot >/dev/null 2>&1; then
|
if command -v clawdbot >/dev/null 2>&1; then
|
||||||
CURRENT_VERSION=$(moltbot --version)
|
CURRENT_VERSION=$(clawdbot --version)
|
||||||
log_warn "Moltbot 已安装 (版本: ${CURRENT_VERSION})"
|
log_warn "ClawdBot (Moltbot) 已安装 (版本: ${CURRENT_VERSION})"
|
||||||
read -p "是否强制重新安装/更新?[y/n]: " force_install
|
read -p "是否强制重新安装/更新?[y/n]: " force_install
|
||||||
if [ "$force_install" != "y" ]; then
|
if [ "$force_install" != "y" ]; then
|
||||||
log_info "跳过安装步骤。"
|
log_info "跳过安装步骤。"
|
||||||
@@ -103,13 +103,14 @@ install_moltbot_core() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
npm install -g moltbot@latest
|
# 强制使用 clawdbot 包,因为更稳定
|
||||||
|
npm install -g clawdbot@latest
|
||||||
|
|
||||||
if command -v moltbot >/dev/null 2>&1; then
|
if command -v clawdbot >/dev/null 2>&1; then
|
||||||
VERSION=$(moltbot --version)
|
VERSION=$(clawdbot --version)
|
||||||
log_info "Moltbot 安装成功,版本: ${VERSION}"
|
log_info "ClawdBot 安装成功,版本: ${VERSION}"
|
||||||
else
|
else
|
||||||
log_error "Moltbot 安装失败,请检查 npm 权限或网络!"
|
log_error "ClawdBot 安装失败,请检查 npm 权限或网络!"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
@@ -258,7 +259,7 @@ After=network.target
|
|||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=root
|
User=root
|
||||||
ExecStart=$(command -v moltbot) gateway --verbose
|
ExecStart=$(command -v clawdbot) gateway --verbose
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
Environment=HOME=/root
|
Environment=HOME=/root
|
||||||
@@ -288,7 +289,7 @@ install() {
|
|||||||
echo -e "${GREEN}=============================================${PLAIN}"
|
echo -e "${GREEN}=============================================${PLAIN}"
|
||||||
echo -e "请在 Telegram 中向您的 Bot 发送任意消息以开始配对。"
|
echo -e "请在 Telegram 中向您的 Bot 发送任意消息以开始配对。"
|
||||||
echo -e "获取配对码后,请在菜单中选择 '6. 手动执行命令' -> 输入配对命令,"
|
echo -e "获取配对码后,请在菜单中选择 '6. 手动执行命令' -> 输入配对命令,"
|
||||||
echo -e "或者直接在终端运行: moltbot pairing approve telegram <配对码>"
|
echo -e "或者直接在终端运行: clawdbot pairing approve telegram <配对码>"
|
||||||
echo -e "${GREEN}=============================================${PLAIN}"
|
echo -e "${GREEN}=============================================${PLAIN}"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,10 +306,11 @@ uninstall() {
|
|||||||
rm -f "${SERVICE_FILE}"
|
rm -f "${SERVICE_FILE}"
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
|
|
||||||
npm uninstall -g moltbot
|
npm uninstall -g clawdbot
|
||||||
|
npm uninstall -g moltbot # 尝试卸载旧包
|
||||||
rm -rf "${CONFIG_DIR}"
|
rm -rf "${CONFIG_DIR}"
|
||||||
|
|
||||||
log_info "Moltbot 已卸载。"
|
log_info "Moltbot (ClawdBot) 已卸载。"
|
||||||
}
|
}
|
||||||
|
|
||||||
# 菜单
|
# 菜单
|
||||||
@@ -338,7 +340,7 @@ show_menu() {
|
|||||||
6) journalctl -u moltbot -f ;;
|
6) journalctl -u moltbot -f ;;
|
||||||
7) nano "${CONFIG_FILE}" && systemctl restart moltbot && log_info "配置已更新并重启服务" ;;
|
7) nano "${CONFIG_FILE}" && systemctl restart moltbot && log_info "配置已更新并重启服务" ;;
|
||||||
8) uninstall ;;
|
8) uninstall ;;
|
||||||
9) moltbot doctor ;;
|
9) clawdbot doctor ;;
|
||||||
0) exit 0 ;;
|
0) exit 0 ;;
|
||||||
*) echo -e "${RED}无效选项,请重新输入${PLAIN}" ;;
|
*) echo -e "${RED}无效选项,请重新输入${PLAIN}" ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
30
moltbot/installer/go.mod
Normal file
30
moltbot/installer/go.mod
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
module moltbot-installer
|
||||||
|
|
||||||
|
go 1.25.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/charmbracelet/bubbles v0.21.0
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
|
golang.org/x/text v0.3.8 // indirect
|
||||||
|
)
|
||||||
47
moltbot/installer/go.sum
Normal file
47
moltbot/installer/go.sum
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||||
|
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||||
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||||
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||||
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
BIN
moltbot/installer/installer.exe
Normal file
BIN
moltbot/installer/installer.exe
Normal file
Binary file not shown.
68
moltbot/installer/internal/style/style.go
Normal file
68
moltbot/installer/internal/style/style.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package style
|
||||||
|
|
||||||
|
import "github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Colors
|
||||||
|
ColorPrimary = lipgloss.Color("#7D56F4") // Purple
|
||||||
|
ColorSecondary = lipgloss.Color("#04B575") // Green
|
||||||
|
ColorError = lipgloss.Color("#FF4C4C") // Red
|
||||||
|
ColorWarning = lipgloss.Color("#FFD700") // Gold
|
||||||
|
ColorSubtle = lipgloss.Color("#626262") // Gray
|
||||||
|
ColorText = lipgloss.Color("#FAFAFA") // White
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
AppStyle = lipgloss.NewStyle().
|
||||||
|
Padding(1, 2)
|
||||||
|
|
||||||
|
HeaderStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(ColorPrimary).
|
||||||
|
Bold(true).
|
||||||
|
PaddingBottom(1)
|
||||||
|
|
||||||
|
StepStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(ColorText)
|
||||||
|
|
||||||
|
SuccessStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(ColorSecondary).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
ErrorStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(ColorError).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
WarningStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(ColorWarning)
|
||||||
|
|
||||||
|
SubtleStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(ColorSubtle)
|
||||||
|
|
||||||
|
CmdStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("#00FFFF")).
|
||||||
|
Padding(0, 1)
|
||||||
|
|
||||||
|
HighlightStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(ColorSecondary).
|
||||||
|
Bold(true)
|
||||||
|
)
|
||||||
|
|
||||||
|
func RenderStep(prefix string, msg string, status string) string {
|
||||||
|
var statusStyle lipgloss.Style
|
||||||
|
switch status {
|
||||||
|
case "pending":
|
||||||
|
statusStyle = SubtleStyle
|
||||||
|
case "running":
|
||||||
|
statusStyle = lipgloss.NewStyle().Foreground(ColorPrimary)
|
||||||
|
case "done":
|
||||||
|
statusStyle = SuccessStyle
|
||||||
|
case "error":
|
||||||
|
statusStyle = ErrorStyle
|
||||||
|
default:
|
||||||
|
statusStyle = StepStyle
|
||||||
|
}
|
||||||
|
|
||||||
|
return lipgloss.JoinHorizontal(lipgloss.Left,
|
||||||
|
statusStyle.Width(3).Render(prefix),
|
||||||
|
statusStyle.Render(msg),
|
||||||
|
)
|
||||||
|
}
|
||||||
556
moltbot/installer/internal/sys/sys.go
Normal file
556
moltbot/installer/internal/sys/sys.go
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
package sys
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config Structures
|
||||||
|
|
||||||
|
type MoltbotConfig struct {
|
||||||
|
Gateway GatewayConfig `json:"gateway"`
|
||||||
|
Env map[string]string `json:"env,omitempty"`
|
||||||
|
Agents AgentsConfig `json:"agents"`
|
||||||
|
Models *ModelsConfig `json:"models,omitempty"`
|
||||||
|
Tools ToolsConfig `json:"tools"`
|
||||||
|
Channels ChannelsConfig `json:"channels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GatewayConfig struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Bind string `json:"bind"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentsConfig struct {
|
||||||
|
Defaults AgentDefaults `json:"defaults"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentDefaults struct {
|
||||||
|
Model ModelRef `json:"model"`
|
||||||
|
ElevatedDefault string `json:"elevatedDefault,omitempty"`
|
||||||
|
Compaction *CompactionConfig `json:"compaction,omitempty"`
|
||||||
|
MaxConcurrent int `json:"maxConcurrent,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModelRef struct {
|
||||||
|
Primary string `json:"primary"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CompactionConfig struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModelsConfig struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Providers map[string]ProviderConfig `json:"providers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProviderConfig struct {
|
||||||
|
BaseURL string `json:"baseUrl"`
|
||||||
|
APIKey string `json:"apiKey"`
|
||||||
|
API string `json:"api"`
|
||||||
|
Models []ModelEntry `json:"models"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModelEntry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolsConfig struct {
|
||||||
|
Exec *ExecConfig `json:"exec,omitempty"`
|
||||||
|
Elevated ElevatedConfig `json:"elevated"`
|
||||||
|
Allow []string `json:"allow"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExecConfig struct {
|
||||||
|
BackgroundMs int `json:"backgroundMs"`
|
||||||
|
TimeoutSec int `json:"timeoutSec"`
|
||||||
|
CleanupMs int `json:"cleanupMs"`
|
||||||
|
NotifyOnExit bool `json:"notifyOnExit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ElevatedConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
AllowFrom map[string][]string `json:"allowFrom"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChannelsConfig struct {
|
||||||
|
Telegram TelegramConfig `json:"telegram"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TelegramConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
BotToken string `json:"botToken"`
|
||||||
|
DMPolicy string `json:"dmPolicy"`
|
||||||
|
AllowFrom []string `json:"allowFrom"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMoltbotPath 尝试解析 moltbot 或 clawdbot 的绝对路径
|
||||||
|
func GetMoltbotPath() (string, error) {
|
||||||
|
// 优先检查 clawdbot
|
||||||
|
if path, err := exec.LookPath("clawdbot"); err == nil {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
if path, err := exec.LookPath("moltbot"); err == nil {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
npmPath, err := getNpmPath()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("无法定位 npm: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(npmPath, "config", "get", "prefix")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("无法获取 npm prefix: %v", err)
|
||||||
|
}
|
||||||
|
npmPrefix := strings.TrimSpace(string(out))
|
||||||
|
|
||||||
|
// 检查 clawdbot.cmd
|
||||||
|
possibleClawd := filepath.Join(npmPrefix, "clawdbot.cmd")
|
||||||
|
if _, err := os.Stat(possibleClawd); err == nil {
|
||||||
|
return possibleClawd, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 moltbot.cmd
|
||||||
|
possibleMolt := filepath.Join(npmPrefix, "moltbot.cmd")
|
||||||
|
if _, err := os.Stat(possibleMolt); err == nil {
|
||||||
|
return possibleMolt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("未找到 moltbot 或 clawdbot 可执行文件")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNodePath 获取 Node.js 可执行文件的绝对路径
|
||||||
|
func GetNodePath() (string, error) {
|
||||||
|
if path, err := exec.LookPath("node"); err == nil {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
defaultPath := `C:\Program Files\nodejs\node.exe`
|
||||||
|
if _, err := os.Stat(defaultPath); err == nil {
|
||||||
|
return defaultPath, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("未找到 Node.js")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupNodeEnv 将 Node.js 所在目录添加到当前进程的 PATH 环境变量
|
||||||
|
// 这对于刚安装完 Node.js 但未重启终端的情况非常重要
|
||||||
|
func SetupNodeEnv() error {
|
||||||
|
nodeExe, err := GetNodePath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
nodeDir := filepath.Dir(nodeExe)
|
||||||
|
|
||||||
|
pathEnv := os.Getenv("PATH")
|
||||||
|
// 简单检查是否已包含 (忽略大小写)
|
||||||
|
if strings.Contains(strings.ToLower(pathEnv), strings.ToLower(nodeDir)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
newPath := nodeDir + string(os.PathListSeparator) + pathEnv
|
||||||
|
|
||||||
|
// 同时确保 npm prefix 在 PATH 中
|
||||||
|
npmPath, err := getNpmPath()
|
||||||
|
if err == nil {
|
||||||
|
cmd := exec.Command(npmPath, "config", "get", "prefix")
|
||||||
|
if out, err := cmd.Output(); err == nil {
|
||||||
|
npmPrefix := strings.TrimSpace(string(out))
|
||||||
|
if !strings.Contains(strings.ToLower(newPath), strings.ToLower(npmPrefix)) {
|
||||||
|
newPath = npmPrefix + string(os.PathListSeparator) + newPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.Setenv("PATH", newPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckMoltbot 检查 Moltbot 是否已安装
|
||||||
|
// 返回: (版本号, 是否已安装)
|
||||||
|
func CheckMoltbot() (string, bool) {
|
||||||
|
// 确保环境正确
|
||||||
|
SetupNodeEnv()
|
||||||
|
|
||||||
|
cmdName, err := GetMoltbotPath()
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get version
|
||||||
|
cmd := exec.Command("cmd", "/c", cmdName, "--version")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out)), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckNode 检查 Node.js 版本是否 >= 22
|
||||||
|
func CheckNode() (string, bool) {
|
||||||
|
// Try to find node
|
||||||
|
nodePath, err := GetNodePath()
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(nodePath, "-v")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
versionStr := strings.TrimSpace(string(out)) // e.g., "v22.1.0"
|
||||||
|
re := regexp.MustCompile(`v(\d+)\.`)
|
||||||
|
matches := re.FindStringSubmatch(versionStr)
|
||||||
|
if len(matches) < 2 {
|
||||||
|
return versionStr, false
|
||||||
|
}
|
||||||
|
|
||||||
|
majorVer, err := strconv.Atoi(matches[1])
|
||||||
|
if err != nil {
|
||||||
|
return versionStr, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if majorVer >= 22 {
|
||||||
|
return versionStr, true
|
||||||
|
}
|
||||||
|
return versionStr, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// getNpmPath 获取 npm 可执行文件路径
|
||||||
|
func getNpmPath() (string, error) {
|
||||||
|
path, err := exec.LookPath("npm")
|
||||||
|
if err == nil {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
// Try default Windows install path
|
||||||
|
defaultPath := `C:\Program Files\nodejs\npm.cmd`
|
||||||
|
if _, err := os.Stat(defaultPath); err == nil {
|
||||||
|
return defaultPath, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("未找到 npm,请确认 Node.js 安装成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigureNpmMirror 设置 npm 淘宝镜像
|
||||||
|
func ConfigureNpmMirror() error {
|
||||||
|
npmPath, err := getNpmPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 设置 registry 为淘宝镜像
|
||||||
|
cmd := exec.Command(npmPath, "config", "set", "registry", "https://registry.npmmirror.com/")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("设置 npm 镜像失败: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstallNode 下载并安装 Node.js MSI
|
||||||
|
func InstallNode() error {
|
||||||
|
msiUrl := "https://nodejs.org/dist/v24.13.0/node-v24.13.0-x64.msi"
|
||||||
|
tempDir := os.TempDir()
|
||||||
|
msiPath := filepath.Join(tempDir, "node-v24.13.0-x64.msi")
|
||||||
|
|
||||||
|
// 1. 下载 MSI
|
||||||
|
fmt.Printf("正在下载 Node.js: %s\n", msiUrl)
|
||||||
|
resp, err := http.Get(msiUrl)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("下载失败: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("下载失败,状态码: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.Create(msiPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("创建文件失败: %v", err)
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(out, resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("写入文件失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 安装 MSI (静默安装)
|
||||||
|
// msiexec /i <file> /qn
|
||||||
|
fmt.Println("正在安装 Node.js...")
|
||||||
|
installCmd := exec.Command("msiexec", "/i", msiPath, "/qn")
|
||||||
|
if err := installCmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("安装失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 刷新环境变量 (当前进程无法立即生效,但后续调用 getNpmPath 会尝试绝对路径)
|
||||||
|
SetupNodeEnv()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstallMoltbotNpm 使用 npm 全局安装
|
||||||
|
func InstallMoltbotNpm(tag string) error {
|
||||||
|
// 确保 Node 环境就绪
|
||||||
|
SetupNodeEnv()
|
||||||
|
|
||||||
|
// 强制使用 clawdbot 包,因为用户反馈该包更稳定
|
||||||
|
// 如果之前传入的是 beta,重置为 latest,因为 clawdbot 的版本管理可能不同
|
||||||
|
pkgName := "clawdbot"
|
||||||
|
if tag == "" || tag == "beta" {
|
||||||
|
tag = "latest"
|
||||||
|
}
|
||||||
|
|
||||||
|
npmPath, err := getNpmPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置环境变量以减少 npm 输出
|
||||||
|
os.Setenv("NPM_CONFIG_LOGLEVEL", "error")
|
||||||
|
os.Setenv("NPM_CONFIG_UPDATE_NOTIFIER", "false")
|
||||||
|
os.Setenv("NPM_CONFIG_FUND", "false")
|
||||||
|
os.Setenv("NPM_CONFIG_AUDIT", "false")
|
||||||
|
|
||||||
|
cmd := exec.Command(npmPath, "install", "-g", fmt.Sprintf("%s@%s", pkgName, tag))
|
||||||
|
cmd.Stdout = nil
|
||||||
|
cmd.Stderr = nil
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureOnPath 确保 moltbot 或 clawdbot 在 PATH 中
|
||||||
|
// 返回值: (需要重启终端, error)
|
||||||
|
func EnsureOnPath() (bool, error) {
|
||||||
|
if _, err := exec.LookPath("clawdbot"); err == nil {
|
||||||
|
return false, nil // 已存在
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("moltbot"); err == nil {
|
||||||
|
return false, nil // 已存在
|
||||||
|
}
|
||||||
|
|
||||||
|
npmPath, err := getNpmPath()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 npm prefix
|
||||||
|
cmd := exec.Command(npmPath, "config", "get", "prefix")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
npmPrefix := strings.TrimSpace(string(out))
|
||||||
|
npmBin := filepath.Join(npmPrefix, "bin")
|
||||||
|
|
||||||
|
// 查找 clawdbot.cmd 或 moltbot.cmd
|
||||||
|
possiblePath := npmPrefix
|
||||||
|
|
||||||
|
// Check priority: clawdbot -> moltbot
|
||||||
|
if _, err := os.Stat(filepath.Join(npmPrefix, "clawdbot.cmd")); os.IsNotExist(err) {
|
||||||
|
if _, err := os.Stat(filepath.Join(npmPrefix, "moltbot.cmd")); os.IsNotExist(err) {
|
||||||
|
// Check bin subdir
|
||||||
|
possiblePath = npmBin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加到用户 PATH
|
||||||
|
// 这里我们添加包含 .cmd 文件的目录到 PATH
|
||||||
|
psCmd := fmt.Sprintf(`
|
||||||
|
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
|
||||||
|
if (-not ($userPath -split ";" | Where-Object { $_ -ieq "%s" })) {
|
||||||
|
[Environment]::SetEnvironmentVariable("Path", "$userPath;%s", "User")
|
||||||
|
}
|
||||||
|
`, possiblePath, possiblePath)
|
||||||
|
|
||||||
|
exec.Command("powershell", "-Command", psCmd).Run()
|
||||||
|
|
||||||
|
return true, nil // 已添加,需要重启终端
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunDoctor 运行迁移
|
||||||
|
func RunDoctor() error {
|
||||||
|
cmdName, err := GetMoltbotPath()
|
||||||
|
if err != nil {
|
||||||
|
// 尝试直接运行,虽然可能失败
|
||||||
|
cmdName = "moltbot"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在 Windows 上,需要通过 cmd /c 或 powershell 来运行 .cmd 文件
|
||||||
|
// 但 exec.Command 如果指向 .cmd 文件通常可以直接运行
|
||||||
|
// 为了保险,使用 cmd /c
|
||||||
|
cmd := exec.Command("cmd", "/c", cmdName, "doctor", "--non-interactive")
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunOnboard 运行引导 (未使用,保留备用)
|
||||||
|
func RunOnboard() error {
|
||||||
|
cmdName, err := GetMoltbotPath()
|
||||||
|
if err != nil {
|
||||||
|
cmdName = "moltbot"
|
||||||
|
}
|
||||||
|
cmd := exec.Command("cmd", "/c", cmdName, "onboard")
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config Options Struct
|
||||||
|
type ConfigOptions struct {
|
||||||
|
ApiType string // "anthropic" or "openai"
|
||||||
|
BotToken string
|
||||||
|
AdminID string
|
||||||
|
AnthropicKey string
|
||||||
|
OpenAIBaseURL string
|
||||||
|
OpenAIKey string
|
||||||
|
OpenAIModel string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateAndWriteConfig 生成并写入配置文件
|
||||||
|
func GenerateAndWriteConfig(opts ConfigOptions) error {
|
||||||
|
userHome, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("无法获取用户目录: %v", err)
|
||||||
|
}
|
||||||
|
configDir := filepath.Join(userHome, ".clawdbot")
|
||||||
|
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("创建配置目录失败: %v", err)
|
||||||
|
}
|
||||||
|
configFile := filepath.Join(configDir, "clawdbot.json")
|
||||||
|
|
||||||
|
config := MoltbotConfig{
|
||||||
|
Gateway: GatewayConfig{
|
||||||
|
Mode: "local",
|
||||||
|
Bind: "loopback",
|
||||||
|
Port: 18789,
|
||||||
|
},
|
||||||
|
Tools: ToolsConfig{
|
||||||
|
Elevated: ElevatedConfig{
|
||||||
|
Enabled: true,
|
||||||
|
AllowFrom: map[string][]string{
|
||||||
|
"telegram": {opts.AdminID},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Allow: []string{"exec", "process", "read", "write", "edit", "web_search", "web_fetch", "cron"},
|
||||||
|
},
|
||||||
|
Channels: ChannelsConfig{
|
||||||
|
Telegram: TelegramConfig{
|
||||||
|
Enabled: true,
|
||||||
|
BotToken: opts.BotToken,
|
||||||
|
DMPolicy: "pairing",
|
||||||
|
AllowFrom: []string{opts.AdminID},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.ApiType == "anthropic" {
|
||||||
|
config.Env = map[string]string{
|
||||||
|
"ANTHROPIC_API_KEY": opts.AnthropicKey,
|
||||||
|
}
|
||||||
|
config.Agents = AgentsConfig{
|
||||||
|
Defaults: AgentDefaults{
|
||||||
|
Model: ModelRef{
|
||||||
|
Primary: "anthropic/claude-opus-4-5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// OpenAI Compatible
|
||||||
|
config.Agents = AgentsConfig{
|
||||||
|
Defaults: AgentDefaults{
|
||||||
|
Model: ModelRef{
|
||||||
|
Primary: fmt.Sprintf("openai-compat/%s", opts.OpenAIModel),
|
||||||
|
},
|
||||||
|
ElevatedDefault: "full",
|
||||||
|
Compaction: &CompactionConfig{
|
||||||
|
Mode: "safeguard",
|
||||||
|
},
|
||||||
|
MaxConcurrent: 4,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
config.Models = &ModelsConfig{
|
||||||
|
Mode: "merge",
|
||||||
|
Providers: map[string]ProviderConfig{
|
||||||
|
"openai-compat": {
|
||||||
|
BaseURL: opts.OpenAIBaseURL,
|
||||||
|
APIKey: opts.OpenAIKey,
|
||||||
|
API: "openai-completions",
|
||||||
|
Models: []ModelEntry{
|
||||||
|
{ID: opts.OpenAIModel, Name: opts.OpenAIModel},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// Add extra exec config for OpenAI mode (as per install.sh)
|
||||||
|
config.Tools.Exec = &ExecConfig{
|
||||||
|
BackgroundMs: 10000,
|
||||||
|
TimeoutSec: 1800,
|
||||||
|
CleanupMs: 1800000,
|
||||||
|
NotifyOnExit: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(config, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("序列化配置失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(configFile, data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartGateway 在新窗口启动网关
|
||||||
|
func StartGateway() error {
|
||||||
|
cmdName, err := GetMoltbotPath()
|
||||||
|
if err != nil {
|
||||||
|
// Fallback if not found (though unlikely if installed)
|
||||||
|
cmdName = "moltbot"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 start 命令在新窗口运行
|
||||||
|
// Windows start command: start "Title" "Executable" args...
|
||||||
|
cmd := exec.Command("cmd", "/c", "start", "Moltbot Gateway", cmdName, "gateway", "--verbose")
|
||||||
|
return cmd.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UninstallMoltbot 卸载 Moltbot/Clawdbot 并清理配置
|
||||||
|
func UninstallMoltbot() error {
|
||||||
|
npmPath, err := getNpmPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Uninstall global packages
|
||||||
|
packages := []string{"clawdbot", "moltbot"}
|
||||||
|
for _, pkg := range packages {
|
||||||
|
cmd := exec.Command(npmPath, "uninstall", "-g", pkg)
|
||||||
|
cmd.Stdout = nil
|
||||||
|
cmd.Stderr = nil
|
||||||
|
cmd.Run() // Ignore errors if not installed
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Remove configuration directory
|
||||||
|
userHome, err := os.UserHomeDir()
|
||||||
|
if err == nil {
|
||||||
|
configDir := filepath.Join(userHome, ".clawdbot")
|
||||||
|
os.RemoveAll(configDir)
|
||||||
|
|
||||||
|
// Also check for legacy .moltbot if exists
|
||||||
|
legacyDir := filepath.Join(userHome, ".moltbot")
|
||||||
|
os.RemoveAll(legacyDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
523
moltbot/installer/internal/ui/model.go
Normal file
523
moltbot/installer/internal/ui/model.go
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"moltbot-installer/internal/style"
|
||||||
|
"moltbot-installer/internal/sys"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AppState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
StateInit AppState = iota
|
||||||
|
StateChecking
|
||||||
|
StateConfirmInstall
|
||||||
|
StateInstallingNode
|
||||||
|
StateConfiguringNpm
|
||||||
|
StateInstallingMoltbot
|
||||||
|
StateConfiguring
|
||||||
|
StateInstalled
|
||||||
|
StateMenu
|
||||||
|
StateConfigApiSelect
|
||||||
|
StateConfigInput
|
||||||
|
StateUninstallConfirm
|
||||||
|
StateUninstalling
|
||||||
|
StateError
|
||||||
|
)
|
||||||
|
|
||||||
|
type Model struct {
|
||||||
|
state AppState
|
||||||
|
spinner spinner.Model
|
||||||
|
err error
|
||||||
|
logs []string
|
||||||
|
nodeVer string
|
||||||
|
nodeOk bool
|
||||||
|
installMsg string
|
||||||
|
quitting bool
|
||||||
|
|
||||||
|
// Config Wizard
|
||||||
|
input textinput.Model
|
||||||
|
configOpts sys.ConfigOptions
|
||||||
|
configStep int
|
||||||
|
menuIndex int
|
||||||
|
|
||||||
|
DidStartGateway bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type checkMsg struct {
|
||||||
|
nodeVer string
|
||||||
|
nodeOk bool
|
||||||
|
needsNode bool
|
||||||
|
moltbotVer string
|
||||||
|
moltbotInstalled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type installNodeMsg struct{ err error }
|
||||||
|
type configNpmMsg struct{ err error }
|
||||||
|
type installMoltbotMsg struct {
|
||||||
|
version string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
type configMsg struct {
|
||||||
|
restartPath bool
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
type saveConfigMsg struct{ err error }
|
||||||
|
type uninstallMsg struct{ err error }
|
||||||
|
|
||||||
|
func InitialModel() Model {
|
||||||
|
s := spinner.New()
|
||||||
|
s.Spinner = spinner.Dot
|
||||||
|
s.Style = style.HeaderStyle
|
||||||
|
|
||||||
|
ti := textinput.New()
|
||||||
|
ti.Cursor.Style = style.HeaderStyle
|
||||||
|
ti.Focus()
|
||||||
|
|
||||||
|
return Model{
|
||||||
|
state: StateInit,
|
||||||
|
spinner: s,
|
||||||
|
input: ti,
|
||||||
|
logs: []string{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) Init() tea.Cmd {
|
||||||
|
return tea.Batch(
|
||||||
|
m.spinner.Tick,
|
||||||
|
func() tea.Msg {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
return checkMsg{}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
if msg.String() == "ctrl+c" {
|
||||||
|
m.quitting = true
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Menu Navigation
|
||||||
|
if m.state == StateMenu {
|
||||||
|
switch msg.String() {
|
||||||
|
case "up", "k":
|
||||||
|
if m.menuIndex > 0 {
|
||||||
|
m.menuIndex--
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if m.menuIndex < 2 {
|
||||||
|
m.menuIndex++
|
||||||
|
}
|
||||||
|
case "enter":
|
||||||
|
switch m.menuIndex {
|
||||||
|
case 0: // Start
|
||||||
|
sys.StartGateway()
|
||||||
|
m.DidStartGateway = true
|
||||||
|
return m, tea.Quit
|
||||||
|
case 1: // Configure
|
||||||
|
m.state = StateConfigApiSelect
|
||||||
|
m.configOpts = sys.ConfigOptions{}
|
||||||
|
case 2: // Uninstall
|
||||||
|
m.state = StateUninstallConfirm
|
||||||
|
case 3: // Exit
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uninstall Confirm State
|
||||||
|
if m.state == StateUninstallConfirm {
|
||||||
|
switch msg.String() {
|
||||||
|
case "y", "Y":
|
||||||
|
m.state = StateUninstalling
|
||||||
|
m.logs = append(m.logs, style.RenderStep("➜", "正在卸载 Moltbot 并清理配置...", "running"))
|
||||||
|
return m, uninstallCmd
|
||||||
|
case "n", "N", "enter":
|
||||||
|
m.state = StateMenu
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config API Selection
|
||||||
|
if m.state == StateConfigApiSelect {
|
||||||
|
switch msg.String() {
|
||||||
|
case "1":
|
||||||
|
m.configOpts.ApiType = "anthropic"
|
||||||
|
m.state = StateConfigInput
|
||||||
|
m.configStep = 0
|
||||||
|
m.input.Placeholder = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||||
|
m.input.EchoMode = textinput.EchoNormal
|
||||||
|
m.input.SetValue("")
|
||||||
|
case "2":
|
||||||
|
m.configOpts.ApiType = "openai"
|
||||||
|
m.state = StateConfigInput
|
||||||
|
m.configStep = 0
|
||||||
|
m.input.Placeholder = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||||
|
m.input.EchoMode = textinput.EchoNormal
|
||||||
|
m.input.SetValue("")
|
||||||
|
case "q", "esc":
|
||||||
|
m.state = StateMenu // Back to menu
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config Input Steps
|
||||||
|
if m.state == StateConfigInput {
|
||||||
|
switch msg.String() {
|
||||||
|
case "enter":
|
||||||
|
val := m.input.Value()
|
||||||
|
// Save current step value
|
||||||
|
switch m.configStep {
|
||||||
|
case 0: // Bot Token
|
||||||
|
m.configOpts.BotToken = val
|
||||||
|
m.configStep++
|
||||||
|
m.input.Placeholder = "123456789"
|
||||||
|
m.input.SetValue("")
|
||||||
|
case 1: // Admin ID
|
||||||
|
m.configOpts.AdminID = val
|
||||||
|
m.configStep++
|
||||||
|
if m.configOpts.ApiType == "anthropic" {
|
||||||
|
m.input.Placeholder = "sk-ant-api03-..."
|
||||||
|
m.input.EchoMode = textinput.EchoPassword
|
||||||
|
} else {
|
||||||
|
m.input.Placeholder = "https://api.openai.com/v1"
|
||||||
|
m.input.EchoMode = textinput.EchoNormal
|
||||||
|
}
|
||||||
|
m.input.SetValue("")
|
||||||
|
case 2: // Key (Anthropic) OR BaseURL (OpenAI)
|
||||||
|
if m.configOpts.ApiType == "anthropic" {
|
||||||
|
m.configOpts.AnthropicKey = val
|
||||||
|
// Finish Anthropic
|
||||||
|
return m, saveConfigCmd(m.configOpts)
|
||||||
|
} else {
|
||||||
|
m.configOpts.OpenAIBaseURL = val
|
||||||
|
m.configStep++
|
||||||
|
m.input.Placeholder = "sk-..."
|
||||||
|
m.input.EchoMode = textinput.EchoPassword
|
||||||
|
m.input.SetValue("")
|
||||||
|
}
|
||||||
|
case 3: // Key (OpenAI)
|
||||||
|
m.configOpts.OpenAIKey = val
|
||||||
|
m.configStep++
|
||||||
|
m.input.Placeholder = "gpt-4o / claude-3-5-sonnet"
|
||||||
|
m.input.EchoMode = textinput.EchoNormal
|
||||||
|
m.input.SetValue("")
|
||||||
|
case 4: // Model (OpenAI)
|
||||||
|
m.configOpts.OpenAIModel = val
|
||||||
|
// Finish OpenAI
|
||||||
|
return m, saveConfigCmd(m.configOpts)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.input, cmd = m.input.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm Install State
|
||||||
|
if m.state == StateConfirmInstall {
|
||||||
|
switch msg.String() {
|
||||||
|
case "y", "Y":
|
||||||
|
m.logs = append(m.logs, style.RenderStep("➜", "开始安装...", "running"))
|
||||||
|
if !m.nodeOk {
|
||||||
|
m.state = StateInstallingNode
|
||||||
|
m.logs = append(m.logs, style.RenderStep("➜", "正在安装 Node.js (可能需要管理员权限)...", "running"))
|
||||||
|
return m, installNodeCmd
|
||||||
|
}
|
||||||
|
m.state = StateConfiguringNpm
|
||||||
|
m.logs = append(m.logs, style.RenderStep("➜", "正在配置 npm 淘宝镜像...", "running"))
|
||||||
|
return m, configNpmCmd
|
||||||
|
case "n", "N", "enter":
|
||||||
|
m.logs = append(m.logs, style.RenderStep("!", "跳过安装步骤", "warning"))
|
||||||
|
m.state = StateConfiguring
|
||||||
|
return m, configCmd
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Installed State (Transition to Config)
|
||||||
|
if m.state == StateInstalled {
|
||||||
|
if msg.String() == "enter" {
|
||||||
|
m.state = StateConfigApiSelect
|
||||||
|
m.configOpts = sys.ConfigOptions{}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
case checkMsg:
|
||||||
|
if m.state == StateInit {
|
||||||
|
m.state = StateChecking
|
||||||
|
return m, checkEnvCmd
|
||||||
|
}
|
||||||
|
|
||||||
|
m.nodeVer = msg.nodeVer
|
||||||
|
m.nodeOk = msg.nodeOk
|
||||||
|
m.logs = append(m.logs, style.RenderStep("✓", "Windows 系统检测完毕", "done"))
|
||||||
|
|
||||||
|
if msg.nodeOk {
|
||||||
|
m.logs = append(m.logs, style.RenderStep("✓", fmt.Sprintf("发现 Node.js %s", msg.nodeVer), "done"))
|
||||||
|
} else {
|
||||||
|
if msg.nodeVer != "" {
|
||||||
|
m.logs = append(m.logs, style.RenderStep("!", fmt.Sprintf("发现 Node.js %s (需要 v22+)", msg.nodeVer), "warning"))
|
||||||
|
} else {
|
||||||
|
m.logs = append(m.logs, style.RenderStep("!", "未检测到 Node.js", "warning"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.moltbotInstalled {
|
||||||
|
m.logs = append(m.logs, style.RenderStep("!", fmt.Sprintf("检测到 Moltbot 已安装 (%s)", msg.moltbotVer), "warning"))
|
||||||
|
m.state = StateConfirmInstall
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !msg.nodeOk {
|
||||||
|
m.state = StateInstallingNode
|
||||||
|
m.logs = append(m.logs, style.RenderStep("➜", "正在安装 Node.js (可能需要管理员权限)...", "running"))
|
||||||
|
return m, installNodeCmd
|
||||||
|
}
|
||||||
|
|
||||||
|
m.state = StateConfiguringNpm
|
||||||
|
m.logs = append(m.logs, style.RenderStep("➜", "正在配置 npm 淘宝镜像...", "running"))
|
||||||
|
return m, configNpmCmd
|
||||||
|
|
||||||
|
case installNodeMsg:
|
||||||
|
if msg.err != nil {
|
||||||
|
m.err = msg.err
|
||||||
|
m.state = StateError
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.logs = append(m.logs, style.RenderStep("✓", "Node.js 安装成功", "done"))
|
||||||
|
m.state = StateConfiguringNpm
|
||||||
|
m.logs = append(m.logs, style.RenderStep("➜", "正在配置 npm 淘宝镜像...", "running"))
|
||||||
|
return m, configNpmCmd
|
||||||
|
|
||||||
|
case configNpmMsg:
|
||||||
|
if msg.err != nil {
|
||||||
|
m.logs = append(m.logs, style.RenderStep("!", fmt.Sprintf("配置镜像失败 (跳过): %v", msg.err), "warning"))
|
||||||
|
} else {
|
||||||
|
m.logs = append(m.logs, style.RenderStep("✓", "npm 淘宝镜像配置成功", "done"))
|
||||||
|
}
|
||||||
|
m.state = StateInstallingMoltbot
|
||||||
|
m.logs = append(m.logs, style.RenderStep("➜", "正在安装 Moltbot...", "running"))
|
||||||
|
return m, installMoltbotCmd
|
||||||
|
|
||||||
|
case installMoltbotMsg:
|
||||||
|
if msg.err != nil {
|
||||||
|
m.err = msg.err
|
||||||
|
m.state = StateError
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if msg.version != "" {
|
||||||
|
m.logs = append(m.logs, style.RenderStep("✓", fmt.Sprintf("Moltbot 安装成功 (%s)", msg.version), "done"))
|
||||||
|
} else {
|
||||||
|
m.logs = append(m.logs, style.RenderStep("✓", "Moltbot 安装成功", "done"))
|
||||||
|
}
|
||||||
|
m.state = StateConfiguring
|
||||||
|
return m, configCmd
|
||||||
|
|
||||||
|
case configMsg:
|
||||||
|
if msg.err != nil {
|
||||||
|
m.logs = append(m.logs, style.RenderStep("!", fmt.Sprintf("配置迁移失败: %v", msg.err), "warning"))
|
||||||
|
} else {
|
||||||
|
m.logs = append(m.logs, style.RenderStep("✓", "配置迁移/初始化完成", "done"))
|
||||||
|
}
|
||||||
|
if msg.restartPath {
|
||||||
|
m.logs = append(m.logs, style.RenderStep("!", "已添加 PATH 环境变量,请重启终端生效", "warning"))
|
||||||
|
}
|
||||||
|
m.state = StateInstalled
|
||||||
|
m.installMsg = getRandomWelcomeMsg()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case saveConfigMsg:
|
||||||
|
if msg.err != nil {
|
||||||
|
m.logs = append(m.logs, style.RenderStep("!", fmt.Sprintf("保存配置失败: %v", msg.err), "warning"))
|
||||||
|
} else {
|
||||||
|
m.logs = append(m.logs, style.RenderStep("✓", "配置文件已生成!", "done"))
|
||||||
|
m.logs = append(m.logs, style.RenderStep("✓", "配置完成,准备启动", "done"))
|
||||||
|
}
|
||||||
|
m.state = StateMenu
|
||||||
|
m.menuIndex = 0 // Default to Start
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case uninstallMsg:
|
||||||
|
if msg.err != nil {
|
||||||
|
m.logs = append(m.logs, style.RenderStep("!", fmt.Sprintf("卸载失败: %v", msg.err), "warning"))
|
||||||
|
} else {
|
||||||
|
m.logs = append(m.logs, style.RenderStep("✓", "Moltbot 已卸载并清理配置", "done"))
|
||||||
|
}
|
||||||
|
m.state = StateMenu
|
||||||
|
m.menuIndex = 0
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case spinner.TickMsg:
|
||||||
|
m.spinner, cmd = m.spinner.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) View() string {
|
||||||
|
if m.err != nil {
|
||||||
|
return fmt.Sprintf("\n%s\n\n%s: %v\n\n按 q 退出\n",
|
||||||
|
style.HeaderStyle.Render("Moltbot 安装程序"),
|
||||||
|
style.ErrorStyle.Render("发生错误"),
|
||||||
|
m.err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := fmt.Sprintf("\n%s\n\n", style.HeaderStyle.Render("Moltbot 安装程序"))
|
||||||
|
|
||||||
|
// Show logs for install process
|
||||||
|
if m.state != StateMenu && m.state != StateConfigApiSelect && m.state != StateConfigInput {
|
||||||
|
for _, log := range m.logs {
|
||||||
|
s += log + "\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic Content based on State
|
||||||
|
switch m.state {
|
||||||
|
case StateInstallingNode, StateConfiguringNpm, StateInstallingMoltbot, StateConfiguring, StateUninstalling:
|
||||||
|
s += fmt.Sprintf("\n%s %s\n", m.spinner.View(), style.SubtleStyle.Render("处理中..."))
|
||||||
|
|
||||||
|
case StateConfirmInstall:
|
||||||
|
s += fmt.Sprintf("\n%s\n", style.SubtleStyle.Render("是否强制重新安装/更新?[y/N]"))
|
||||||
|
|
||||||
|
case StateUninstallConfirm:
|
||||||
|
s += fmt.Sprintf("\n%s\n", style.SubtleStyle.Render("确定要卸载 Moltbot 吗?(这将删除配置文件) [y/N]"))
|
||||||
|
|
||||||
|
case StateInstalled:
|
||||||
|
s += fmt.Sprintf("\n%s\n", style.SuccessStyle.Render("安装完成!"))
|
||||||
|
s += style.SubtleStyle.Render(m.installMsg) + "\n\n"
|
||||||
|
s += style.StepStyle.Render("按 Enter 进入配置向导") + "\n"
|
||||||
|
|
||||||
|
case StateMenu:
|
||||||
|
s += style.HeaderStyle.Render("主菜单") + "\n\n"
|
||||||
|
choices := []string{"启动 Moltbot 网关", "配置 Moltbot", "卸载 Moltbot", "退出"}
|
||||||
|
for i, choice := range choices {
|
||||||
|
cursor := " "
|
||||||
|
if m.menuIndex == i {
|
||||||
|
cursor = "➜"
|
||||||
|
choice = style.HighlightStyle.Render(choice)
|
||||||
|
}
|
||||||
|
s += fmt.Sprintf(" %s %s\n", cursor, choice)
|
||||||
|
}
|
||||||
|
s += "\n" + style.SubtleStyle.Render("使用 ↑/↓ 选择,Enter 确认") + "\n"
|
||||||
|
// Show logs below menu if desired, or keep clean
|
||||||
|
if len(m.logs) > 0 {
|
||||||
|
s += "\n" + style.SubtleStyle.Render("--- 安装日志 ---") + "\n"
|
||||||
|
start := len(m.logs) - 3
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
for _, log := range m.logs[start:] {
|
||||||
|
s += log + "\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case StateConfigApiSelect:
|
||||||
|
s += style.HeaderStyle.Render("配置向导 - 选择 API 类型") + "\n\n"
|
||||||
|
s += "1. Anthropic 官方 API\n"
|
||||||
|
s += "2. OpenAI 兼容 API (中转站/其他模型)\n\n"
|
||||||
|
s += style.SubtleStyle.Render("按 1 或 2 选择,Esc 返回") + "\n"
|
||||||
|
|
||||||
|
case StateConfigInput:
|
||||||
|
s += style.HeaderStyle.Render("配置向导") + "\n\n"
|
||||||
|
|
||||||
|
label := ""
|
||||||
|
switch m.configStep {
|
||||||
|
case 0:
|
||||||
|
label = "Telegram Bot Token:"
|
||||||
|
case 1:
|
||||||
|
label = "Telegram User ID (管理员):"
|
||||||
|
case 2:
|
||||||
|
if m.configOpts.ApiType == "anthropic" {
|
||||||
|
label = "Anthropic API Key (sk-ant-...):"
|
||||||
|
} else {
|
||||||
|
label = "API Base URL (例如 https://api.example.com/v1):"
|
||||||
|
}
|
||||||
|
case 3:
|
||||||
|
label = "API Key:"
|
||||||
|
case 4:
|
||||||
|
label = "模型名称 (例如 gpt-4o):"
|
||||||
|
}
|
||||||
|
|
||||||
|
s += fmt.Sprintf("%s\n\n%s\n\n", label, m.input.View())
|
||||||
|
s += style.SubtleStyle.Render("按 Enter 确认") + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
return style.AppStyle.Render(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commands
|
||||||
|
|
||||||
|
func checkEnvCmd() tea.Msg {
|
||||||
|
nodeVer, nodeOk := sys.CheckNode()
|
||||||
|
moltbotVer, moltbotInstalled := sys.CheckMoltbot()
|
||||||
|
return checkMsg{
|
||||||
|
nodeVer: nodeVer,
|
||||||
|
nodeOk: nodeOk,
|
||||||
|
needsNode: !nodeOk,
|
||||||
|
moltbotVer: moltbotVer,
|
||||||
|
moltbotInstalled: moltbotInstalled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func installNodeCmd() tea.Msg {
|
||||||
|
err := sys.InstallNode()
|
||||||
|
return installNodeMsg{err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configNpmCmd() tea.Msg {
|
||||||
|
err := sys.ConfigureNpmMirror()
|
||||||
|
return configNpmMsg{err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func installMoltbotCmd() tea.Msg {
|
||||||
|
err := sys.InstallMoltbotNpm("latest")
|
||||||
|
if err != nil {
|
||||||
|
return installMoltbotMsg{err: err}
|
||||||
|
}
|
||||||
|
ver, _ := sys.CheckMoltbot()
|
||||||
|
return installMoltbotMsg{version: ver, err: nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configCmd() tea.Msg {
|
||||||
|
restart, _ := sys.EnsureOnPath()
|
||||||
|
sys.RunDoctor()
|
||||||
|
return configMsg{restartPath: restart, err: nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveConfigCmd(opts sys.ConfigOptions) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
err := sys.GenerateAndWriteConfig(opts)
|
||||||
|
return saveConfigMsg{err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func uninstallCmd() tea.Msg {
|
||||||
|
err := sys.UninstallMoltbot()
|
||||||
|
return uninstallMsg{err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRandomWelcomeMsg() string {
|
||||||
|
msgs := []string{
|
||||||
|
"所有系统准备就绪",
|
||||||
|
"Moltbot 已就绪,随时为您服务",
|
||||||
|
"环境配置完成,开始使用吧",
|
||||||
|
"安装成功,期待您的使用",
|
||||||
|
}
|
||||||
|
return msgs[rand.Intn(len(msgs))]
|
||||||
|
}
|
||||||
25
moltbot/installer/main.go
Normal file
25
moltbot/installer/main.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"moltbot-installer/internal/ui"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
p := tea.NewProgram(ui.InitialModel())
|
||||||
|
m, err := p.Run()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error starting installer: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if model, ok := m.(ui.Model); ok {
|
||||||
|
if model.DidStartGateway {
|
||||||
|
fmt.Println("Web Console: http://127.0.0.1:18789/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user