mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
- Added `automatic_tasks.go` and `automatic_tasks_test.go` for backend logic and testing of automatic tasks. - Created `automatic_tasks_test.go` to validate task claiming and deletion behavior. - Developed `AutomaticTasksPage.tsx` for frontend management of automatic tasks, including task creation, editing, and execution. - Integrated device and eSIM profile selection for task configuration. - Implemented automatic task scheduling and retry logic in the backend.
2515 lines
86 KiB
Go
2515 lines
86 KiB
Go
package server
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
cryptorand "crypto/rand"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"vocat/internal/device"
|
||
"vocat/internal/modem"
|
||
"vocat/internal/store"
|
||
"vocat/internal/vowifi"
|
||
vowifiruntime "vocat/internal/vowifi/runtime"
|
||
)
|
||
|
||
const (
|
||
telegramPollInterval = 3 * time.Second
|
||
telegramNotificationPeriod = 2 * time.Second
|
||
telegramConfirmationTTL = 2 * time.Minute
|
||
telegramMenuTTL = 15 * time.Minute
|
||
telegramInputTTL = 10 * time.Minute
|
||
telegramMaxDialDuration = 10 * time.Minute
|
||
)
|
||
|
||
var telegramTokenInURLPattern = regexp.MustCompile(`bot[0-9]{5,20}:[A-Za-z0-9_-]{20,128}`)
|
||
|
||
type telegramRuntimeConfig struct {
|
||
Token string
|
||
ChatID string
|
||
AdminID int64
|
||
BaseURL string
|
||
Proxy string
|
||
}
|
||
|
||
type telegramBot struct {
|
||
server *Server
|
||
|
||
pendingMu sync.Mutex
|
||
pending map[string]telegramPendingAction
|
||
|
||
logMu sync.Mutex
|
||
lastLogTime time.Time
|
||
lastLogText string
|
||
|
||
callMu sync.Mutex
|
||
activeDials map[string]struct{}
|
||
|
||
inputMu sync.Mutex
|
||
inputs map[string]telegramInputState
|
||
}
|
||
|
||
type telegramPendingAction struct {
|
||
Kind string
|
||
DeviceID string
|
||
Argument string
|
||
Text string
|
||
Duration time.Duration
|
||
ChatID int64
|
||
AdminID int64
|
||
CreatedAt time.Time
|
||
TargetAID string
|
||
TargetICCID string
|
||
}
|
||
|
||
type telegramInputState struct {
|
||
Kind string
|
||
DeviceID string
|
||
Argument string
|
||
ChatID int64
|
||
AdminID int64
|
||
CreatedAt time.Time
|
||
}
|
||
|
||
type telegramAPIResponse struct {
|
||
OK bool `json:"ok"`
|
||
Description string `json:"description"`
|
||
Result json.RawMessage `json:"result"`
|
||
}
|
||
|
||
type telegramUpdate struct {
|
||
UpdateID int64 `json:"update_id"`
|
||
Message *telegramMessage `json:"message"`
|
||
CallbackQuery *telegramCallbackQuery `json:"callback_query"`
|
||
}
|
||
|
||
type telegramMessage struct {
|
||
MessageID int64 `json:"message_id"`
|
||
From *telegramUser `json:"from"`
|
||
Chat telegramChat `json:"chat"`
|
||
Text string `json:"text"`
|
||
}
|
||
|
||
type telegramUser struct {
|
||
ID int64 `json:"id"`
|
||
}
|
||
|
||
type telegramChat struct {
|
||
ID int64 `json:"id"`
|
||
}
|
||
|
||
type telegramCallbackQuery struct {
|
||
ID string `json:"id"`
|
||
From telegramUser `json:"from"`
|
||
Message *telegramMessage `json:"message"`
|
||
Data string `json:"data"`
|
||
}
|
||
|
||
// StartTelegramBot starts both the Telegram command poller and durable inbound
|
||
// SMS notifier. Configuration is reloaded between polls, so saving Settings
|
||
// takes effect without restarting vocat.
|
||
func (s *Server) StartTelegramBot(ctx context.Context) {
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
bot := &telegramBot{
|
||
server: s,
|
||
pending: make(map[string]telegramPendingAction),
|
||
activeDials: make(map[string]struct{}),
|
||
inputs: make(map[string]telegramInputState),
|
||
}
|
||
go bot.poll(ctx)
|
||
go bot.notifyInboundSMS(ctx)
|
||
}
|
||
|
||
func (bot *telegramBot) poll(ctx context.Context) {
|
||
activeToken := ""
|
||
var offset int64
|
||
for ctx.Err() == nil {
|
||
config, enabled, err := bot.loadConfig(ctx)
|
||
if err != nil {
|
||
bot.warn("load Telegram bot configuration", err)
|
||
if !waitTelegram(ctx, telegramPollInterval) {
|
||
return
|
||
}
|
||
continue
|
||
}
|
||
if !enabled {
|
||
activeToken = ""
|
||
offset = 0
|
||
if !waitTelegram(ctx, telegramPollInterval) {
|
||
return
|
||
}
|
||
continue
|
||
}
|
||
if config.Token != activeToken {
|
||
offset, err = bot.bootstrap(ctx, config)
|
||
if err != nil {
|
||
bot.warn("start Telegram bot polling", err)
|
||
if !waitTelegram(ctx, telegramPollInterval) {
|
||
return
|
||
}
|
||
continue
|
||
}
|
||
activeToken = config.Token
|
||
}
|
||
pollContext, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||
updates, pollErr := bot.getUpdates(pollContext, config, offset, 5)
|
||
cancel()
|
||
if pollErr != nil {
|
||
if ctx.Err() != nil {
|
||
return
|
||
}
|
||
bot.warn("poll Telegram updates", pollErr)
|
||
if !waitTelegram(ctx, telegramPollInterval) {
|
||
return
|
||
}
|
||
continue
|
||
}
|
||
for _, update := range updates {
|
||
if update.UpdateID >= offset {
|
||
offset = update.UpdateID + 1
|
||
}
|
||
update := update
|
||
go bot.handleUpdate(ctx, config, update)
|
||
}
|
||
}
|
||
}
|
||
|
||
// bootstrap discards stale Telegram updates. Replaying an old /sms, /call, or
|
||
// /switch command after a service restart would be unsafe even though each
|
||
// command has its own confirmation step.
|
||
func (bot *telegramBot) bootstrap(ctx context.Context, config telegramRuntimeConfig) (int64, error) {
|
||
requestContext, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||
defer cancel()
|
||
updates, err := bot.getUpdates(requestContext, config, -1, 0)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
var offset int64
|
||
for _, update := range updates {
|
||
if update.UpdateID >= offset {
|
||
offset = update.UpdateID + 1
|
||
}
|
||
}
|
||
commands := []map[string]string{
|
||
{"command": "menu", "description": "打开可视化操作菜单"},
|
||
{"command": "status", "description": "查看全部设备状态"},
|
||
{"command": "cancel", "description": "取消当前输入或操作"},
|
||
{"command": "help", "description": "查看帮助"},
|
||
}
|
||
if err := bot.call(requestContext, config, "setMyCommands", map[string]any{"commands": commands}, nil); err != nil {
|
||
bot.warn("register Telegram command menu", err)
|
||
}
|
||
return offset, nil
|
||
}
|
||
|
||
func (bot *telegramBot) getUpdates(
|
||
ctx context.Context,
|
||
config telegramRuntimeConfig,
|
||
offset int64,
|
||
timeout int,
|
||
) ([]telegramUpdate, error) {
|
||
payload := map[string]any{
|
||
"offset": offset,
|
||
"timeout": timeout,
|
||
"allowed_updates": []string{"message", "callback_query"},
|
||
}
|
||
var updates []telegramUpdate
|
||
if err := bot.call(ctx, config, "getUpdates", payload, &updates); err != nil {
|
||
return nil, err
|
||
}
|
||
return updates, nil
|
||
}
|
||
|
||
func (bot *telegramBot) handleUpdate(ctx context.Context, config telegramRuntimeConfig, update telegramUpdate) {
|
||
if callback := update.CallbackQuery; callback != nil {
|
||
if callback.Message == nil || !bot.authorized(config, callback.Message.Chat.ID, callback.From.ID) {
|
||
_ = bot.answerCallback(ctx, config, callback.ID, "无权限")
|
||
return
|
||
}
|
||
_ = bot.answerCallback(ctx, config, callback.ID, "")
|
||
bot.handleCallback(ctx, config, callback)
|
||
return
|
||
}
|
||
message := update.Message
|
||
if message == nil || message.From == nil || !bot.authorized(config, message.Chat.ID, message.From.ID) {
|
||
return
|
||
}
|
||
command, remainder := parseTelegramCommand(message.Text)
|
||
if _, waiting := bot.input(message.Chat.ID, message.From.ID); waiting &&
|
||
command != "menu" && command != "start" && command != "cancel" {
|
||
bot.handleInputMessage(ctx, config, message)
|
||
return
|
||
}
|
||
if command == "" {
|
||
bot.handleInputMessage(ctx, config, message)
|
||
return
|
||
}
|
||
switch command {
|
||
case "start", "menu":
|
||
bot.sendMainMenu(ctx, config, message.Chat.ID)
|
||
case "help":
|
||
bot.sendHelp(ctx, config, message.Chat.ID)
|
||
case "cancel":
|
||
bot.clearInput(message.Chat.ID, message.From.ID)
|
||
bot.sendText(ctx, config, message.Chat.ID, "已取消当前输入或操作。", bot.homeKeyboard())
|
||
case "status", "devices":
|
||
bot.sendDeviceStatus(ctx, config, message.Chat.ID, strings.TrimSpace(remainder))
|
||
case "esim":
|
||
deviceID := strings.TrimSpace(remainder)
|
||
if deviceID == "" {
|
||
bot.sendDevicePicker(ctx, config, message.Chat.ID, message.From.ID, "esim")
|
||
} else {
|
||
bot.sendESIMProfiles(ctx, config, message.Chat.ID, message.From.ID, deviceID)
|
||
}
|
||
case "switch":
|
||
parts := strings.Fields(remainder)
|
||
if len(parts) == 0 {
|
||
bot.sendDevicePicker(ctx, config, message.Chat.ID, message.From.ID, "esim")
|
||
return
|
||
}
|
||
if len(parts) == 1 {
|
||
bot.sendESIMProfiles(ctx, config, message.Chat.ID, message.From.ID, parts[0])
|
||
return
|
||
}
|
||
if len(parts) != 2 {
|
||
bot.sendText(ctx, config, message.Chat.ID, "用法:/switch <设备ID> <目标ICCID>", nil)
|
||
return
|
||
}
|
||
bot.confirmESIMSwitch(ctx, config, message.Chat.ID, message.From.ID, parts[0], parts[1])
|
||
case "wfc", "wificalling":
|
||
parts := strings.Fields(remainder)
|
||
if len(parts) == 0 {
|
||
bot.sendDevicePicker(ctx, config, message.Chat.ID, message.From.ID, "wfc")
|
||
return
|
||
}
|
||
if len(parts) == 1 {
|
||
bot.sendVoWiFiMenu(ctx, config, message.Chat.ID, message.From.ID, parts[0])
|
||
return
|
||
}
|
||
if len(parts) != 2 {
|
||
bot.sendText(ctx, config, message.Chat.ID, "请选择菜单按钮,或使用 /wfc <设备ID> <status|on|off|reconnect>。", bot.homeKeyboard())
|
||
return
|
||
}
|
||
bot.handleVoWiFi(ctx, config, message.Chat.ID, message.From.ID, parts[0], parts[1])
|
||
case "sms":
|
||
parts := splitTelegramArguments(remainder, 3)
|
||
if len(parts) == 0 {
|
||
bot.sendDevicePicker(ctx, config, message.Chat.ID, message.From.ID, "sms")
|
||
return
|
||
}
|
||
if len(parts) == 1 {
|
||
bot.beginInput(ctx, config, message.Chat.ID, message.From.ID, parts[0], "sms_phone", "请输入收件人号码:")
|
||
return
|
||
}
|
||
if len(parts) != 3 {
|
||
bot.sendText(ctx, config, message.Chat.ID, "用法:/sms <设备ID> <号码> <短信内容>", nil)
|
||
return
|
||
}
|
||
bot.confirmSMS(ctx, config, message.Chat.ID, message.From.ID, parts[0], parts[1], parts[2])
|
||
case "call":
|
||
parts := strings.Fields(remainder)
|
||
if len(parts) == 0 {
|
||
bot.sendDevicePicker(ctx, config, message.Chat.ID, message.From.ID, "call")
|
||
return
|
||
}
|
||
if len(parts) == 1 {
|
||
bot.beginInput(ctx, config, message.Chat.ID, message.From.ID, parts[0], "call_number", "请输入要拨打的电话号码:")
|
||
return
|
||
}
|
||
if len(parts) != 3 {
|
||
bot.sendText(ctx, config, message.Chat.ID, "用法:/call <设备ID> <号码> <持续秒数>\n拨号后将在指定时间自动挂断,不处理通话音频。", nil)
|
||
return
|
||
}
|
||
seconds, err := strconv.Atoi(parts[2])
|
||
if err != nil || seconds < 1 || time.Duration(seconds)*time.Second > telegramMaxDialDuration {
|
||
bot.sendText(ctx, config, message.Chat.ID, "持续时间必须是 1–600 秒。", nil)
|
||
return
|
||
}
|
||
bot.confirmCall(ctx, config, message.Chat.ID, message.From.ID, parts[0], parts[1], time.Duration(seconds)*time.Second)
|
||
case "answer":
|
||
if strings.TrimSpace(remainder) == "" {
|
||
bot.sendDevicePicker(ctx, config, message.Chat.ID, message.From.ID, "answer")
|
||
} else {
|
||
bot.executeSimpleCallAction(ctx, config, message.Chat.ID, message.From.ID, strings.TrimSpace(remainder), "answer")
|
||
}
|
||
case "hangup":
|
||
if strings.TrimSpace(remainder) == "" {
|
||
bot.sendDevicePicker(ctx, config, message.Chat.ID, message.From.ID, "hangup")
|
||
} else {
|
||
bot.executeSimpleCallAction(ctx, config, message.Chat.ID, message.From.ID, strings.TrimSpace(remainder), "hangup")
|
||
}
|
||
case "calls":
|
||
if strings.TrimSpace(remainder) == "" {
|
||
bot.sendDevicePicker(ctx, config, message.Chat.ID, message.From.ID, "calls")
|
||
} else {
|
||
bot.executeSimpleCallAction(ctx, config, message.Chat.ID, message.From.ID, strings.TrimSpace(remainder), "status")
|
||
}
|
||
case "at":
|
||
parts := splitTelegramArguments(remainder, 2)
|
||
if len(parts) == 0 {
|
||
bot.sendDevicePicker(ctx, config, message.Chat.ID, message.From.ID, "at")
|
||
return
|
||
}
|
||
if len(parts) == 1 {
|
||
bot.beginInput(ctx, config, message.Chat.ID, message.From.ID, parts[0], "at", "请直接发送一条 AT 指令,例如 AT+CSQ:")
|
||
return
|
||
}
|
||
if len(parts) != 2 {
|
||
bot.sendText(ctx, config, message.Chat.ID, "用法:/at <设备ID> <AT指令>\n示例:/at EC20 AT+CSQ", nil)
|
||
return
|
||
}
|
||
bot.handleATCommand(ctx, config, message.Chat.ID, message.From.ID, parts[0], parts[1])
|
||
case "ussd":
|
||
parts := strings.Fields(remainder)
|
||
if len(parts) == 0 {
|
||
bot.sendDevicePicker(ctx, config, message.Chat.ID, message.From.ID, "ussd")
|
||
return
|
||
}
|
||
if len(parts) == 1 {
|
||
bot.beginInput(ctx, config, message.Chat.ID, message.From.ID, parts[0], "ussd", "请直接发送 USSD 代码,例如 *100#:")
|
||
return
|
||
}
|
||
if len(parts) != 2 {
|
||
bot.sendText(ctx, config, message.Chat.ID, "用法:/ussd <设备ID> <USSD代码>\n示例:/ussd EC20 *100#", nil)
|
||
return
|
||
}
|
||
bot.handleUSSDCommand(ctx, config, message.Chat.ID, message.From.ID, parts[0], parts[1])
|
||
case "ussd_reply":
|
||
parts := splitTelegramArguments(remainder, 2)
|
||
if len(parts) != 2 {
|
||
bot.sendText(ctx, config, message.Chat.ID, "用法:/ussd_reply <会话ID> <回复内容>", nil)
|
||
return
|
||
}
|
||
bot.handleUSSDReply(ctx, config, message.Chat.ID, message.From.ID, parts[0], parts[1])
|
||
case "ussd_cancel":
|
||
sessionID := strings.TrimSpace(remainder)
|
||
if sessionID == "" || strings.ContainsAny(sessionID, " \t\r\n") {
|
||
bot.sendText(ctx, config, message.Chat.ID, "用法:/ussd_cancel <会话ID>", nil)
|
||
return
|
||
}
|
||
bot.handleUSSDCancel(ctx, config, message.Chat.ID, message.From.ID, sessionID)
|
||
default:
|
||
bot.sendText(ctx, config, message.Chat.ID, "未知命令,请使用下方操作菜单。", bot.homeKeyboard())
|
||
}
|
||
}
|
||
|
||
func (bot *telegramBot) handleCallback(ctx context.Context, config telegramRuntimeConfig, callback *telegramCallbackQuery) {
|
||
data := strings.TrimSpace(callback.Data)
|
||
chatID, adminID := callback.Message.Chat.ID, callback.From.ID
|
||
if data == "menu:home" || data == "menu:devices" {
|
||
if data == "menu:home" {
|
||
bot.sendMainMenu(ctx, config, chatID)
|
||
} else {
|
||
bot.sendDevicePicker(ctx, config, chatID, adminID, "")
|
||
}
|
||
return
|
||
}
|
||
if data == "menu:status" {
|
||
bot.sendDeviceStatus(ctx, config, chatID, "")
|
||
return
|
||
}
|
||
if data == "menu:help" {
|
||
bot.sendHelp(ctx, config, chatID)
|
||
return
|
||
}
|
||
if data == "input:cancel" {
|
||
bot.clearInput(chatID, adminID)
|
||
bot.sendText(ctx, config, chatID, "已取消输入。", bot.homeKeyboard())
|
||
return
|
||
}
|
||
if strings.HasPrefix(data, "pick:") {
|
||
action, ok := bot.takePending(strings.TrimPrefix(data, "pick:"), chatID, adminID)
|
||
if !ok || action.Kind != "menu_pick" {
|
||
bot.sendExpiredMenu(ctx, config, chatID)
|
||
return
|
||
}
|
||
bot.dispatchDeviceChoice(ctx, config, chatID, adminID, action.DeviceID, action.Argument)
|
||
return
|
||
}
|
||
if prefix, token, operation, ok := parseTelegramMenuCallback(data); ok {
|
||
consume := prefix == "es" || prefix == "dur" || prefix == "uc"
|
||
var action telegramPendingAction
|
||
var found bool
|
||
if consume {
|
||
action, found = bot.takePending(token, chatID, adminID)
|
||
} else {
|
||
action, found = bot.getPending(token, chatID, adminID)
|
||
}
|
||
if !found {
|
||
bot.sendExpiredMenu(ctx, config, chatID)
|
||
return
|
||
}
|
||
switch prefix {
|
||
case "d":
|
||
if action.Kind != "menu_device" {
|
||
bot.sendExpiredMenu(ctx, config, chatID)
|
||
return
|
||
}
|
||
bot.dispatchDeviceChoice(ctx, config, chatID, adminID, action.DeviceID, operation)
|
||
case "w":
|
||
if action.Kind != "menu_device" {
|
||
bot.sendExpiredMenu(ctx, config, chatID)
|
||
return
|
||
}
|
||
bot.handleVoWiFi(ctx, config, chatID, adminID, action.DeviceID, operation)
|
||
case "call":
|
||
if action.Kind != "menu_device" {
|
||
bot.sendExpiredMenu(ctx, config, chatID)
|
||
return
|
||
}
|
||
bot.dispatchCallAction(ctx, config, chatID, adminID, action.DeviceID, operation)
|
||
case "es":
|
||
if action.Kind != "menu_esim_profile" {
|
||
bot.sendExpiredMenu(ctx, config, chatID)
|
||
return
|
||
}
|
||
bot.confirmESIMSwitch(ctx, config, chatID, adminID, action.DeviceID, action.TargetICCID)
|
||
case "dur":
|
||
if action.Kind != "menu_call_duration" {
|
||
bot.sendExpiredMenu(ctx, config, chatID)
|
||
return
|
||
}
|
||
seconds, err := strconv.Atoi(operation)
|
||
if err != nil || seconds < 1 || time.Duration(seconds)*time.Second > telegramMaxDialDuration {
|
||
bot.sendText(ctx, config, chatID, "自动挂断时间无效。", bot.homeKeyboard())
|
||
return
|
||
}
|
||
bot.confirmCall(ctx, config, chatID, adminID, action.DeviceID, action.Argument, time.Duration(seconds)*time.Second)
|
||
case "uc":
|
||
if action.Kind != "menu_ussd_session" {
|
||
bot.sendExpiredMenu(ctx, config, chatID)
|
||
return
|
||
}
|
||
bot.clearInput(chatID, adminID)
|
||
bot.handleUSSDCancel(ctx, config, chatID, adminID, action.Argument)
|
||
}
|
||
return
|
||
}
|
||
decision, token, found := strings.Cut(data, ":")
|
||
if !found || (decision != "confirm" && decision != "cancel") {
|
||
return
|
||
}
|
||
action, ok := bot.takePending(token, callback.Message.Chat.ID, callback.From.ID)
|
||
if !ok {
|
||
bot.sendText(ctx, config, callback.Message.Chat.ID, "该确认已过期或已处理。", nil)
|
||
return
|
||
}
|
||
if decision == "cancel" {
|
||
bot.sendText(ctx, config, callback.Message.Chat.ID, "操作已取消。", bot.homeKeyboard())
|
||
return
|
||
}
|
||
switch action.Kind {
|
||
case "sms":
|
||
bot.sendText(ctx, config, action.ChatID, "正在提交短信…", nil)
|
||
result, err := bot.executeSMS(ctx, action)
|
||
bot.finishAction(ctx, config, action, "telegram.sms.send", result, err)
|
||
case "esim_switch":
|
||
bot.sendText(ctx, config, action.ChatID, "正在切换 Profile 并等待模块恢复校验…", nil)
|
||
result, err := bot.executeESIMSwitch(ctx, action)
|
||
bot.finishAction(ctx, config, action, "telegram.esim.switch", result, err)
|
||
case "call":
|
||
result, err := bot.executeTimedCall(ctx, config, action)
|
||
bot.finishAction(ctx, config, action, "telegram.call.dial", result, err)
|
||
}
|
||
}
|
||
|
||
func parseTelegramMenuCallback(data string) (prefix, token, operation string, ok bool) {
|
||
parts := strings.SplitN(data, ":", 3)
|
||
if len(parts) != 3 || parts[1] == "" || parts[2] == "" {
|
||
return "", "", "", false
|
||
}
|
||
switch parts[0] {
|
||
case "d", "w", "call", "es", "dur", "uc":
|
||
return parts[0], parts[1], parts[2], true
|
||
default:
|
||
return "", "", "", false
|
||
}
|
||
}
|
||
|
||
func telegramKeyboard(rows ...[]map[string]string) map[string]any {
|
||
return map[string]any{"inline_keyboard": rows}
|
||
}
|
||
|
||
func telegramButton(text, data string) map[string]string {
|
||
return map[string]string{"text": text, "callback_data": data}
|
||
}
|
||
|
||
func (bot *telegramBot) homeKeyboard() map[string]any {
|
||
return telegramKeyboard([]map[string]string{
|
||
telegramButton("📱 选择设备", "menu:devices"),
|
||
telegramButton("🏠 主菜单", "menu:home"),
|
||
})
|
||
}
|
||
|
||
func (bot *telegramBot) sendMainMenu(ctx context.Context, config telegramRuntimeConfig, chatID int64) {
|
||
bot.clearInput(chatID, config.AdminID)
|
||
bot.sendText(ctx, config, chatID,
|
||
"Vocat 控制中心\n\n请选择设备或直接查看全部设备状态。发送 /menu 可随时返回这里。",
|
||
telegramKeyboard(
|
||
[]map[string]string{telegramButton("📱 设备操作", "menu:devices")},
|
||
[]map[string]string{
|
||
telegramButton("📊 全部状态", "menu:status"),
|
||
telegramButton("❓ 帮助", "menu:help"),
|
||
},
|
||
),
|
||
)
|
||
}
|
||
|
||
func (bot *telegramBot) sendDevicePicker(
|
||
ctx context.Context,
|
||
config telegramRuntimeConfig,
|
||
chatID, adminID int64,
|
||
next string,
|
||
) {
|
||
configs, err := bot.server.store.ListDevices(ctx)
|
||
if err != nil {
|
||
bot.sendText(ctx, config, chatID, "读取设备失败:"+err.Error(), bot.homeKeyboard())
|
||
return
|
||
}
|
||
rows := make([][]map[string]string, 0, len(configs)+1)
|
||
for _, stored := range configs {
|
||
_, _, present := bot.server.physicalForConfig(stored)
|
||
icon := "⚫"
|
||
if present {
|
||
icon = "🟢"
|
||
}
|
||
token, tokenErr := bot.putPending(telegramPendingAction{
|
||
Kind: "menu_pick", DeviceID: stored.ID, Argument: next,
|
||
ChatID: chatID, AdminID: adminID, CreatedAt: time.Now(),
|
||
})
|
||
if tokenErr != nil {
|
||
continue
|
||
}
|
||
label := fmt.Sprintf("%s %s", icon, firstNonEmpty(stored.Name, stored.ID))
|
||
if stored.Name != "" && stored.Name != stored.ID {
|
||
label += " · " + stored.ID
|
||
}
|
||
rows = append(rows, []map[string]string{telegramButton(truncateTelegramButton(label), "pick:"+token)})
|
||
}
|
||
rows = append(rows, []map[string]string{telegramButton("🏠 主菜单", "menu:home")})
|
||
if len(configs) == 0 {
|
||
bot.sendText(ctx, config, chatID, "当前没有已配置设备。", telegramKeyboard(rows...))
|
||
return
|
||
}
|
||
title := "请选择要操作的设备"
|
||
if next != "" {
|
||
title += ":"
|
||
}
|
||
bot.sendText(ctx, config, chatID, title, telegramKeyboard(rows...))
|
||
}
|
||
|
||
func truncateTelegramButton(value string) string {
|
||
value = strings.TrimSpace(value)
|
||
runes := []rune(value)
|
||
if len(runes) <= 48 {
|
||
return value
|
||
}
|
||
return string(runes[:47]) + "…"
|
||
}
|
||
|
||
func (bot *telegramBot) sendDeviceMenu(
|
||
ctx context.Context,
|
||
config telegramRuntimeConfig,
|
||
chatID, adminID int64,
|
||
deviceID string,
|
||
) {
|
||
stored, err := bot.server.store.Device(ctx, deviceID)
|
||
if err != nil {
|
||
bot.sendText(ctx, config, chatID, "读取设备失败:"+err.Error(), bot.homeKeyboard())
|
||
return
|
||
}
|
||
_, _, present := bot.server.physicalForConfig(stored)
|
||
token, err := bot.putPending(telegramPendingAction{
|
||
Kind: "menu_device", DeviceID: deviceID, ChatID: chatID, AdminID: adminID, CreatedAt: time.Now(),
|
||
})
|
||
if err != nil {
|
||
bot.sendText(ctx, config, chatID, "创建设备菜单失败:"+err.Error(), bot.homeKeyboard())
|
||
return
|
||
}
|
||
status := "⚫ 离线"
|
||
if present {
|
||
status = "🟢 在线"
|
||
}
|
||
bot.sendText(ctx, config, chatID,
|
||
fmt.Sprintf("📡 %s\n设备 ID:%s\n状态:%s\n\n请选择功能:", firstNonEmpty(stored.Name, deviceID), deviceID, status),
|
||
telegramKeyboard(
|
||
[]map[string]string{
|
||
telegramButton("📊 状态", "d:"+token+":status"),
|
||
telegramButton("📲 eSIM", "d:"+token+":esim"),
|
||
},
|
||
[]map[string]string{
|
||
telegramButton("📶 VoWiFi", "d:"+token+":wfc"),
|
||
telegramButton("✉️ 发送短信", "d:"+token+":sms"),
|
||
},
|
||
[]map[string]string{
|
||
telegramButton("📞 通话", "d:"+token+":call"),
|
||
telegramButton("🛠 AT / USSD", "d:"+token+":tools"),
|
||
},
|
||
[]map[string]string{
|
||
telegramButton("⬅️ 设备列表", "menu:devices"),
|
||
telegramButton("🏠 主菜单", "menu:home"),
|
||
},
|
||
),
|
||
)
|
||
}
|
||
|
||
func (bot *telegramBot) dispatchDeviceChoice(
|
||
ctx context.Context,
|
||
config telegramRuntimeConfig,
|
||
chatID, adminID int64,
|
||
deviceID, operation string,
|
||
) {
|
||
switch operation {
|
||
case "":
|
||
bot.sendDeviceMenu(ctx, config, chatID, adminID, deviceID)
|
||
case "status":
|
||
bot.sendDeviceStatus(ctx, config, chatID, deviceID)
|
||
case "esim":
|
||
bot.sendESIMProfiles(ctx, config, chatID, adminID, deviceID)
|
||
case "wfc":
|
||
bot.sendVoWiFiMenu(ctx, config, chatID, adminID, deviceID)
|
||
case "sms":
|
||
bot.beginInput(ctx, config, chatID, adminID, deviceID, "sms_phone", "请输入收件人号码:")
|
||
case "call":
|
||
bot.sendCallMenu(ctx, config, chatID, adminID, deviceID)
|
||
case "tools":
|
||
bot.sendToolsMenu(ctx, config, chatID, adminID, deviceID)
|
||
case "at":
|
||
bot.beginInput(ctx, config, chatID, adminID, deviceID, "at", "请直接发送一条 AT 指令,例如 AT+CSQ:")
|
||
case "ussd":
|
||
bot.beginInput(ctx, config, chatID, adminID, deviceID, "ussd", "请直接发送 USSD 代码,例如 *100#:")
|
||
case "answer", "hangup", "calls":
|
||
action := map[string]string{"answer": "answer", "hangup": "hangup", "calls": "status"}[operation]
|
||
bot.executeSimpleCallAction(ctx, config, chatID, adminID, deviceID, action)
|
||
default:
|
||
bot.sendDeviceMenu(ctx, config, chatID, adminID, deviceID)
|
||
}
|
||
}
|
||
|
||
func (bot *telegramBot) sendVoWiFiMenu(ctx context.Context, config telegramRuntimeConfig, chatID, adminID int64, deviceID string) {
|
||
stored, _, _, err := bot.device(deviceID)
|
||
if err != nil {
|
||
bot.sendText(ctx, config, chatID, "VoWiFi 不可用:"+err.Error(), bot.homeKeyboard())
|
||
return
|
||
}
|
||
if bot.server.vowifi == nil {
|
||
bot.sendText(ctx, config, chatID, "VoWiFi runtime 不可用。", bot.homeKeyboard())
|
||
return
|
||
}
|
||
state, stateErr := bot.server.vowifi.State(deviceID)
|
||
if stateErr != nil {
|
||
bot.sendText(ctx, config, chatID, "读取 VoWiFi 状态失败:"+stateErr.Error(), bot.homeKeyboard())
|
||
return
|
||
}
|
||
token, err := bot.putPending(telegramPendingAction{Kind: "menu_device", DeviceID: deviceID, ChatID: chatID, AdminID: adminID, CreatedAt: time.Now()})
|
||
if err != nil {
|
||
return
|
||
}
|
||
bot.sendText(ctx, config, chatID,
|
||
fmt.Sprintf("📶 %s · VoWiFi\n策略:%s\n阶段:%s\nTunnel:%t · IMS:%t · SMS:%t", deviceID, map[bool]string{true: "已启用", false: "已关闭"}[stored.VoWiFiEnabled], firstNonEmpty(string(state.Phase), "idle"), state.TunnelReady, state.IMSReady, state.SMSReady),
|
||
telegramKeyboard(
|
||
[]map[string]string{
|
||
telegramButton("✅ 开启", "w:"+token+":on"),
|
||
telegramButton("⛔ 关闭", "w:"+token+":off"),
|
||
},
|
||
[]map[string]string{
|
||
telegramButton("🔄 重新连接", "w:"+token+":reconnect"),
|
||
telegramButton("📊 刷新状态", "w:"+token+":status"),
|
||
},
|
||
[]map[string]string{telegramButton("⬅️ 设备功能", "d:"+token+":menu")},
|
||
),
|
||
)
|
||
}
|
||
|
||
func (bot *telegramBot) sendCallMenu(ctx context.Context, config telegramRuntimeConfig, chatID, adminID int64, deviceID string) {
|
||
if _, _, _, err := bot.device(deviceID); err != nil {
|
||
bot.sendText(ctx, config, chatID, "通话功能不可用:"+err.Error(), bot.homeKeyboard())
|
||
return
|
||
}
|
||
token, err := bot.putPending(telegramPendingAction{Kind: "menu_device", DeviceID: deviceID, ChatID: chatID, AdminID: adminID, CreatedAt: time.Now()})
|
||
if err != nil {
|
||
return
|
||
}
|
||
bot.sendText(ctx, config, chatID, "📞 "+deviceID+" · 通话\n请选择操作:",
|
||
telegramKeyboard(
|
||
[]map[string]string{telegramButton("📱 拨打电话", "call:"+token+":dial")},
|
||
[]map[string]string{
|
||
telegramButton("✅ 接听", "call:"+token+":answer"),
|
||
telegramButton("🔴 挂断", "call:"+token+":hangup"),
|
||
},
|
||
[]map[string]string{telegramButton("📋 当前通话", "call:"+token+":status")},
|
||
[]map[string]string{telegramButton("⬅️ 设备功能", "d:"+token+":menu")},
|
||
),
|
||
)
|
||
}
|
||
|
||
func (bot *telegramBot) dispatchCallAction(ctx context.Context, config telegramRuntimeConfig, chatID, adminID int64, deviceID, operation string) {
|
||
switch operation {
|
||
case "dial":
|
||
bot.beginInput(ctx, config, chatID, adminID, deviceID, "call_number", "请输入要拨打的电话号码:")
|
||
case "answer", "hangup", "status":
|
||
bot.executeSimpleCallAction(ctx, config, chatID, adminID, deviceID, operation)
|
||
default:
|
||
bot.sendCallMenu(ctx, config, chatID, adminID, deviceID)
|
||
}
|
||
}
|
||
|
||
func (bot *telegramBot) sendToolsMenu(ctx context.Context, config telegramRuntimeConfig, chatID, adminID int64, deviceID string) {
|
||
token, err := bot.putPending(telegramPendingAction{Kind: "menu_device", DeviceID: deviceID, ChatID: chatID, AdminID: adminID, CreatedAt: time.Now()})
|
||
if err != nil {
|
||
return
|
||
}
|
||
bot.sendText(ctx, config, chatID, "🛠 "+deviceID+" · 调试与运营商指令\n请选择输入类型:",
|
||
telegramKeyboard(
|
||
[]map[string]string{
|
||
telegramButton("⌨️ AT 指令", "d:"+token+":at"),
|
||
telegramButton("📟 USSD", "d:"+token+":ussd"),
|
||
},
|
||
[]map[string]string{telegramButton("⬅️ 设备功能", "d:"+token+":menu")},
|
||
),
|
||
)
|
||
}
|
||
|
||
func (bot *telegramBot) sendExpiredMenu(ctx context.Context, config telegramRuntimeConfig, chatID int64) {
|
||
bot.sendText(ctx, config, chatID, "该菜单已过期,请重新选择。", bot.homeKeyboard())
|
||
}
|
||
|
||
func (bot *telegramBot) sendHelp(ctx context.Context, config telegramRuntimeConfig, chatID int64) {
|
||
text := strings.Join([]string{
|
||
"Vocat Telegram 控制", "",
|
||
"推荐直接发送 /menu,使用按钮完成设备和功能选择。以下命令仅用于兼容和高级操作:", "",
|
||
"/status [设备ID] — 查看设备、SIM、蜂窝与 VoWiFi 状态",
|
||
"/esim <设备ID> — 只读查看已安装 Profile",
|
||
"/switch <设备ID> <ICCID> — 切换到已安装 Profile(需确认)",
|
||
"/wfc <设备ID> <status|on|off|reconnect> — 管理 WiFi Calling",
|
||
"/sms <设备ID> <号码> <内容> — 发送短信(需确认)",
|
||
"/call <设备ID> <号码> <秒数> — 拨号并在 1–600 秒后自动挂断(需确认)",
|
||
"/calls <设备ID> — 查看模块当前通话",
|
||
"/answer <设备ID> — 按当前通道接听来电",
|
||
"/hangup <设备ID> — 立即挂断",
|
||
"/at <设备ID> <AT指令> — 执行经过安全校验的单行 AT 指令",
|
||
"/ussd <设备ID> <代码> — 发送 USSD 指令",
|
||
"/ussd_reply <会话ID> <内容> — 回复交互式 USSD 菜单",
|
||
"/ussd_cancel <会话ID> — 取消交互式 USSD 会话",
|
||
"",
|
||
"Bot 不提供 eSIM 下载、删除或改名,也不采集或转发通话音频。控制命令只接受设置中的 Admin ID。",
|
||
}, "\n")
|
||
keyboard := telegramKeyboard(
|
||
[]map[string]string{telegramButton("📱 使用操作菜单", "menu:devices")},
|
||
[]map[string]string{telegramButton("🏠 主菜单", "menu:home")},
|
||
)
|
||
bot.sendText(ctx, config, chatID, text, keyboard)
|
||
}
|
||
|
||
func (bot *telegramBot) sendDeviceStatus(ctx context.Context, config telegramRuntimeConfig, chatID int64, onlyID string) {
|
||
configs, err := bot.server.store.ListDevices(ctx)
|
||
if err != nil {
|
||
bot.sendText(ctx, config, chatID, "读取设备失败:"+err.Error(), nil)
|
||
return
|
||
}
|
||
var blocks []string
|
||
for _, stored := range configs {
|
||
if onlyID != "" && stored.ID != onlyID {
|
||
continue
|
||
}
|
||
entry, _, present := bot.server.physicalForConfig(stored)
|
||
lines := []string{fmt.Sprintf("📡 %s (%s)", firstNonEmpty(stored.Name, stored.ID), stored.ID)}
|
||
var wfcState *vowifi.State
|
||
if bot.server.vowifi != nil {
|
||
if state, stateErr := bot.server.vowifi.State(stored.ID); stateErr == nil {
|
||
wfcState = &state
|
||
}
|
||
}
|
||
if !present {
|
||
lines = append(lines, "设备:离线")
|
||
} else {
|
||
lines = append(lines, "设备:在线 · "+strings.ToUpper(firstNonEmpty(stored.DeviceBackend, "AT")))
|
||
if snapshot := entry.Snapshot; snapshot != nil {
|
||
associationNumber := ""
|
||
if snapshot.ICCID != "" {
|
||
if association, associationErr := bot.server.store.PhoneAssociation(ctx, snapshot.ICCID); associationErr == nil {
|
||
associationNumber = association.Number
|
||
}
|
||
}
|
||
lines = append(lines,
|
||
"SIM:"+map[bool]string{true: "Ready", false: firstNonEmpty(snapshot.SIMStatus, "未就绪")}[snapshot.SIMReady],
|
||
"IMEI:"+firstNonEmpty(snapshot.IMEI, stored.ModemIMEI, "--"),
|
||
"ICCID:"+firstNonEmpty(snapshot.ICCID, "--"),
|
||
"IMSI:"+firstNonEmpty(snapshot.IMSI, "--"),
|
||
"号码:"+resolveTelegramPhoneNumber(associationNumber, wfcState, snapshot),
|
||
"原运营商:"+telegramHomeCarrier(snapshot.IMSI, snapshot.SPN),
|
||
"当前网络:"+telegramCurrentNetwork(snapshot),
|
||
"蜂窝模式:"+map[bool]string{true: "飞行模式", false: "开启"}[snapshot.FlightMode],
|
||
)
|
||
if module := telegramModuleLine(snapshot); module != "" {
|
||
lines = append(lines, module)
|
||
}
|
||
if signal := telegramSignalLine(snapshot); signal != "" {
|
||
lines = append(lines, signal)
|
||
}
|
||
}
|
||
}
|
||
if wfcState != nil {
|
||
lines = append(lines,
|
||
fmt.Sprintf("VoWiFi:%s · Tunnel=%t IMS=%t SMS=%t", firstNonEmpty(string(wfcState.Phase), "idle"), wfcState.TunnelReady, wfcState.IMSReady, wfcState.SMSReady),
|
||
)
|
||
if wfcState.LastError != "" {
|
||
lines = append(lines, "最后错误:"+wfcState.LastError)
|
||
}
|
||
}
|
||
blocks = append(blocks, strings.Join(lines, "\n"))
|
||
}
|
||
if len(blocks) == 0 {
|
||
bot.sendText(ctx, config, chatID, "未找到设备 "+onlyID, nil)
|
||
return
|
||
}
|
||
bot.sendText(ctx, config, chatID, strings.Join(blocks, "\n\n"), bot.homeKeyboard())
|
||
}
|
||
|
||
func resolveTelegramPhoneNumber(associationNumber string, state *vowifi.State, snapshot *device.Snapshot) string {
|
||
if usableTelegramPhoneNumber(associationNumber) {
|
||
return strings.TrimSpace(associationNumber)
|
||
}
|
||
currentICCID := ""
|
||
if snapshot != nil {
|
||
currentICCID = strings.TrimSpace(snapshot.ICCID)
|
||
}
|
||
if state != nil && usableTelegramPhoneNumber(state.PhoneNumber) {
|
||
stateICCID := strings.TrimSpace(state.ICCID)
|
||
if currentICCID == "" || (stateICCID != "" && strings.EqualFold(currentICCID, stateICCID)) {
|
||
return strings.TrimSpace(state.PhoneNumber)
|
||
}
|
||
}
|
||
if snapshot != nil && usableTelegramPhoneNumber(snapshot.Phone.Number) {
|
||
return strings.TrimSpace(snapshot.Phone.Number)
|
||
}
|
||
return "--"
|
||
}
|
||
|
||
func usableTelegramPhoneNumber(value string) bool {
|
||
value = strings.TrimSpace(value)
|
||
if strings.HasPrefix(value, "+") {
|
||
value = value[1:]
|
||
}
|
||
var digits []byte
|
||
for index := 0; index < len(value); index++ {
|
||
switch character := value[index]; {
|
||
case character >= '0' && character <= '9':
|
||
digits = append(digits, character)
|
||
case character == ' ' || character == '-' || character == '(' || character == ')':
|
||
continue
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
if len(digits) < 5 || len(digits) > 20 {
|
||
return false
|
||
}
|
||
allSame := true
|
||
for _, digit := range digits[1:] {
|
||
if digit != digits[0] {
|
||
allSame = false
|
||
break
|
||
}
|
||
}
|
||
return !allSame
|
||
}
|
||
|
||
func telegramHomeCarrier(imsi string, spn ...string) string {
|
||
plmn, name, country, ok := device.CarrierForIMSI(imsi)
|
||
if !ok {
|
||
if len(spn) > 0 && strings.TrimSpace(spn[0]) != "" {
|
||
return strings.TrimSpace(spn[0])
|
||
}
|
||
return "--"
|
||
}
|
||
if len(spn) > 0 && strings.TrimSpace(spn[0]) != "" {
|
||
brand := strings.TrimSpace(spn[0])
|
||
brandCountry := country
|
||
if strings.Contains(strings.ToLower(brand), "lebara") && strings.HasPrefix(strings.TrimSpace(imsi), "20404") {
|
||
brandCountry = "GB"
|
||
}
|
||
return strings.TrimSpace(strings.Join([]string{telegramCountryFlag(brandCountry), brand, "(认证核心 " + plmn + ")"}, " "))
|
||
}
|
||
return strings.TrimSpace(strings.Join([]string{telegramCountryFlag(country), name, "(" + plmn + ")"}, " "))
|
||
}
|
||
|
||
func telegramCurrentNetwork(snapshot *device.Snapshot) string {
|
||
if snapshot == nil {
|
||
return "--"
|
||
}
|
||
if snapshot.FlightMode || snapshot.RadioOff {
|
||
return "--(飞行模式)"
|
||
}
|
||
operatorName := strings.TrimSpace(snapshot.OperatorName)
|
||
country := ""
|
||
if databaseName, databaseCountry, ok := device.CarrierForPLMN(snapshot.OperatorCode); ok {
|
||
if operatorName == "" {
|
||
operatorName = databaseName
|
||
}
|
||
country = databaseCountry
|
||
}
|
||
operator := firstNonEmpty(operatorName, strings.TrimSpace(snapshot.OperatorCode), "--")
|
||
if flag := telegramCountryFlag(country); flag != "" && operator != "--" {
|
||
operator = flag + " " + operator
|
||
}
|
||
parts := []string{operator, telegramRegistrationText(snapshot.RegistrationStatus)}
|
||
if radio := strings.TrimSpace(strings.Join([]string{snapshot.AccessTech, snapshot.Band}, " ")); radio != "" {
|
||
parts = append(parts, radio)
|
||
}
|
||
return strings.Join(parts, " · ")
|
||
}
|
||
|
||
func telegramRegistrationText(status int) string {
|
||
switch status {
|
||
case 1:
|
||
return "已驻网"
|
||
case 5:
|
||
return "已驻网(漫游)"
|
||
case 2:
|
||
return "搜索中"
|
||
case 3:
|
||
return "驻网被拒绝"
|
||
default:
|
||
return "未驻网"
|
||
}
|
||
}
|
||
|
||
func telegramCountryFlag(country string) string {
|
||
country = strings.ToUpper(strings.TrimSpace(country))
|
||
if len(country) != 2 || country[0] < 'A' || country[0] > 'Z' || country[1] < 'A' || country[1] > 'Z' {
|
||
return ""
|
||
}
|
||
return string([]rune{
|
||
rune(0x1F1E6) + rune(country[0]-'A'),
|
||
rune(0x1F1E6) + rune(country[1]-'A'),
|
||
})
|
||
}
|
||
|
||
func telegramModuleLine(snapshot *device.Snapshot) string {
|
||
if snapshot == nil {
|
||
return ""
|
||
}
|
||
description := strings.TrimSpace(strings.Join([]string{snapshot.Manufacturer, snapshot.Model}, " "))
|
||
if description == "" && strings.TrimSpace(snapshot.Firmware) == "" {
|
||
return ""
|
||
}
|
||
parts := []string{firstNonEmpty(description, "--")}
|
||
if strings.TrimSpace(snapshot.Firmware) != "" {
|
||
parts = append(parts, snapshot.Firmware)
|
||
}
|
||
return "模块:" + strings.Join(parts, " · ")
|
||
}
|
||
|
||
func telegramSignalLine(snapshot *device.Snapshot) string {
|
||
if snapshot == nil || snapshot.FlightMode || snapshot.RadioOff {
|
||
return ""
|
||
}
|
||
metrics := make([]string, 0, 4)
|
||
if snapshot.RSSIDBm != nil {
|
||
metrics = append(metrics, fmt.Sprintf("%d dBm", *snapshot.RSSIDBm))
|
||
}
|
||
if snapshot.RSRP != nil {
|
||
metrics = append(metrics, fmt.Sprintf("RSRP %d", *snapshot.RSRP))
|
||
}
|
||
if snapshot.RSRQ != nil {
|
||
metrics = append(metrics, fmt.Sprintf("RSRQ %d", *snapshot.RSRQ))
|
||
}
|
||
if snapshot.SINR != nil {
|
||
metrics = append(metrics, fmt.Sprintf("SINR %d", *snapshot.SINR))
|
||
}
|
||
if len(metrics) == 0 {
|
||
return ""
|
||
}
|
||
return "信号:" + strings.Join(metrics, " · ")
|
||
}
|
||
|
||
func (bot *telegramBot) sendESIMProfiles(ctx context.Context, config telegramRuntimeConfig, chatID, adminID int64, deviceID string) {
|
||
if deviceID == "" {
|
||
bot.sendText(ctx, config, chatID, "用法:/esim <设备ID>", nil)
|
||
return
|
||
}
|
||
_, _, physicalID, err := bot.device(deviceID)
|
||
if err != nil {
|
||
bot.sendText(ctx, config, chatID, "读取 eSIM 失败:"+err.Error(), nil)
|
||
return
|
||
}
|
||
readContext, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||
defer cancel()
|
||
inventory, err := bot.server.devices.ESIMInventory(readContext, physicalID)
|
||
if err != nil {
|
||
bot.sendText(ctx, config, chatID, "读取 eSIM 失败:"+err.Error(), nil)
|
||
return
|
||
}
|
||
if len(inventory) == 0 {
|
||
bot.sendText(ctx, config, chatID, "该设备没有可用的 eUICC/Profile。", nil)
|
||
return
|
||
}
|
||
lines := []string{"📲 " + deviceID + " 已安装 Profile(只读)"}
|
||
rows := make([][]map[string]string, 0)
|
||
for index, group := range inventory {
|
||
lines = append(lines, fmt.Sprintf("\neUICC #%d · …%s", index+1, tailDigits(group.Info.EID, 4)))
|
||
for _, profile := range group.Info.Profiles {
|
||
state := "Disabled"
|
||
if profile.State == 1 {
|
||
state = "Enabled"
|
||
}
|
||
name := firstNonEmpty(profile.Nickname, profile.Name, profile.ServiceProvider, "未命名")
|
||
lines = append(lines, fmt.Sprintf("• %s · %s\n %s", name, state, profile.ICCID))
|
||
if profile.State != 1 {
|
||
token, tokenErr := bot.putPending(telegramPendingAction{
|
||
Kind: "menu_esim_profile", DeviceID: deviceID, TargetICCID: profile.ICCID,
|
||
TargetAID: group.Info.AID, ChatID: chatID, AdminID: adminID, CreatedAt: time.Now(),
|
||
})
|
||
if tokenErr == nil {
|
||
label := "切换到 " + name + " · …" + tailDigits(profile.ICCID, 4)
|
||
rows = append(rows, []map[string]string{telegramButton(truncateTelegramButton(label), "es:"+token+":select")})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
rows = append(rows, []map[string]string{
|
||
telegramButton("⬅️ 设备列表", "menu:devices"),
|
||
telegramButton("🏠 主菜单", "menu:home"),
|
||
})
|
||
bot.sendText(ctx, config, chatID, strings.Join(lines, "\n"), telegramKeyboard(rows...))
|
||
}
|
||
|
||
func (bot *telegramBot) confirmESIMSwitch(ctx context.Context, config telegramRuntimeConfig, chatID, adminID int64, deviceID, iccid string) {
|
||
_, _, physicalID, err := bot.device(deviceID)
|
||
if err != nil {
|
||
bot.sendText(ctx, config, chatID, "无法切换:"+err.Error(), nil)
|
||
return
|
||
}
|
||
readContext, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||
defer cancel()
|
||
inventory, err := bot.server.devices.ESIMInventory(readContext, physicalID)
|
||
if err != nil {
|
||
bot.sendText(ctx, config, chatID, "无法读取 Profile:"+err.Error(), nil)
|
||
return
|
||
}
|
||
var target *device.EsimProfile
|
||
var targetAID string
|
||
for groupIndex := range inventory {
|
||
for profileIndex := range inventory[groupIndex].Info.Profiles {
|
||
profile := &inventory[groupIndex].Info.Profiles[profileIndex]
|
||
if profile.ICCID == iccid {
|
||
target = profile
|
||
targetAID = inventory[groupIndex].Info.AID
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if target == nil {
|
||
bot.sendText(ctx, config, chatID, "目标 ICCID 不在该设备已安装 Profile 中。", nil)
|
||
return
|
||
}
|
||
if target.State == 1 {
|
||
bot.sendText(ctx, config, chatID, "目标 Profile 已经处于 Enabled。", nil)
|
||
return
|
||
}
|
||
action := telegramPendingAction{
|
||
Kind: "esim_switch", DeviceID: deviceID, ChatID: chatID, AdminID: adminID,
|
||
CreatedAt: time.Now(), TargetAID: targetAID, TargetICCID: target.ICCID,
|
||
}
|
||
name := firstNonEmpty(target.Nickname, target.Name, target.ServiceProvider, "未命名")
|
||
bot.askConfirmation(ctx, config, action, fmt.Sprintf("确认将设备 %s 切换到:\n%s\nICCID %s?\n\nBot 只会执行 EnableProfile,不会下载或删除 Profile。", deviceID, name, target.ICCID))
|
||
}
|
||
|
||
func (bot *telegramBot) confirmSMS(ctx context.Context, config telegramRuntimeConfig, chatID, adminID int64, deviceID, phone, text string) {
|
||
phone = strings.TrimSpace(phone)
|
||
text = strings.TrimSpace(text)
|
||
if _, _, _, err := bot.device(deviceID); err != nil {
|
||
bot.sendText(ctx, config, chatID, "无法发送:"+err.Error(), nil)
|
||
return
|
||
}
|
||
if blocked, reason := blockedSMSDestination(phone); blocked {
|
||
bot.sendText(ctx, config, chatID, "无法发送:"+reason, nil)
|
||
return
|
||
}
|
||
if text == "" {
|
||
bot.sendText(ctx, config, chatID, "短信内容不能为空。", nil)
|
||
return
|
||
}
|
||
action := telegramPendingAction{
|
||
Kind: "sms", DeviceID: deviceID, Argument: phone, Text: text,
|
||
ChatID: chatID, AdminID: adminID, CreatedAt: time.Now(),
|
||
}
|
||
bot.askConfirmation(ctx, config, action, fmt.Sprintf("确认通过设备 %s 发送短信?\n收件人:%s\n内容:%s", deviceID, phone, truncateTelegramText(text, 800)))
|
||
}
|
||
|
||
func (bot *telegramBot) confirmCall(ctx context.Context, config telegramRuntimeConfig, chatID, adminID int64, deviceID, number string, duration time.Duration) {
|
||
if !validTelegramDialNumber(number) {
|
||
bot.sendText(ctx, config, chatID, "拨号号码无效,只允许一个可选的前导 + 和 3–20 位数字。", nil)
|
||
return
|
||
}
|
||
stored, entry, _, err := bot.device(deviceID)
|
||
if err != nil {
|
||
bot.sendText(ctx, config, chatID, "无法拨号:"+err.Error(), nil)
|
||
return
|
||
}
|
||
transport, _, err := bot.telegramCallTransport(stored, entry)
|
||
if err != nil {
|
||
bot.sendText(ctx, config, chatID, "无法拨号:"+err.Error(), nil)
|
||
return
|
||
}
|
||
action := telegramPendingAction{
|
||
Kind: "call", DeviceID: deviceID, Argument: number, Duration: duration,
|
||
ChatID: chatID, AdminID: adminID, CreatedAt: time.Now(),
|
||
}
|
||
bot.askConfirmation(ctx, config, action, fmt.Sprintf("确认通过设备 %s 拨打 %s?\n通道:%s\n持续:%d 秒,然后自动挂断。\n不会采集或处理通话音频。", deviceID, number, telegramCallTransportLabel(transport), int(duration/time.Second)))
|
||
}
|
||
|
||
func (bot *telegramBot) askConfirmation(ctx context.Context, config telegramRuntimeConfig, action telegramPendingAction, text string) {
|
||
token, err := bot.putPending(action)
|
||
if err != nil {
|
||
bot.sendText(ctx, config, action.ChatID, "创建确认失败:"+err.Error(), nil)
|
||
return
|
||
}
|
||
keyboard := map[string]any{"inline_keyboard": [][]map[string]string{{
|
||
{"text": "✅ 确认", "callback_data": "confirm:" + token},
|
||
{"text": "❌ 取消", "callback_data": "cancel:" + token},
|
||
}}}
|
||
bot.sendText(ctx, config, action.ChatID, text, keyboard)
|
||
}
|
||
|
||
func telegramInputKey(chatID, adminID int64) string {
|
||
return strconv.FormatInt(chatID, 10) + ":" + strconv.FormatInt(adminID, 10)
|
||
}
|
||
|
||
func (bot *telegramBot) beginInput(
|
||
ctx context.Context,
|
||
config telegramRuntimeConfig,
|
||
chatID, adminID int64,
|
||
deviceID, kind, prompt string,
|
||
) {
|
||
if _, _, _, err := bot.device(deviceID); err != nil {
|
||
bot.sendText(ctx, config, chatID, "设备不可用:"+err.Error(), bot.homeKeyboard())
|
||
return
|
||
}
|
||
bot.setInput(telegramInputState{
|
||
Kind: kind, DeviceID: deviceID, ChatID: chatID, AdminID: adminID, CreatedAt: time.Now(),
|
||
})
|
||
bot.sendText(ctx, config, chatID, prompt+"\n\n设备:"+deviceID,
|
||
telegramKeyboard([]map[string]string{telegramButton("❌ 取消", "input:cancel")}))
|
||
}
|
||
|
||
func (bot *telegramBot) setInput(state telegramInputState) {
|
||
bot.inputMu.Lock()
|
||
defer bot.inputMu.Unlock()
|
||
if bot.inputs == nil {
|
||
bot.inputs = make(map[string]telegramInputState)
|
||
}
|
||
state.CreatedAt = time.Now()
|
||
bot.inputs[telegramInputKey(state.ChatID, state.AdminID)] = state
|
||
}
|
||
|
||
func (bot *telegramBot) clearInput(chatID, adminID int64) {
|
||
bot.inputMu.Lock()
|
||
defer bot.inputMu.Unlock()
|
||
delete(bot.inputs, telegramInputKey(chatID, adminID))
|
||
}
|
||
|
||
func (bot *telegramBot) input(chatID, adminID int64) (telegramInputState, bool) {
|
||
bot.inputMu.Lock()
|
||
defer bot.inputMu.Unlock()
|
||
state, ok := bot.inputs[telegramInputKey(chatID, adminID)]
|
||
if ok && time.Since(state.CreatedAt) > telegramInputTTL {
|
||
delete(bot.inputs, telegramInputKey(chatID, adminID))
|
||
return telegramInputState{}, false
|
||
}
|
||
return state, ok
|
||
}
|
||
|
||
func (bot *telegramBot) handleInputMessage(ctx context.Context, config telegramRuntimeConfig, message *telegramMessage) {
|
||
state, ok := bot.input(message.Chat.ID, message.From.ID)
|
||
if !ok {
|
||
bot.sendMainMenu(ctx, config, message.Chat.ID)
|
||
return
|
||
}
|
||
value := strings.TrimSpace(message.Text)
|
||
if value == "" {
|
||
return
|
||
}
|
||
switch state.Kind {
|
||
case "sms_phone":
|
||
if blocked, reason := blockedSMSDestination(value); blocked {
|
||
bot.sendText(ctx, config, message.Chat.ID, "号码不可用:"+reason+"\n请重新输入号码。", telegramKeyboard([]map[string]string{telegramButton("❌ 取消", "input:cancel")}))
|
||
return
|
||
}
|
||
state.Kind = "sms_text"
|
||
state.Argument = value
|
||
bot.setInput(state)
|
||
bot.sendText(ctx, config, message.Chat.ID, "请输入短信内容:\n\n收件人:"+value,
|
||
telegramKeyboard([]map[string]string{telegramButton("❌ 取消", "input:cancel")}))
|
||
case "sms_text":
|
||
bot.clearInput(message.Chat.ID, message.From.ID)
|
||
bot.confirmSMS(ctx, config, message.Chat.ID, message.From.ID, state.DeviceID, state.Argument, value)
|
||
case "call_number":
|
||
if !validTelegramDialNumber(value) {
|
||
bot.sendText(ctx, config, message.Chat.ID, "号码无效,请输入 3–20 位数字,可带前导 +。",
|
||
telegramKeyboard([]map[string]string{telegramButton("❌ 取消", "input:cancel")}))
|
||
return
|
||
}
|
||
bot.clearInput(message.Chat.ID, message.From.ID)
|
||
token, err := bot.putPending(telegramPendingAction{
|
||
Kind: "menu_call_duration", DeviceID: state.DeviceID, Argument: value,
|
||
ChatID: message.Chat.ID, AdminID: message.From.ID, CreatedAt: time.Now(),
|
||
})
|
||
if err != nil {
|
||
bot.sendText(ctx, config, message.Chat.ID, "创建拨号操作失败:"+err.Error(), bot.homeKeyboard())
|
||
return
|
||
}
|
||
bot.sendText(ctx, config, message.Chat.ID, "请选择自动挂断时间:\n\n号码:"+value,
|
||
telegramKeyboard(
|
||
[]map[string]string{
|
||
telegramButton("10 秒", "dur:"+token+":10"),
|
||
telegramButton("30 秒", "dur:"+token+":30"),
|
||
},
|
||
[]map[string]string{
|
||
telegramButton("60 秒", "dur:"+token+":60"),
|
||
telegramButton("120 秒", "dur:"+token+":120"),
|
||
},
|
||
[]map[string]string{telegramButton("❌ 取消", "input:cancel")},
|
||
))
|
||
case "at":
|
||
bot.clearInput(message.Chat.ID, message.From.ID)
|
||
bot.handleATCommand(ctx, config, message.Chat.ID, message.From.ID, state.DeviceID, value)
|
||
case "ussd":
|
||
bot.clearInput(message.Chat.ID, message.From.ID)
|
||
bot.handleUSSDCommand(ctx, config, message.Chat.ID, message.From.ID, state.DeviceID, value)
|
||
case "ussd_reply":
|
||
bot.clearInput(message.Chat.ID, message.From.ID)
|
||
bot.handleUSSDReply(ctx, config, message.Chat.ID, message.From.ID, state.Argument, value)
|
||
default:
|
||
bot.clearInput(message.Chat.ID, message.From.ID)
|
||
bot.sendMainMenu(ctx, config, message.Chat.ID)
|
||
}
|
||
}
|
||
|
||
func (bot *telegramBot) putPending(action telegramPendingAction) (string, error) {
|
||
raw := make([]byte, 8)
|
||
if _, err := cryptorand.Read(raw); err != nil {
|
||
return "", err
|
||
}
|
||
token := hex.EncodeToString(raw)
|
||
bot.pendingMu.Lock()
|
||
defer bot.pendingMu.Unlock()
|
||
now := time.Now()
|
||
if bot.pending == nil {
|
||
bot.pending = make(map[string]telegramPendingAction)
|
||
}
|
||
for key, value := range bot.pending {
|
||
if now.Sub(value.CreatedAt) > telegramPendingLifetime(value.Kind) {
|
||
delete(bot.pending, key)
|
||
}
|
||
}
|
||
bot.pending[token] = action
|
||
return token, nil
|
||
}
|
||
|
||
func telegramPendingLifetime(kind string) time.Duration {
|
||
if strings.HasPrefix(kind, "menu_") {
|
||
return telegramMenuTTL
|
||
}
|
||
return telegramConfirmationTTL
|
||
}
|
||
|
||
func (bot *telegramBot) getPending(token string, chatID, adminID int64) (telegramPendingAction, bool) {
|
||
bot.pendingMu.Lock()
|
||
defer bot.pendingMu.Unlock()
|
||
action, ok := bot.pending[token]
|
||
if !ok || action.ChatID != chatID || action.AdminID != adminID || time.Since(action.CreatedAt) > telegramPendingLifetime(action.Kind) {
|
||
if ok {
|
||
delete(bot.pending, token)
|
||
}
|
||
return telegramPendingAction{}, false
|
||
}
|
||
return action, true
|
||
}
|
||
|
||
func (bot *telegramBot) takePending(token string, chatID, adminID int64) (telegramPendingAction, bool) {
|
||
bot.pendingMu.Lock()
|
||
defer bot.pendingMu.Unlock()
|
||
action, ok := bot.pending[token]
|
||
if ok {
|
||
delete(bot.pending, token)
|
||
}
|
||
if !ok || action.ChatID != chatID || action.AdminID != adminID || time.Since(action.CreatedAt) > telegramPendingLifetime(action.Kind) {
|
||
return telegramPendingAction{}, false
|
||
}
|
||
return action, true
|
||
}
|
||
|
||
func (bot *telegramBot) executeSMS(ctx context.Context, action telegramPendingAction) (string, error) {
|
||
payload, _ := json.Marshal(map[string]string{
|
||
"device_id": action.DeviceID,
|
||
"phone": action.Argument,
|
||
"message": action.Text,
|
||
})
|
||
request := httptest.NewRequest(http.MethodPost, "/api/sms/send", bytes.NewReader(payload)).WithContext(ctx)
|
||
request.Header.Set("Content-Type", "application/json")
|
||
recorder := httptest.NewRecorder()
|
||
bot.server.handleSMSSend(recorder, request)
|
||
var response struct {
|
||
Data map[string]any `json:"data"`
|
||
Error *apiError `json:"error"`
|
||
}
|
||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||
return "", fmt.Errorf("decode SMS result: %w", err)
|
||
}
|
||
if recorder.Code >= http.StatusBadRequest || response.Error != nil {
|
||
if response.Error != nil {
|
||
return "", errors.New(response.Error.Message)
|
||
}
|
||
return "", fmt.Errorf("SMS submission returned HTTP %d", recorder.Code)
|
||
}
|
||
return fmt.Sprintf("短信已提交。\n通道:%v\n结果:%v\n送达确认:%v", response.Data["transport"], response.Data["outcome"], response.Data["delivery_confirmed"]), nil
|
||
}
|
||
|
||
func (bot *telegramBot) executeESIMSwitch(ctx context.Context, action telegramPendingAction) (string, error) {
|
||
_, _, physicalID, err := bot.device(action.DeviceID)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
operationContext, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
||
defer cancel()
|
||
if err := bot.server.devices.ESIMSwitchProfile(operationContext, physicalID, action.TargetICCID, action.TargetAID); err != nil {
|
||
return "", err
|
||
}
|
||
return "Profile 切换成功,模块恢复后已校验当前 ICCID:" + action.TargetICCID, nil
|
||
}
|
||
|
||
func (bot *telegramBot) executeTimedCall(ctx context.Context, config telegramRuntimeConfig, action telegramPendingAction) (string, error) {
|
||
stored, entry, physicalID, err := bot.device(action.DeviceID)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
transport, controller, err := bot.telegramCallTransport(stored, entry)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if !bot.beginTelegramDial(action.DeviceID) {
|
||
return "", errors.New("该设备已有一个由 Telegram 发起的限时拨号任务")
|
||
}
|
||
defer bot.endTelegramDial(action.DeviceID)
|
||
|
||
if transport == "vowifi" {
|
||
return bot.executeTimedVoWiFiCall(ctx, config, action, controller)
|
||
}
|
||
return bot.executeTimedCellularCall(ctx, config, action, physicalID)
|
||
}
|
||
|
||
func (bot *telegramBot) executeSimpleCallAction(ctx context.Context, config telegramRuntimeConfig, chatID, adminID int64, deviceID, action string) {
|
||
if deviceID == "" {
|
||
bot.sendText(ctx, config, chatID, fmt.Sprintf("用法:/%s <设备ID>", map[string]string{"status": "calls", "answer": "answer", "hangup": "hangup"}[action]), nil)
|
||
return
|
||
}
|
||
stored, entry, physicalID, err := bot.device(deviceID)
|
||
if err != nil {
|
||
bot.sendText(ctx, config, chatID, "通话操作失败:"+err.Error(), nil)
|
||
return
|
||
}
|
||
transport, controller, err := bot.telegramCallTransport(stored, entry)
|
||
result := ""
|
||
if err == nil {
|
||
if transport == "vowifi" {
|
||
result, err = bot.executeSimpleVoWiFiCallAction(ctx, deviceID, action, controller)
|
||
} else {
|
||
result, err = bot.executeSimpleCellularCallAction(ctx, physicalID, action)
|
||
}
|
||
}
|
||
outcome := "success"
|
||
if err != nil {
|
||
outcome = "failure"
|
||
bot.sendText(ctx, config, chatID, "通话操作失败:"+err.Error(), bot.homeKeyboard())
|
||
} else {
|
||
bot.sendText(ctx, config, chatID, result, bot.homeKeyboard())
|
||
}
|
||
bot.server.recordAudit(ctx, fmt.Sprintf("telegram:%d", adminID), "telegram.call."+action, "device", deviceID, outcome, firstNonEmpty(transport, "unknown"))
|
||
}
|
||
|
||
func (bot *telegramBot) telegramCallTransport(stored store.Device, entry device.Device) (string, VoWiFiCallController, error) {
|
||
if stored.VoWiFiEnabled {
|
||
if bot.server.vowifi == nil {
|
||
return "", nil, errors.New("设备卡片处于 VoWiFi 模式,但 VoWiFi 运行时不可用")
|
||
}
|
||
state, err := bot.server.vowifi.State(stored.ID)
|
||
if err != nil {
|
||
return "", nil, fmt.Errorf("读取 VoWiFi 通话状态失败: %w", err)
|
||
}
|
||
if !state.IMSReady {
|
||
detail := firstNonEmpty(state.LastError, state.LastReason, "IMS 尚未注册")
|
||
return "", nil, fmt.Errorf("设备卡片处于 VoWiFi 模式,但 IMS 未就绪(%s):%s", firstNonEmpty(string(state.Phase), "idle"), detail)
|
||
}
|
||
controller, ok := bot.server.vowifi.(VoWiFiCallController)
|
||
if !ok {
|
||
return "", nil, errors.New("当前 VoWiFi 运行时不支持 IMS 通话信令")
|
||
}
|
||
return "vowifi", controller, nil
|
||
}
|
||
if entry.Snapshot != nil && entry.Snapshot.FlightMode {
|
||
return "", nil, errors.New("设备处于飞行模式且 VoWiFi 未启用,无法通过基站拨号")
|
||
}
|
||
return "cellular", nil, nil
|
||
}
|
||
|
||
func telegramCallTransportLabel(transport string) string {
|
||
if transport == "vowifi" {
|
||
return "VoWiFi IMS"
|
||
}
|
||
return "基站直连"
|
||
}
|
||
|
||
func (bot *telegramBot) beginTelegramDial(deviceID string) bool {
|
||
bot.callMu.Lock()
|
||
defer bot.callMu.Unlock()
|
||
if bot.activeDials == nil {
|
||
bot.activeDials = make(map[string]struct{})
|
||
}
|
||
if _, exists := bot.activeDials[deviceID]; exists {
|
||
return false
|
||
}
|
||
bot.activeDials[deviceID] = struct{}{}
|
||
return true
|
||
}
|
||
|
||
func (bot *telegramBot) endTelegramDial(deviceID string) {
|
||
bot.callMu.Lock()
|
||
delete(bot.activeDials, deviceID)
|
||
bot.callMu.Unlock()
|
||
}
|
||
|
||
func (bot *telegramBot) executeTimedVoWiFiCall(
|
||
ctx context.Context,
|
||
config telegramRuntimeConfig,
|
||
action telegramPendingAction,
|
||
controller VoWiFiCallController,
|
||
) (string, error) {
|
||
dialContext, cancelDial := context.WithTimeout(ctx, 20*time.Second)
|
||
call, err := controller.DialCall(dialContext, action.DeviceID, action.Argument)
|
||
cancelDial()
|
||
if err != nil {
|
||
return "", fmt.Errorf("VoWiFi IMS 拨号失败: %w", err)
|
||
}
|
||
hangupAt := time.Now().Add(action.Duration)
|
||
_ = bot.sendText(ctx, config, action.ChatID, fmt.Sprintf(
|
||
"📞 已通过 VoWiFi IMS 提交 %s,Call-ID:%s\n将在 %d 秒后自动挂断,并持续检查 SIP 结果。",
|
||
action.Argument, call.ID, int(action.Duration/time.Second),
|
||
), nil)
|
||
|
||
timer := time.NewTimer(time.Until(hangupAt))
|
||
defer timer.Stop()
|
||
ticker := time.NewTicker(500 * time.Millisecond)
|
||
defer ticker.Stop()
|
||
seenNetwork := false
|
||
lastState := call.State
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
bot.bestEffortVoWiFiHangup(controller, action.DeviceID, call.ID)
|
||
return "", ctx.Err()
|
||
case <-timer.C:
|
||
current, found, listErr := telegramFindVoWiFiCall(controller, action.DeviceID, call.ID)
|
||
if listErr != nil {
|
||
bot.bestEffortVoWiFiHangup(controller, action.DeviceID, call.ID)
|
||
return "", fmt.Errorf("读取 IMS 通话状态失败: %w", listErr)
|
||
}
|
||
if found && current.State == "failed" {
|
||
return telegramVoWiFiCallOutcome(action.Argument, current)
|
||
}
|
||
if found {
|
||
lastState = current.State
|
||
if current.State == "ringing" || current.State == "active" {
|
||
seenNetwork = true
|
||
}
|
||
}
|
||
if found && current.State != "ended" {
|
||
hangContext, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||
err = controller.HangupCall(hangContext, action.DeviceID, call.ID)
|
||
cancel()
|
||
if err != nil {
|
||
return "", fmt.Errorf("VoWiFi 拨号已执行,但自动挂断失败: %w", err)
|
||
}
|
||
}
|
||
if seenNetwork {
|
||
return fmt.Sprintf("VoWiFi 拨号完成:%s;已确认 %s,%d 秒后自动挂断。", action.Argument, telegramIMSStateLabel(lastState), int(action.Duration/time.Second)), nil
|
||
}
|
||
return fmt.Sprintf("VoWiFi INVITE 已发送并在 %d 秒后取消;运营商未返回振铃或接通确认。", int(action.Duration/time.Second)), nil
|
||
case <-ticker.C:
|
||
current, found, listErr := telegramFindVoWiFiCall(controller, action.DeviceID, call.ID)
|
||
if listErr != nil {
|
||
bot.bestEffortVoWiFiHangup(controller, action.DeviceID, call.ID)
|
||
return "", fmt.Errorf("读取 IMS 通话状态失败: %w", listErr)
|
||
}
|
||
if !found {
|
||
bot.bestEffortVoWiFiHangup(controller, action.DeviceID, call.ID)
|
||
return "", errors.New("IMS 通话记录在拨号过程中消失")
|
||
}
|
||
lastState = current.State
|
||
switch current.State {
|
||
case "ringing", "active":
|
||
seenNetwork = true
|
||
case "failed":
|
||
return telegramVoWiFiCallOutcome(action.Argument, current)
|
||
case "ended":
|
||
if seenNetwork {
|
||
return fmt.Sprintf("VoWiFi 通话 %s 已由网络或对端提前结束(最后状态:%s)。", action.Argument, telegramIMSStateLabel(lastState)), nil
|
||
}
|
||
return fmt.Sprintf("📴 VoWiFi 呼叫 %s 在振铃前结束%s。", action.Argument, telegramSIPDiagnostic(current)), nil
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func telegramFindVoWiFiCall(controller VoWiFiCallController, deviceID, callID string) (vowifi.Call, bool, error) {
|
||
calls, err := controller.Calls(deviceID)
|
||
if err != nil {
|
||
return vowifi.Call{}, false, err
|
||
}
|
||
for _, call := range calls {
|
||
if call.ID == callID {
|
||
return call, true, nil
|
||
}
|
||
}
|
||
return vowifi.Call{}, false, nil
|
||
}
|
||
|
||
func telegramVoWiFiCallFailure(call vowifi.Call) error {
|
||
return fmt.Errorf("VoWiFi 呼叫被拒绝或失败%s", telegramSIPDiagnostic(call))
|
||
}
|
||
|
||
func telegramVoWiFiCallOutcome(number string, call vowifi.Call) (string, error) {
|
||
diagnostic := telegramSIPDiagnostic(call)
|
||
switch call.SIPCode {
|
||
case 408:
|
||
return fmt.Sprintf("📵 VoWiFi 呼叫 %s 等待响应超时%s,未接通。", number, diagnostic), nil
|
||
case 480:
|
||
return fmt.Sprintf("📵 VoWiFi 呼叫 %s 暂时无人接听或对端不可用%s。", number, diagnostic), nil
|
||
case 486, 600:
|
||
return fmt.Sprintf("📵 VoWiFi 呼叫 %s 对方忙线%s。", number, diagnostic), nil
|
||
case 487:
|
||
return fmt.Sprintf("📴 VoWiFi 呼叫 %s 已在接通前取消或终止%s;这不是 IMS 注册失败。", number, diagnostic), nil
|
||
case 603:
|
||
return fmt.Sprintf("📵 VoWiFi 呼叫 %s 被对端拒接%s。", number, diagnostic), nil
|
||
default:
|
||
return "", telegramVoWiFiCallFailure(call)
|
||
}
|
||
}
|
||
|
||
func telegramSIPDiagnostic(call vowifi.Call) string {
|
||
parts := make([]string, 0, 2)
|
||
if call.SIPCode != 0 {
|
||
parts = append(parts, fmt.Sprintf("SIP %d", call.SIPCode))
|
||
}
|
||
if reason := strings.TrimSpace(call.Reason); reason != "" {
|
||
parts = append(parts, reason)
|
||
}
|
||
if len(parts) == 0 {
|
||
return ""
|
||
}
|
||
return "(" + strings.Join(parts, " · ") + ")"
|
||
}
|
||
|
||
func telegramIMSStateLabel(state string) string {
|
||
switch state {
|
||
case "dialing":
|
||
return "正在拨号"
|
||
case "ringing":
|
||
return "对端振铃"
|
||
case "active":
|
||
return "已接通"
|
||
case "ended":
|
||
return "已结束"
|
||
case "failed":
|
||
return "失败"
|
||
default:
|
||
return firstNonEmpty(state, "未知状态")
|
||
}
|
||
}
|
||
|
||
func (bot *telegramBot) bestEffortVoWiFiHangup(controller VoWiFiCallController, deviceID, callID string) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
_ = controller.HangupCall(ctx, deviceID, callID)
|
||
}
|
||
|
||
func (bot *telegramBot) executeTimedCellularCall(
|
||
ctx context.Context,
|
||
config telegramRuntimeConfig,
|
||
action telegramPendingAction,
|
||
physicalID string,
|
||
) (string, error) {
|
||
dialContext, cancelDial := context.WithTimeout(ctx, 20*time.Second)
|
||
response, err := bot.server.devices.ExecuteAT(dialContext, physicalID, "ATD"+action.Argument+";")
|
||
cancelDial()
|
||
if err != nil {
|
||
return "", fmt.Errorf("基站拨号失败: %w", err)
|
||
}
|
||
if !response.OK() {
|
||
return "", fmt.Errorf("基站未接受拨号: %s", formatTelegramAT(response))
|
||
}
|
||
hangupAt := time.Now().Add(action.Duration)
|
||
_ = bot.sendText(ctx, config, action.ChatID, fmt.Sprintf(
|
||
"📞 已通过基站提交 %s,将在 %d 秒后自动挂断,并持续检查 CLCC 状态。",
|
||
action.Argument, int(action.Duration/time.Second),
|
||
), nil)
|
||
|
||
timer := time.NewTimer(time.Until(hangupAt))
|
||
defer timer.Stop()
|
||
ticker := time.NewTicker(750 * time.Millisecond)
|
||
defer ticker.Stop()
|
||
startedAt := time.Now()
|
||
seenCall := false
|
||
confirmed := false
|
||
lastState := -1
|
||
for {
|
||
calls, queryErr := bot.telegramCellularCalls(ctx, physicalID)
|
||
if queryErr != nil {
|
||
if final, ok := telegramCallFinalError(queryErr); ok {
|
||
if confirmed && final == "NO CARRIER" {
|
||
return fmt.Sprintf("基站通话 %s 已由网络或对端提前结束。", action.Argument), nil
|
||
}
|
||
return "", fmt.Errorf("基站呼叫失败: %s", telegramCallFinalLabel(final))
|
||
}
|
||
bot.bestEffortCellularHangup(physicalID)
|
||
return "", fmt.Errorf("查询基站通话状态失败: %w", queryErr)
|
||
}
|
||
current, found := telegramFindOutgoingCellularCall(calls, action.Argument)
|
||
if found {
|
||
seenCall = true
|
||
lastState = telegramCellularCallInt(current, "state")
|
||
if lastState == 0 || lastState == 1 || lastState == 3 {
|
||
confirmed = true
|
||
}
|
||
} else if seenCall {
|
||
if confirmed {
|
||
return fmt.Sprintf("基站通话 %s 已由网络或对端提前结束(最后状态:%s)。", action.Argument, telegramCLCCStateLabel(lastState)), nil
|
||
}
|
||
return "", fmt.Errorf("基站呼叫在振铃前结束(最后状态:%s)", telegramCLCCStateLabel(lastState))
|
||
} else if time.Since(startedAt) >= 5*time.Second {
|
||
bot.bestEffortCellularHangup(physicalID)
|
||
return "", errors.New("模块接受了 ATD,但 5 秒内没有建立任何 CLCC 呼叫记录")
|
||
}
|
||
|
||
select {
|
||
case <-ctx.Done():
|
||
bot.bestEffortCellularHangup(physicalID)
|
||
return "", ctx.Err()
|
||
case <-timer.C:
|
||
if err := bot.hangupCellularCall(physicalID); err != nil {
|
||
return "", fmt.Errorf("基站拨号已执行,但自动挂断失败: %w", err)
|
||
}
|
||
if !seenCall {
|
||
return fmt.Sprintf("基站已接受拨号并在 %d 秒后自动挂断;持续时间过短,未取得 CLCC 状态。", int(action.Duration/time.Second)), nil
|
||
}
|
||
return fmt.Sprintf("基站拨号完成:%s;模块确认%s,%d 秒后自动挂断。", action.Argument, telegramCLCCStateLabel(lastState), int(action.Duration/time.Second)), nil
|
||
case <-ticker.C:
|
||
}
|
||
}
|
||
}
|
||
|
||
func (bot *telegramBot) telegramCellularCalls(ctx context.Context, physicalID string) ([]map[string]any, error) {
|
||
queryContext, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||
defer cancel()
|
||
response, err := bot.server.devices.ExecuteAT(queryContext, physicalID, "AT+CLCC")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return parseCLCC(response), nil
|
||
}
|
||
|
||
func telegramFindOutgoingCellularCall(calls []map[string]any, number string) (map[string]any, bool) {
|
||
cleanNumber := strings.TrimPrefix(strings.TrimSpace(number), "+")
|
||
var fallback map[string]any
|
||
for _, call := range calls {
|
||
if telegramCellularCallInt(call, "direction") != 0 {
|
||
continue
|
||
}
|
||
if fallback == nil {
|
||
fallback = call
|
||
}
|
||
candidate := strings.TrimPrefix(strings.TrimSpace(fmt.Sprint(call["number"])), "+")
|
||
if candidate == "<nil>" || candidate == "" || candidate == cleanNumber {
|
||
return call, true
|
||
}
|
||
}
|
||
return fallback, fallback != nil
|
||
}
|
||
|
||
func telegramCellularCallInt(call map[string]any, key string) int {
|
||
value, _ := call[key].(int)
|
||
return value
|
||
}
|
||
|
||
func telegramCLCCStateLabel(state int) string {
|
||
switch state {
|
||
case 0:
|
||
return "已接通"
|
||
case 1:
|
||
return "保持中"
|
||
case 2:
|
||
return "正在拨号"
|
||
case 3:
|
||
return "对端振铃"
|
||
case 4:
|
||
return "来电振铃"
|
||
case 5:
|
||
return "来电等待"
|
||
default:
|
||
return fmt.Sprintf("未知状态(%d)", state)
|
||
}
|
||
}
|
||
|
||
func telegramCallFinalError(err error) (string, bool) {
|
||
var commandErr *modem.CommandError
|
||
if !errors.As(err, &commandErr) {
|
||
return "", false
|
||
}
|
||
final := strings.ToUpper(strings.TrimSpace(commandErr.Final))
|
||
switch final {
|
||
case "NO CARRIER", "BUSY", "NO ANSWER":
|
||
return final, true
|
||
default:
|
||
return "", false
|
||
}
|
||
}
|
||
|
||
func telegramCallFinalLabel(final string) string {
|
||
switch final {
|
||
case "BUSY":
|
||
return "对方忙线(BUSY)"
|
||
case "NO ANSWER":
|
||
return "无人接听(NO ANSWER)"
|
||
case "NO CARRIER":
|
||
return "网络或对端已结束呼叫(NO CARRIER)"
|
||
default:
|
||
return final
|
||
}
|
||
}
|
||
|
||
func (bot *telegramBot) hangupCellularCall(physicalID string) error {
|
||
hangContext, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||
defer cancel()
|
||
response, err := bot.server.devices.ExecuteAT(hangContext, physicalID, "ATH")
|
||
if err != nil {
|
||
if final, ok := telegramCallFinalError(err); ok && final == "NO CARRIER" {
|
||
return nil
|
||
}
|
||
return err
|
||
}
|
||
if !response.OK() {
|
||
return fmt.Errorf("模块未确认 ATH: %s", formatTelegramAT(response))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (bot *telegramBot) bestEffortCellularHangup(physicalID string) {
|
||
_ = bot.hangupCellularCall(physicalID)
|
||
}
|
||
|
||
func (bot *telegramBot) executeSimpleVoWiFiCallAction(ctx context.Context, deviceID, action string, controller VoWiFiCallController) (string, error) {
|
||
calls, err := controller.Calls(deviceID)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
operationContext, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||
defer cancel()
|
||
switch action {
|
||
case "status":
|
||
return formatTelegramIMSCalls(calls), nil
|
||
case "answer":
|
||
callID, err := resolveVoWiFiCallID(controller, deviceID, "", "ringing")
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
call, err := controller.AnswerCall(operationContext, deviceID, callID)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return fmt.Sprintf("📞 已通过 VoWiFi IMS 接听 %s(%s)。", firstNonEmpty(call.Number, "未知号码"), call.ID), nil
|
||
case "hangup":
|
||
count := 0
|
||
var failures []string
|
||
for _, call := range calls {
|
||
if call.State == "ended" || call.State == "failed" {
|
||
continue
|
||
}
|
||
if err := controller.HangupCall(operationContext, deviceID, call.ID); err != nil {
|
||
failures = append(failures, err.Error())
|
||
continue
|
||
}
|
||
count++
|
||
}
|
||
if len(failures) > 0 {
|
||
return "", errors.New(strings.Join(failures, "; "))
|
||
}
|
||
if count == 0 {
|
||
return "", errors.New("当前没有可挂断的 VoWiFi 通话")
|
||
}
|
||
return fmt.Sprintf("已通过 VoWiFi IMS 挂断 %d 路通话。", count), nil
|
||
default:
|
||
return "", errors.New("未知通话操作")
|
||
}
|
||
}
|
||
|
||
func formatTelegramIMSCalls(calls []vowifi.Call) string {
|
||
if len(calls) == 0 {
|
||
return "VoWiFi IMS:当前没有通话。"
|
||
}
|
||
lines := []string{"VoWiFi IMS 通话:"}
|
||
for _, call := range calls {
|
||
direction := map[bool]string{true: "来电", false: "去电"}[call.Direction == "incoming"]
|
||
lines = append(lines, fmt.Sprintf("• %s · %s · %s · %s%s", firstNonEmpty(call.Number, "未知号码"), direction, telegramIMSStateLabel(call.State), call.ID, telegramSIPDiagnostic(call)))
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func (bot *telegramBot) executeSimpleCellularCallAction(ctx context.Context, physicalID, action string) (string, error) {
|
||
switch action {
|
||
case "status":
|
||
calls, err := bot.telegramCellularCalls(ctx, physicalID)
|
||
if err != nil {
|
||
if final, ok := telegramCallFinalError(err); ok && final == "NO CARRIER" {
|
||
return "基站直连:当前没有活动通话。", nil
|
||
}
|
||
return "", err
|
||
}
|
||
return formatTelegramCellularCalls(calls), nil
|
||
case "answer":
|
||
operationContext, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||
response, err := bot.server.devices.ExecuteAT(operationContext, physicalID, "ATA")
|
||
cancel()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if !response.OK() {
|
||
return "", fmt.Errorf("模块未确认 ATA: %s", formatTelegramAT(response))
|
||
}
|
||
deadline := time.Now().Add(8 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
calls, queryErr := bot.telegramCellularCalls(ctx, physicalID)
|
||
if queryErr != nil {
|
||
return "", queryErr
|
||
}
|
||
for _, call := range calls {
|
||
if telegramCellularCallInt(call, "direction") == 1 && telegramCellularCallInt(call, "state") == 0 {
|
||
return "📞 基站来电已接通。", nil
|
||
}
|
||
}
|
||
if !waitTelegram(ctx, 500*time.Millisecond) {
|
||
return "", ctx.Err()
|
||
}
|
||
}
|
||
return "", errors.New("模块接受了 ATA,但 8 秒内未确认来电已接通")
|
||
case "hangup":
|
||
if err := bot.hangupCellularCall(physicalID); err != nil {
|
||
return "", err
|
||
}
|
||
deadline := time.Now().Add(4 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
calls, queryErr := bot.telegramCellularCalls(ctx, physicalID)
|
||
if queryErr != nil {
|
||
if final, ok := telegramCallFinalError(queryErr); ok && final == "NO CARRIER" {
|
||
return "已通过基站发送 ATH,并确认通话结束。", nil
|
||
}
|
||
return "", queryErr
|
||
}
|
||
if len(calls) == 0 {
|
||
return "已通过基站发送 ATH,并确认没有活动通话。", nil
|
||
}
|
||
if !waitTelegram(ctx, 400*time.Millisecond) {
|
||
return "", ctx.Err()
|
||
}
|
||
}
|
||
return "", errors.New("模块接受了 ATH,但 4 秒后仍报告活动通话")
|
||
default:
|
||
return "", errors.New("未知通话操作")
|
||
}
|
||
}
|
||
|
||
func formatTelegramCellularCalls(calls []map[string]any) string {
|
||
if len(calls) == 0 {
|
||
return "基站直连:当前没有活动通话。"
|
||
}
|
||
lines := []string{"基站直连通话:"}
|
||
for _, call := range calls {
|
||
direction := map[bool]string{true: "来电", false: "去电"}[telegramCellularCallInt(call, "direction") == 1]
|
||
number := strings.TrimSpace(fmt.Sprint(call["number"]))
|
||
if number == "" || number == "<nil>" {
|
||
number = "未知号码"
|
||
}
|
||
lines = append(lines, fmt.Sprintf("• #%d · %s · %s · %s", telegramCellularCallInt(call, "index"), number, direction, telegramCLCCStateLabel(telegramCellularCallInt(call, "state"))))
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func (bot *telegramBot) handleATCommand(ctx context.Context, config telegramRuntimeConfig, chatID, adminID int64, deviceID, command string) {
|
||
result, err := bot.executeATCommand(ctx, deviceID, command)
|
||
outcome := "success"
|
||
if err != nil {
|
||
outcome = "failure"
|
||
bot.sendText(ctx, config, chatID, "AT 指令执行失败:"+err.Error(), bot.homeKeyboard())
|
||
} else {
|
||
bot.sendText(ctx, config, chatID, result, bot.homeKeyboard())
|
||
}
|
||
bot.server.recordAudit(ctx, fmt.Sprintf("telegram:%d", adminID), "telegram.at.execute", "device", deviceID, outcome, "telegram")
|
||
}
|
||
|
||
func (bot *telegramBot) executeATCommand(ctx context.Context, deviceID, command string) (string, error) {
|
||
command = strings.TrimSpace(command)
|
||
if err := validateATCommand(command); err != nil {
|
||
return "", err
|
||
}
|
||
_, _, physicalID, err := bot.device(deviceID)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
operationContext, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||
defer cancel()
|
||
response, err := bot.server.devices.ExecuteAT(operationContext, physicalID, command)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return fmt.Sprintf("设备:%s\n> %s\n\n%s", deviceID, command, formatTelegramAT(response)), nil
|
||
}
|
||
|
||
func (bot *telegramBot) handleUSSDCommand(ctx context.Context, config telegramRuntimeConfig, chatID, adminID int64, deviceID, code string) {
|
||
result, err := bot.executeUSSDCommand(ctx, deviceID, code)
|
||
outcome := "success"
|
||
if err != nil {
|
||
outcome = "failure"
|
||
bot.sendText(ctx, config, chatID, "USSD 指令执行失败:"+err.Error(), bot.homeKeyboard())
|
||
} else {
|
||
bot.sendUSSDResult(ctx, config, chatID, adminID, deviceID, result)
|
||
}
|
||
bot.server.recordAudit(ctx, fmt.Sprintf("telegram:%d", adminID), "telegram.ussd.start", "device", deviceID, outcome, "telegram")
|
||
}
|
||
|
||
func (bot *telegramBot) executeUSSDCommand(ctx context.Context, deviceID, code string) (device.USSDResult, error) {
|
||
_, _, physicalID, err := bot.device(deviceID)
|
||
if err != nil {
|
||
return device.USSDResult{}, err
|
||
}
|
||
operationContext, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||
defer cancel()
|
||
return bot.server.devices.USSD(operationContext, physicalID, strings.TrimSpace(code))
|
||
}
|
||
|
||
func (bot *telegramBot) handleUSSDReply(ctx context.Context, config telegramRuntimeConfig, chatID, adminID int64, sessionID, input string) {
|
||
operationContext, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||
result, err := bot.server.devices.ContinueUSSD(operationContext, strings.TrimSpace(sessionID), strings.TrimSpace(input))
|
||
cancel()
|
||
outcome := "success"
|
||
if err != nil {
|
||
outcome = "failure"
|
||
bot.sendText(ctx, config, chatID, "USSD 回复失败:"+err.Error(), bot.homeKeyboard())
|
||
} else {
|
||
bot.sendUSSDResult(ctx, config, chatID, adminID, "", result)
|
||
}
|
||
bot.server.recordAudit(ctx, fmt.Sprintf("telegram:%d", adminID), "telegram.ussd.reply", "ussd_session", "interactive", outcome, "telegram")
|
||
}
|
||
|
||
func (bot *telegramBot) handleUSSDCancel(ctx context.Context, config telegramRuntimeConfig, chatID, adminID int64, sessionID string) {
|
||
operationContext, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||
err := bot.server.devices.CancelUSSD(operationContext, strings.TrimSpace(sessionID))
|
||
cancel()
|
||
outcome := "success"
|
||
if err != nil {
|
||
outcome = "failure"
|
||
bot.sendText(ctx, config, chatID, "取消 USSD 会话失败:"+err.Error(), bot.homeKeyboard())
|
||
} else {
|
||
bot.sendText(ctx, config, chatID, "USSD 会话已取消。", bot.homeKeyboard())
|
||
}
|
||
bot.server.recordAudit(ctx, fmt.Sprintf("telegram:%d", adminID), "telegram.ussd.cancel", "ussd_session", "interactive", outcome, "telegram")
|
||
}
|
||
|
||
func (bot *telegramBot) sendUSSDResult(
|
||
ctx context.Context,
|
||
config telegramRuntimeConfig,
|
||
chatID, adminID int64,
|
||
deviceID string,
|
||
result device.USSDResult,
|
||
) {
|
||
if !result.Continueable || strings.TrimSpace(result.SessionID) == "" {
|
||
bot.clearInput(chatID, adminID)
|
||
bot.sendText(ctx, config, chatID, formatTelegramUSSD(deviceID, result), bot.homeKeyboard())
|
||
return
|
||
}
|
||
sessionID := strings.TrimSpace(result.SessionID)
|
||
bot.setInput(telegramInputState{
|
||
Kind: "ussd_reply", DeviceID: deviceID, Argument: sessionID,
|
||
ChatID: chatID, AdminID: adminID, CreatedAt: time.Now(),
|
||
})
|
||
token, err := bot.putPending(telegramPendingAction{
|
||
Kind: "menu_ussd_session", Argument: sessionID,
|
||
ChatID: chatID, AdminID: adminID, CreatedAt: time.Now(),
|
||
})
|
||
if err != nil {
|
||
bot.sendText(ctx, config, chatID, formatTelegramUSSD(deviceID, result), bot.homeKeyboard())
|
||
return
|
||
}
|
||
bot.sendText(ctx, config, chatID, formatTelegramUSSD(deviceID, result),
|
||
telegramKeyboard(
|
||
[]map[string]string{telegramButton("❌ 取消 USSD 会话", "uc:"+token+":cancel")},
|
||
[]map[string]string{telegramButton("🏠 主菜单", "menu:home")},
|
||
))
|
||
}
|
||
|
||
func formatTelegramUSSD(deviceID string, result device.USSDResult) string {
|
||
lines := make([]string, 0, 7)
|
||
if strings.TrimSpace(deviceID) != "" {
|
||
lines = append(lines, "设备:"+strings.TrimSpace(deviceID))
|
||
}
|
||
if strings.TrimSpace(result.Code) != "" {
|
||
lines = append(lines, "USSD:"+strings.TrimSpace(result.Code))
|
||
}
|
||
lines = append(lines, "状态:"+firstNonEmpty(strings.TrimSpace(result.Status), "final"))
|
||
if strings.TrimSpace(result.Text) != "" {
|
||
lines = append(lines, "\n"+strings.TrimSpace(result.Text))
|
||
} else if strings.TrimSpace(result.Raw) != "" {
|
||
lines = append(lines, "\n"+strings.TrimSpace(result.Raw))
|
||
} else {
|
||
lines = append(lines, "\n网络未返回文本内容。")
|
||
}
|
||
if result.Continueable && strings.TrimSpace(result.SessionID) != "" {
|
||
lines = append(lines,
|
||
"\n网络正在等待输入。",
|
||
"请直接发送回复内容,或点击下方按钮取消会话。",
|
||
)
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func (bot *telegramBot) handleVoWiFi(ctx context.Context, config telegramRuntimeConfig, chatID, adminID int64, deviceID, operation string) {
|
||
stored, entry, physicalID, err := bot.device(deviceID)
|
||
if err != nil {
|
||
bot.sendText(ctx, config, chatID, "VoWiFi 操作失败:"+err.Error(), bot.homeKeyboard())
|
||
return
|
||
}
|
||
if bot.server.vowifi == nil {
|
||
bot.sendText(ctx, config, chatID, "VoWiFi runtime 不可用。", bot.homeKeyboard())
|
||
return
|
||
}
|
||
operation = strings.ToLower(strings.TrimSpace(operation))
|
||
if operation == "status" {
|
||
state, stateErr := bot.server.vowifi.State(deviceID)
|
||
if stateErr != nil {
|
||
bot.sendText(ctx, config, chatID, "读取 VoWiFi 状态失败:"+stateErr.Error(), nil)
|
||
return
|
||
}
|
||
bot.sendText(ctx, config, chatID, formatTelegramVoWiFiState(state), nil)
|
||
return
|
||
}
|
||
var state vowifi.State
|
||
switch operation {
|
||
case "on", "off":
|
||
enabled := operation == "on"
|
||
if enabled && stored.NetworkEnabled {
|
||
bot.sendText(ctx, config, chatID, "VoWiFi 操作失败:请先关闭漫游数据。", bot.homeKeyboard())
|
||
return
|
||
}
|
||
if enabled && entry.Snapshot != nil {
|
||
if reason := device.RegionBlockReason(entry.Snapshot.IMSI); reason != "" {
|
||
bot.sendText(ctx, config, chatID, "VoWiFi 操作被拒绝:"+reason, nil)
|
||
return
|
||
}
|
||
}
|
||
previous := stored.VoWiFiEnabled
|
||
// Telegram follows the same fail-closed transaction as the web API:
|
||
// RF is disabled before either starting or stopping VoWiFi. Stopping it
|
||
// never implicitly permits cellular registration.
|
||
if _, err = bot.server.devices.SetFlight(ctx, physicalID, true); err != nil {
|
||
bot.sendText(ctx, config, chatID, "VoWiFi 操作失败:无法进入飞行模式:"+err.Error(), bot.homeKeyboard())
|
||
return
|
||
}
|
||
iccid := ""
|
||
if entry.Snapshot != nil {
|
||
iccid = strings.TrimSpace(entry.Snapshot.ICCID)
|
||
}
|
||
if iccid != "" {
|
||
policy, policyErr := bot.server.store.CardPolicy(ctx, iccid)
|
||
if errors.Is(policyErr, store.ErrNotFound) {
|
||
policy = store.CardPolicy{ICCID: iccid, IPVersion: "IPV4V6"}
|
||
policyErr = nil
|
||
}
|
||
if policyErr != nil {
|
||
bot.sendText(ctx, config, chatID, "VoWiFi 操作失败:"+policyErr.Error(), bot.homeKeyboard())
|
||
return
|
||
}
|
||
policy.VoWiFiEnabled = enabled
|
||
policy.AirplaneEnabled = true
|
||
policy.NetworkEnabled = false
|
||
policy.Source = "manual"
|
||
if err = bot.server.store.UpsertCardPolicy(ctx, policy); err != nil {
|
||
bot.sendText(ctx, config, chatID, "VoWiFi 操作失败:"+err.Error(), bot.homeKeyboard())
|
||
return
|
||
}
|
||
}
|
||
stored.VoWiFiEnabled = enabled
|
||
if err = bot.server.store.UpsertDevice(ctx, stored); err == nil {
|
||
state, err = bot.server.vowifi.RequestEnabled(deviceID, enabled)
|
||
}
|
||
if err != nil {
|
||
stored.VoWiFiEnabled = previous
|
||
_ = bot.server.store.UpsertDevice(ctx, stored)
|
||
if iccid != "" {
|
||
if policy, policyErr := bot.server.store.CardPolicy(ctx, iccid); policyErr == nil {
|
||
policy.VoWiFiEnabled = previous
|
||
policy.AirplaneEnabled = true
|
||
policy.NetworkEnabled = false
|
||
_ = bot.server.store.UpsertCardPolicy(ctx, policy)
|
||
}
|
||
}
|
||
if errors.Is(err, vowifiruntime.ErrOperationInProgress) && state.Enabled == enabled {
|
||
err = nil
|
||
}
|
||
}
|
||
case "reconnect":
|
||
if !stored.VoWiFiEnabled {
|
||
err = errors.New("请先启用 VoWiFi")
|
||
} else {
|
||
state, err = bot.server.vowifi.RequestReconnect(deviceID)
|
||
}
|
||
default:
|
||
bot.sendText(ctx, config, chatID, "操作必须是 status、on、off 或 reconnect。", nil)
|
||
return
|
||
}
|
||
outcome := "success"
|
||
if err != nil {
|
||
outcome = "failure"
|
||
bot.sendText(ctx, config, chatID, "VoWiFi 操作失败:"+err.Error(), bot.homeKeyboard())
|
||
} else {
|
||
bot.sendText(ctx, config, chatID, "VoWiFi 操作已受理。\n"+formatTelegramVoWiFiState(state), bot.homeKeyboard())
|
||
}
|
||
bot.server.recordAudit(ctx, fmt.Sprintf("telegram:%d", adminID), "telegram.vowifi."+operation, "device", deviceID, outcome, "telegram")
|
||
}
|
||
|
||
func (bot *telegramBot) finishAction(ctx context.Context, config telegramRuntimeConfig, action telegramPendingAction, auditAction, result string, err error) {
|
||
outcome := "success"
|
||
if err != nil {
|
||
outcome = "failure"
|
||
bot.sendText(ctx, config, action.ChatID, "操作失败:"+err.Error(), bot.homeKeyboard())
|
||
} else {
|
||
bot.sendText(ctx, config, action.ChatID, "✅ "+result, bot.homeKeyboard())
|
||
}
|
||
bot.server.recordAudit(ctx, fmt.Sprintf("telegram:%d", action.AdminID), auditAction, "device", action.DeviceID, outcome, "telegram")
|
||
}
|
||
|
||
func (bot *telegramBot) notifyInboundSMS(ctx context.Context) {
|
||
cursorInitialized := false
|
||
var cursor int64
|
||
for ctx.Err() == nil {
|
||
if !cursorInitialized {
|
||
latest, err := bot.server.store.LatestSMSMessageID(ctx)
|
||
if err != nil {
|
||
bot.warn("initialize Telegram SMS cursor", err)
|
||
if !waitTelegram(ctx, telegramNotificationPeriod) {
|
||
return
|
||
}
|
||
continue
|
||
}
|
||
cursor, cursorInitialized = latest, true
|
||
}
|
||
config, enabled, err := bot.loadConfig(ctx)
|
||
if err != nil {
|
||
bot.warn("load Telegram SMS notification configuration", err)
|
||
} else if !enabled {
|
||
if latest, latestErr := bot.server.store.LatestSMSMessageID(ctx); latestErr == nil {
|
||
cursor = latest
|
||
}
|
||
} else {
|
||
messages, listErr := bot.server.store.ListInboundSMSAfterID(ctx, cursor, 100)
|
||
if listErr != nil {
|
||
bot.warn("list Telegram SMS notifications", listErr)
|
||
} else {
|
||
for _, message := range messages {
|
||
if !store.ConcatSMSReadyToNotify(message.MessageID, message.Extra) {
|
||
// A carrier-split long SMS still waiting for segments. Hold
|
||
// the notification but advance the cursor so the partial row
|
||
// is not reconsidered every poll; when the final segment
|
||
// merges, the row re-enters with a fresh id and is pushed
|
||
// here as one complete message.
|
||
cursor = message.ID
|
||
continue
|
||
}
|
||
text := fmt.Sprintf("📩 新短信\n设备:%s\n来自:%s\n时间:%s\n\n%s", message.DeviceID, message.Peer, message.Timestamp.Local().Format("2006-01-02 15:04:05"), message.Body)
|
||
if sendErr := bot.sendText(ctx, config, 0, text, nil); sendErr != nil {
|
||
bot.warn("send Telegram SMS notification", sendErr)
|
||
break
|
||
}
|
||
cursor = message.ID
|
||
}
|
||
}
|
||
}
|
||
if !waitTelegram(ctx, telegramNotificationPeriod) {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
func (bot *telegramBot) device(deviceID string) (store.Device, device.Device, string, error) {
|
||
deviceID = strings.TrimSpace(deviceID)
|
||
if deviceID == "" {
|
||
return store.Device{}, device.Device{}, "", errors.New("设备 ID 不能为空")
|
||
}
|
||
stored, err := bot.server.store.Device(context.Background(), deviceID)
|
||
if err != nil {
|
||
return store.Device{}, device.Device{}, "", err
|
||
}
|
||
entry, physicalID, present := bot.server.physicalForConfig(stored)
|
||
if !present {
|
||
return stored, entry, "", errors.New("设备不在线")
|
||
}
|
||
return stored, entry, physicalID, nil
|
||
}
|
||
|
||
func (bot *telegramBot) authorized(config telegramRuntimeConfig, chatID, userID int64) bool {
|
||
return config.AdminID > 0 && userID == config.AdminID && strconv.FormatInt(chatID, 10) == config.ChatID
|
||
}
|
||
|
||
func (bot *telegramBot) loadConfig(ctx context.Context) (telegramRuntimeConfig, bool, error) {
|
||
setting, err := bot.server.store.NotificationSetting(ctx, "telegram")
|
||
if errors.Is(err, store.ErrNotFound) {
|
||
return telegramRuntimeConfig{}, false, nil
|
||
}
|
||
if err != nil {
|
||
return telegramRuntimeConfig{}, false, err
|
||
}
|
||
if !setting.Enabled {
|
||
return telegramRuntimeConfig{}, false, nil
|
||
}
|
||
var raw map[string]any
|
||
if err := json.Unmarshal(setting.Config, &raw); err != nil {
|
||
return telegramRuntimeConfig{}, false, fmt.Errorf("decode Telegram config: %w", err)
|
||
}
|
||
config := telegramRuntimeConfig{
|
||
Token: configString(raw, "bot_token"),
|
||
ChatID: configString(raw, "chat_id"),
|
||
BaseURL: configString(raw, "base_url"),
|
||
Proxy: configString(raw, "proxy"),
|
||
}
|
||
if config.BaseURL == "" {
|
||
config.BaseURL = defaultTelegramBaseURL
|
||
}
|
||
if admin := configString(raw, "admin_id"); admin != "" {
|
||
config.AdminID, err = strconv.ParseInt(admin, 10, 64)
|
||
if err != nil || config.AdminID <= 0 {
|
||
return telegramRuntimeConfig{}, false, errors.New("telegram.admin_id must be a positive integer")
|
||
}
|
||
}
|
||
if !telegramTokenPattern.MatchString(config.Token) || config.ChatID == "" {
|
||
return telegramRuntimeConfig{}, false, errors.New("Telegram bot token or chat id is invalid")
|
||
}
|
||
return config, true, nil
|
||
}
|
||
|
||
func (bot *telegramBot) call(ctx context.Context, config telegramRuntimeConfig, method string, payload any, result any) error {
|
||
base, err := validateTelegramAPIURL(ctx, config.BaseURL, config.Token, method)
|
||
if err != nil {
|
||
return redactTelegramError(err, config.Token)
|
||
}
|
||
body, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
client, err := restrictedHTTPClient(ctx, 10*time.Second, config.Proxy)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, base.String(), bytes.NewReader(body))
|
||
if err != nil {
|
||
return redactTelegramError(err, config.Token)
|
||
}
|
||
request.Header.Set("Content-Type", "application/json")
|
||
request.Header.Set("User-Agent", "vocat-telegram-bot/1")
|
||
response, err := client.Do(request)
|
||
if err != nil {
|
||
return redactTelegramError(err, config.Token)
|
||
}
|
||
defer response.Body.Close()
|
||
responseBody, err := io.ReadAll(io.LimitReader(response.Body, 2<<20))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var envelope telegramAPIResponse
|
||
if err := json.Unmarshal(responseBody, &envelope); err != nil {
|
||
return fmt.Errorf("decode Telegram response: %w", err)
|
||
}
|
||
if response.StatusCode < 200 || response.StatusCode >= 300 || !envelope.OK {
|
||
return fmt.Errorf("Telegram %s failed: HTTP %d %s", method, response.StatusCode, envelope.Description)
|
||
}
|
||
if result != nil && len(envelope.Result) != 0 {
|
||
if err := json.Unmarshal(envelope.Result, result); err != nil {
|
||
return fmt.Errorf("decode Telegram %s result: %w", method, err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (bot *telegramBot) sendText(ctx context.Context, config telegramRuntimeConfig, chatID int64, text string, replyMarkup any) error {
|
||
target := config.ChatID
|
||
if chatID != 0 {
|
||
target = strconv.FormatInt(chatID, 10)
|
||
}
|
||
payload := map[string]any{
|
||
"chat_id": target,
|
||
"text": truncateTelegramText(text, 3900),
|
||
}
|
||
if replyMarkup != nil {
|
||
payload["reply_markup"] = replyMarkup
|
||
}
|
||
requestContext, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||
defer cancel()
|
||
return bot.call(requestContext, config, "sendMessage", payload, nil)
|
||
}
|
||
|
||
func (bot *telegramBot) answerCallback(ctx context.Context, config telegramRuntimeConfig, callbackID, text string) error {
|
||
payload := map[string]any{"callback_query_id": callbackID}
|
||
if text != "" {
|
||
payload["text"] = text
|
||
}
|
||
requestContext, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||
defer cancel()
|
||
return bot.call(requestContext, config, "answerCallbackQuery", payload, nil)
|
||
}
|
||
|
||
func (bot *telegramBot) warn(message string, err error) {
|
||
if err == nil || bot.server.logger == nil {
|
||
return
|
||
}
|
||
now := time.Now()
|
||
text := redactTelegramText(err.Error(), "")
|
||
bot.logMu.Lock()
|
||
if text == bot.lastLogText && now.Sub(bot.lastLogTime) < time.Minute {
|
||
bot.logMu.Unlock()
|
||
return
|
||
}
|
||
bot.lastLogText, bot.lastLogTime = text, now
|
||
bot.logMu.Unlock()
|
||
bot.server.logger.Warn(message, "error", text)
|
||
}
|
||
|
||
func redactTelegramError(err error, token string) error {
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
return errors.New(redactTelegramText(err.Error(), token))
|
||
}
|
||
|
||
func redactTelegramText(value, token string) string {
|
||
if strings.TrimSpace(token) != "" {
|
||
value = strings.ReplaceAll(value, token, "[REDACTED]")
|
||
}
|
||
return telegramTokenInURLPattern.ReplaceAllString(value, "bot[REDACTED]")
|
||
}
|
||
|
||
func parseTelegramCommand(text string) (string, string) {
|
||
text = strings.TrimSpace(text)
|
||
if !strings.HasPrefix(text, "/") {
|
||
return "", ""
|
||
}
|
||
commandToken, remainder, _ := strings.Cut(text, " ")
|
||
commandToken = strings.TrimPrefix(commandToken, "/")
|
||
if at := strings.IndexByte(commandToken, '@'); at >= 0 {
|
||
commandToken = commandToken[:at]
|
||
}
|
||
return strings.ToLower(strings.TrimSpace(commandToken)), strings.TrimSpace(remainder)
|
||
}
|
||
|
||
func splitTelegramArguments(value string, count int) []string {
|
||
fields := strings.Fields(value)
|
||
if len(fields) == 0 || count <= 0 {
|
||
return nil
|
||
}
|
||
if len(fields) <= count {
|
||
return fields
|
||
}
|
||
result := append([]string(nil), fields[:count-1]...)
|
||
return append(result, strings.Join(fields[count-1:], " "))
|
||
}
|
||
|
||
func validTelegramDialNumber(number string) bool {
|
||
number = strings.TrimSpace(number)
|
||
if strings.HasPrefix(number, "+") {
|
||
number = number[1:]
|
||
}
|
||
if len(number) < 3 || len(number) > 20 {
|
||
return false
|
||
}
|
||
for _, character := range number {
|
||
if character < '0' || character > '9' {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func formatTelegramAT(response modem.Response) string {
|
||
parts := make([]string, 0, 2)
|
||
if text := strings.TrimSpace(response.Text()); text != "" {
|
||
parts = append(parts, text)
|
||
}
|
||
if final := strings.TrimSpace(response.Final); final != "" {
|
||
parts = append(parts, final)
|
||
}
|
||
if len(parts) == 0 {
|
||
return "模块没有返回结果"
|
||
}
|
||
return strings.Join(parts, "\n")
|
||
}
|
||
|
||
func formatTelegramVoWiFiState(state vowifi.State) string {
|
||
lines := []string{
|
||
fmt.Sprintf("状态:%s", firstNonEmpty(string(state.Phase), "idle")),
|
||
fmt.Sprintf("SIM=%t Access=%t Tunnel=%t IMS=%t SMS=%t", state.SIMReady, state.AccessReady, state.TunnelReady, state.IMSReady, state.SMSReady),
|
||
}
|
||
if state.LastReason != "" {
|
||
lines = append(lines, "原因:"+state.LastReason)
|
||
}
|
||
if state.LastError != "" {
|
||
lines = append(lines, "错误:"+state.LastError)
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func truncateTelegramText(value string, maximum int) string {
|
||
runes := []rune(value)
|
||
if maximum <= 0 || len(runes) <= maximum {
|
||
return value
|
||
}
|
||
return string(runes[:maximum]) + "…"
|
||
}
|
||
|
||
func tailDigits(value string, count int) string {
|
||
value = strings.TrimSpace(value)
|
||
if count <= 0 || len(value) <= count {
|
||
return value
|
||
}
|
||
return value[len(value)-count:]
|
||
}
|
||
|
||
func waitTelegram(ctx context.Context, duration time.Duration) bool {
|
||
timer := time.NewTimer(duration)
|
||
defer timer.Stop()
|
||
select {
|
||
case <-ctx.Done():
|
||
return false
|
||
case <-timer.C:
|
||
return true
|
||
}
|
||
}
|