| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010 |
- package server
- import (
- "bytes"
- "context"
- cryptorand "crypto/rand"
- "encoding/hex"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net/http"
- "net/http/httptest"
- "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
- telegramMaxDialDuration = 10 * time.Minute
- )
- 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
- }
- 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 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),
- }
- 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 {
- 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": "status", "description": "查看设备状态"},
- {"command": "esim", "description": "查看已安装 eSIM Profile"},
- {"command": "wfc", "description": "管理 WiFi Calling"},
- {"command": "sms", "description": "发送短信(需要确认)"},
- {"command": "call", "description": "限时拨号并自动挂断(需要确认)"},
- {"command": "calls", "description": "查看当前通话"},
- {"command": "hangup", "description": "挂断通话"},
- {"command": "help", "description": "查看命令帮助"},
- }
- _ = bot.call(requestContext, config, "setMyCommands", map[string]any{"commands": commands}, nil)
- 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 command == "" {
- return
- }
- switch command {
- case "start", "menu", "help":
- bot.sendHelp(ctx, config, message.Chat.ID)
- case "status", "devices":
- bot.sendDeviceStatus(ctx, config, message.Chat.ID, strings.TrimSpace(remainder))
- case "esim":
- bot.sendESIMProfiles(ctx, config, message.Chat.ID, strings.TrimSpace(remainder))
- case "switch":
- parts := strings.Fields(remainder)
- 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) != 2 {
- bot.sendText(ctx, config, message.Chat.ID, "用法:/wfc <设备ID> <status|on|off|reconnect>", nil)
- return
- }
- bot.handleVoWiFi(ctx, config, message.Chat.ID, message.From.ID, parts[0], parts[1])
- case "sms":
- parts := splitTelegramArguments(remainder, 3)
- 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) != 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":
- bot.executeSimpleCallAction(ctx, config, message.Chat.ID, message.From.ID, strings.TrimSpace(remainder), "answer")
- case "hangup":
- bot.executeSimpleCallAction(ctx, config, message.Chat.ID, message.From.ID, strings.TrimSpace(remainder), "hangup")
- case "calls":
- bot.executeSimpleCallAction(ctx, config, message.Chat.ID, message.From.ID, strings.TrimSpace(remainder), "status")
- default:
- bot.sendText(ctx, config, message.Chat.ID, "未知命令。发送 /help 查看可用操作。", nil)
- }
- }
- func (bot *telegramBot) handleCallback(ctx context.Context, config telegramRuntimeConfig, callback *telegramCallbackQuery) {
- data := strings.TrimSpace(callback.Data)
- if data == "menu:status" {
- bot.sendDeviceStatus(ctx, config, callback.Message.Chat.ID, "")
- return
- }
- if data == "menu:help" {
- bot.sendHelp(ctx, config, callback.Message.Chat.ID)
- 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, "操作已取消。", nil)
- 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 (bot *telegramBot) sendHelp(ctx context.Context, config telegramRuntimeConfig, chatID int64) {
- text := strings.Join([]string{
- "vocat Telegram 控制", "",
- "/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> — 立即挂断",
- "",
- "Bot 不提供 eSIM 下载、删除或改名,也不采集或转发通话音频。控制命令只接受设置中的 Admin ID。",
- }, "\n")
- keyboard := map[string]any{"inline_keyboard": [][]map[string]string{{
- {"text": "📊 设备状态", "callback_data": "menu:status"},
- {"text": "❓ 帮助", "callback_data": "menu:help"},
- }}}
- 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)}
- if !present {
- lines = append(lines, "设备:离线")
- } else {
- lines = append(lines, "设备:在线")
- if snapshot := entry.Snapshot; snapshot != nil {
- lines = append(lines,
- "SIM:"+map[bool]string{true: "Ready", false: firstNonEmpty(snapshot.SIMStatus, "未就绪")}[snapshot.SIMReady],
- "ICCID:"+firstNonEmpty(snapshot.ICCID, "--"),
- "IMSI:"+firstNonEmpty(snapshot.IMSI, "--"),
- "号码:"+firstNonEmpty(snapshot.Phone.Number, "--"),
- "运营商:"+firstNonEmpty(snapshot.OperatorName, snapshot.OperatorCode, "--"),
- "蜂窝模式:"+map[bool]string{true: "飞行模式", false: "开启"}[snapshot.FlightMode],
- )
- }
- }
- if bot.server.vowifi != nil {
- if state, stateErr := bot.server.vowifi.State(stored.ID); stateErr == nil {
- lines = append(lines,
- fmt.Sprintf("VoWiFi:%s · Tunnel=%t IMS=%t SMS=%t", firstNonEmpty(string(state.Phase), "idle"), state.TunnelReady, state.IMSReady, state.SMSReady),
- )
- if state.LastError != "" {
- lines = append(lines, "最后错误:"+state.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"), nil)
- }
- func (bot *telegramBot) sendESIMProfiles(ctx context.Context, config telegramRuntimeConfig, chatID 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(只读)"}
- 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))
- }
- }
- lines = append(lines, "\n切换:/switch "+deviceID+" <目标ICCID>")
- bot.sendText(ctx, config, chatID, strings.Join(lines, "\n"), nil)
- }
- 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
- }
- if _, entry, _, err := bot.device(deviceID); err != nil {
- bot.sendText(ctx, config, chatID, "无法拨号:"+err.Error(), nil)
- return
- } else if entry.Snapshot != nil && entry.Snapshot.FlightMode {
- bot.sendText(ctx, config, chatID, "设备处于飞行模式,蜂窝语音拨号不可用。当前 Bot 不实现 IMS 语音或音频处理。", 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持续:%d 秒,然后自动挂断。\n不会采集或处理通话音频。", deviceID, number, 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 (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()
- for key, value := range bot.pending {
- if now.Sub(value.CreatedAt) > telegramConfirmationTTL {
- delete(bot.pending, key)
- }
- }
- bot.pending[token] = action
- return token, nil
- }
- 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) > telegramConfirmationTTL {
- 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) {
- _, entry, physicalID, err := bot.device(action.DeviceID)
- if err != nil {
- return "", err
- }
- if entry.Snapshot != nil && entry.Snapshot.FlightMode {
- return "", errors.New("device is in airplane mode")
- }
- 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 !strings.EqualFold(strings.TrimSpace(response.Final), "OK") {
- return "", fmt.Errorf("拨号未被模块接受: %s", formatTelegramAT(response))
- }
- bot.sendText(ctx, config, action.ChatID, fmt.Sprintf("📞 已开始拨打 %s,将在 %d 秒后自动挂断。", action.Argument, int(action.Duration/time.Second)), nil)
- timer := time.NewTimer(action.Duration)
- defer timer.Stop()
- select {
- case <-ctx.Done():
- return "", ctx.Err()
- case <-timer.C:
- }
- hangContext, cancelHang := context.WithTimeout(context.Background(), 15*time.Second)
- defer cancelHang()
- hangResponse, hangErr := bot.server.devices.ExecuteAT(hangContext, physicalID, "ATH")
- if hangErr != nil {
- return "", fmt.Errorf("拨号已执行,但自动挂断失败: %w", hangErr)
- }
- if !strings.EqualFold(strings.TrimSpace(hangResponse.Final), "OK") {
- return "", fmt.Errorf("拨号已执行,但模块未确认自动挂断: %s", formatTelegramAT(hangResponse))
- }
- return fmt.Sprintf("拨号动作完成:%s,持续 %d 秒后已自动挂断。", action.Argument, int(action.Duration/time.Second)), nil
- }
- 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
- }
- _, _, physicalID, err := bot.device(deviceID)
- if err != nil {
- bot.sendText(ctx, config, chatID, "通话操作失败:"+err.Error(), nil)
- return
- }
- command := map[string]string{"status": "AT+CLCC", "answer": "ATA", "hangup": "ATH"}[action]
- operationContext, cancel := context.WithTimeout(ctx, 20*time.Second)
- response, err := bot.server.devices.ExecuteAT(operationContext, physicalID, command)
- cancel()
- outcome := "success"
- if err != nil {
- outcome = "failure"
- bot.sendText(ctx, config, chatID, "通话操作失败:"+err.Error(), nil)
- } else {
- text := formatTelegramAT(response)
- if action == "status" && strings.TrimSpace(response.Text()) == "" {
- text = "当前没有活动通话。"
- }
- bot.sendText(ctx, config, chatID, text, nil)
- }
- bot.server.recordAudit(ctx, fmt.Sprintf("telegram:%d", adminID), "telegram.call."+action, "device", deviceID, outcome, "telegram")
- }
- func (bot *telegramBot) handleVoWiFi(ctx context.Context, config telegramRuntimeConfig, chatID, adminID int64, deviceID, operation string) {
- stored, entry, _, err := bot.device(deviceID)
- if err != nil {
- bot.sendText(ctx, config, chatID, "VoWiFi 操作失败:"+err.Error(), nil)
- return
- }
- if bot.server.vowifi == nil {
- bot.sendText(ctx, config, chatID, "VoWiFi runtime 不可用。", nil)
- 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 && entry.Snapshot != nil {
- if reason := device.RegionBlockReason(entry.Snapshot.IMSI); reason != "" {
- bot.sendText(ctx, config, chatID, "VoWiFi 操作被拒绝:"+reason, nil)
- return
- }
- }
- previous := stored.VoWiFiEnabled
- 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 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(), nil)
- } else {
- bot.sendText(ctx, config, chatID, "VoWiFi 操作已受理。\n"+formatTelegramVoWiFiState(state), nil)
- }
- 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(), nil)
- } else {
- bot.sendText(ctx, config, action.ChatID, "✅ "+result, nil)
- }
- 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 {
- 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 = "https://api.telegram.org"
- }
- 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 := validateOutboundURL(ctx, config.BaseURL, true)
- if err != nil {
- return err
- }
- base.Path = strings.TrimRight(base.Path, "/") + "/bot" + config.Token + "/" + method
- base.RawPath, base.RawQuery, base.Fragment = "", "", ""
- 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 err
- }
- 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 err
- }
- 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 := 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", err)
- }
- 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
- }
- }
|