mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
feat: enhance Telegram call handling with VoWiFi and cellular support
This commit is contained in:
@@ -23,6 +23,7 @@ type fakeDeviceController struct {
|
||||
entry device.Device
|
||||
atResponse modem.Response
|
||||
atErr error
|
||||
atHandler func(string) (modem.Response, error)
|
||||
scanResult device.OperatorScanResult
|
||||
scanErr error
|
||||
ussdResult device.USSDResult
|
||||
@@ -44,7 +45,11 @@ func (f fakeDeviceController) Get(id string) (device.Device, error) {
|
||||
func (f fakeDeviceController) Refresh(context.Context, string) (device.Snapshot, error) {
|
||||
return device.Snapshot{}, nil
|
||||
}
|
||||
func (f fakeDeviceController) ExecuteAT(context.Context, string, string) (modem.Response, error) {
|
||||
|
||||
func (f fakeDeviceController) ExecuteAT(_ context.Context, _ string, command string) (modem.Response, error) {
|
||||
if f.atHandler != nil {
|
||||
return f.atHandler(command)
|
||||
}
|
||||
return f.atResponse, f.atErr
|
||||
}
|
||||
func (f fakeDeviceController) Reboot(context.Context, string) error { return nil }
|
||||
|
||||
+552
-45
@@ -50,6 +50,9 @@ type telegramBot struct {
|
||||
logMu sync.Mutex
|
||||
lastLogTime time.Time
|
||||
lastLogText string
|
||||
|
||||
callMu sync.Mutex
|
||||
activeDials map[string]struct{}
|
||||
}
|
||||
|
||||
type telegramPendingAction struct {
|
||||
@@ -107,8 +110,9 @@ func (s *Server) StartTelegramBot(ctx context.Context) {
|
||||
ctx = context.Background()
|
||||
}
|
||||
bot := &telegramBot{
|
||||
server: s,
|
||||
pending: make(map[string]telegramPendingAction),
|
||||
server: s,
|
||||
pending: make(map[string]telegramPendingAction),
|
||||
activeDials: make(map[string]struct{}),
|
||||
}
|
||||
go bot.poll(ctx)
|
||||
go bot.notifyInboundSMS(ctx)
|
||||
@@ -191,6 +195,7 @@ func (bot *telegramBot) bootstrap(ctx context.Context, config telegramRuntimeCon
|
||||
{"command": "sms", "description": "发送短信(需要确认)"},
|
||||
{"command": "call", "description": "限时拨号并自动挂断(需要确认)"},
|
||||
{"command": "calls", "description": "查看当前通话"},
|
||||
{"command": "answer", "description": "接听当前来电"},
|
||||
{"command": "hangup", "description": "挂断通话"},
|
||||
{"command": "at", "description": "向指定设备发送安全 AT 指令"},
|
||||
{"command": "ussd", "description": "向指定设备发送 USSD 指令"},
|
||||
@@ -365,7 +370,7 @@ func (bot *telegramBot) sendHelp(ctx context.Context, config telegramRuntimeConf
|
||||
"/sms <设备ID> <号码> <内容> — 发送短信(需确认)",
|
||||
"/call <设备ID> <号码> <秒数> — 拨号并在 1–600 秒后自动挂断(需确认)",
|
||||
"/calls <设备ID> — 查看模块当前通话",
|
||||
"/answer <设备ID> — 接听蜂窝来电",
|
||||
"/answer <设备ID> — 按当前通道接听来电",
|
||||
"/hangup <设备ID> — 立即挂断",
|
||||
"/at <设备ID> <AT指令> — 执行经过安全校验的单行 AT 指令",
|
||||
"/ussd <设备ID> <代码> — 发送 USSD 指令",
|
||||
@@ -533,18 +538,21 @@ func (bot *telegramBot) confirmCall(ctx context.Context, config telegramRuntimeC
|
||||
bot.sendText(ctx, config, chatID, "拨号号码无效,只允许一个可选的前导 + 和 3–20 位数字。", nil)
|
||||
return
|
||||
}
|
||||
if _, entry, _, err := bot.device(deviceID); err != nil {
|
||||
stored, entry, _, err := bot.device(deviceID)
|
||||
if 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)
|
||||
}
|
||||
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持续:%d 秒,然后自动挂断。\n不会采集或处理通话音频。", deviceID, number, int(duration/time.Second)))
|
||||
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) {
|
||||
@@ -631,40 +639,23 @@ func (bot *telegramBot) executeESIMSwitch(ctx context.Context, action telegramPe
|
||||
}
|
||||
|
||||
func (bot *telegramBot) executeTimedCall(ctx context.Context, config telegramRuntimeConfig, action telegramPendingAction) (string, error) {
|
||||
_, entry, physicalID, err := bot.device(action.DeviceID)
|
||||
stored, 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()
|
||||
transport, controller, err := bot.telegramCallTransport(stored, entry)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("拨号失败: %w", err)
|
||||
return "", err
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(response.Final), "OK") {
|
||||
return "", fmt.Errorf("拨号未被模块接受: %s", formatTelegramAT(response))
|
||||
if !bot.beginTelegramDial(action.DeviceID) {
|
||||
return "", errors.New("该设备已有一个由 Telegram 发起的限时拨号任务")
|
||||
}
|
||||
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:
|
||||
defer bot.endTelegramDial(action.DeviceID)
|
||||
|
||||
if transport == "vowifi" {
|
||||
return bot.executeTimedVoWiFiCall(ctx, config, action, controller)
|
||||
}
|
||||
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
|
||||
return bot.executeTimedCellularCall(ctx, config, action, physicalID)
|
||||
}
|
||||
|
||||
func (bot *telegramBot) executeSimpleCallAction(ctx context.Context, config telegramRuntimeConfig, chatID, adminID int64, deviceID, action string) {
|
||||
@@ -672,27 +663,543 @@ func (bot *telegramBot) executeSimpleCallAction(ctx context.Context, config tele
|
||||
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)
|
||||
stored, entry, 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()
|
||||
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(), nil)
|
||||
} else {
|
||||
text := formatTelegramAT(response)
|
||||
if action == "status" && strings.TrimSpace(response.Text()) == "" {
|
||||
text = "当前没有活动通话。"
|
||||
}
|
||||
bot.sendText(ctx, config, chatID, text, nil)
|
||||
bot.sendText(ctx, config, chatID, result, nil)
|
||||
}
|
||||
bot.server.recordAudit(ctx, fmt.Sprintf("telegram:%d", adminID), "telegram.call."+action, "device", deviceID, outcome, "telegram")
|
||||
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) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/modem"
|
||||
"vocat/internal/store"
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
func TestTelegramAPIURLSupportsBaseAndTemplate(t *testing.T) {
|
||||
@@ -181,3 +182,145 @@ func TestTelegramErrorsRedactBotTokens(t *testing.T) {
|
||||
t.Fatalf("redacted error = %q", redacted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramCallTransportFollowsConfiguredCardMode(t *testing.T) {
|
||||
controller := &telegramTestCallController{state: vowifi.State{IMSReady: true, Phase: vowifi.PhaseIMSReady}}
|
||||
bot := &telegramBot{server: &Server{vowifi: controller}}
|
||||
|
||||
transport, gotController, err := bot.telegramCallTransport(
|
||||
store.Device{ID: "EC20", VoWiFiEnabled: true},
|
||||
device.Device{Snapshot: &device.Snapshot{FlightMode: true}},
|
||||
)
|
||||
if err != nil || transport != "vowifi" || gotController == nil {
|
||||
t.Fatalf("VoWiFi route = %q, %#v, %v", transport, gotController, err)
|
||||
}
|
||||
|
||||
transport, gotController, err = bot.telegramCallTransport(
|
||||
store.Device{ID: "EC20", VoWiFiEnabled: false},
|
||||
device.Device{Snapshot: &device.Snapshot{FlightMode: false}},
|
||||
)
|
||||
if err != nil || transport != "cellular" || gotController != nil {
|
||||
t.Fatalf("cellular route = %q, %#v, %v", transport, gotController, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramCallTransportDoesNotFallBackFromUnreadyVoWiFi(t *testing.T) {
|
||||
controller := &telegramTestCallController{state: vowifi.State{
|
||||
Phase: vowifi.PhaseFailed, LastError: "SIP registration was rejected: SIP 403",
|
||||
}}
|
||||
bot := &telegramBot{server: &Server{vowifi: controller}}
|
||||
_, _, err := bot.telegramCallTransport(
|
||||
store.Device{ID: "EC20", VoWiFiEnabled: true},
|
||||
device.Device{Snapshot: &device.Snapshot{FlightMode: true}},
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "SIP 403") {
|
||||
t.Fatalf("unready VoWiFi route error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramTimedVoWiFiCallUsesIMSAndHangsUpByCallID(t *testing.T) {
|
||||
controller := &telegramTestCallController{state: vowifi.State{IMSReady: true}}
|
||||
controller.dialResult = vowifi.Call{ID: "ims-call-1", Number: "+447700900123", Direction: "outgoing", State: "dialing"}
|
||||
controller.calls = []vowifi.Call{{ID: "ims-call-1", Number: "+447700900123", Direction: "outgoing", State: "active"}}
|
||||
bot := &telegramBot{server: &Server{vowifi: controller}}
|
||||
result, err := bot.executeTimedVoWiFiCall(context.Background(), telegramRuntimeConfig{}, telegramPendingAction{
|
||||
DeviceID: "EC20", Argument: "+447700900123", Duration: 20 * time.Millisecond,
|
||||
}, controller)
|
||||
if err != nil || !strings.Contains(result, "已接通") {
|
||||
t.Fatalf("timed VoWiFi result = %q, %v", result, err)
|
||||
}
|
||||
if controller.dialed != "+447700900123" || len(controller.hungUp) != 1 || controller.hungUp[0] != "ims-call-1" {
|
||||
t.Fatalf("IMS actions dial=%q hangup=%#v", controller.dialed, controller.hungUp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramTimedCellularCallUsesATDCLCCAndATH(t *testing.T) {
|
||||
commands := make([]string, 0, 3)
|
||||
devices := fakeDeviceController{atHandler: func(command string) (modem.Response, error) {
|
||||
commands = append(commands, command)
|
||||
switch {
|
||||
case strings.HasPrefix(command, "ATD"):
|
||||
return modem.Response{Final: "OK"}, nil
|
||||
case command == "AT+CLCC":
|
||||
return modem.Response{Lines: []string{`+CLCC: 1,0,2,0,0,"+447700900123",145`}, Final: "OK"}, nil
|
||||
case command == "ATH":
|
||||
return modem.Response{Final: "OK"}, nil
|
||||
default:
|
||||
return modem.Response{}, errors.New("unexpected command")
|
||||
}
|
||||
}}
|
||||
bot := &telegramBot{server: &Server{devices: devices}}
|
||||
result, err := bot.executeTimedCellularCall(context.Background(), telegramRuntimeConfig{}, telegramPendingAction{
|
||||
DeviceID: "EC20", Argument: "+447700900123", Duration: 20 * time.Millisecond,
|
||||
}, "physical")
|
||||
if err != nil || !strings.Contains(result, "正在拨号") {
|
||||
t.Fatalf("timed cellular result = %q, %v", result, err)
|
||||
}
|
||||
joined := strings.Join(commands, ",")
|
||||
for _, expected := range []string{"ATD+447700900123;", "AT+CLCC", "ATH"} {
|
||||
if !strings.Contains(joined, expected) {
|
||||
t.Fatalf("commands %q omit %q", joined, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramVoWiFiFailureIncludesSIPDiagnostic(t *testing.T) {
|
||||
err := telegramVoWiFiCallFailure(vowifi.Call{State: "failed", SIPCode: 403, Reason: "Forbidden"})
|
||||
if !strings.Contains(err.Error(), "SIP 403") || !strings.Contains(err.Error(), "Forbidden") {
|
||||
t.Fatalf("VoWiFi diagnostic = %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramVoWiFi487IsReportedAsCancelledOutcome(t *testing.T) {
|
||||
result, err := telegramVoWiFiCallOutcome("888", vowifi.Call{
|
||||
State: "failed", SIPCode: 487, Reason: "Request Terminated",
|
||||
})
|
||||
if err != nil || !strings.Contains(result, "取消或终止") || !strings.Contains(result, "SIP 487") {
|
||||
t.Fatalf("487 outcome = %q, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
type telegramTestCallController struct {
|
||||
state vowifi.State
|
||||
calls []vowifi.Call
|
||||
dialResult vowifi.Call
|
||||
dialErr error
|
||||
dialed string
|
||||
hungUp []string
|
||||
}
|
||||
|
||||
func (controller *telegramTestCallController) State(string) (vowifi.State, error) {
|
||||
return controller.state, nil
|
||||
}
|
||||
|
||||
func (controller *telegramTestCallController) RequestEnabled(string, bool) (vowifi.State, error) {
|
||||
return controller.state, nil
|
||||
}
|
||||
|
||||
func (controller *telegramTestCallController) RequestReconnect(string) (vowifi.State, error) {
|
||||
return controller.state, nil
|
||||
}
|
||||
|
||||
func (controller *telegramTestCallController) Calls(string) ([]vowifi.Call, error) {
|
||||
return append([]vowifi.Call(nil), controller.calls...), nil
|
||||
}
|
||||
|
||||
func (controller *telegramTestCallController) DialCall(_ context.Context, _ string, number string) (vowifi.Call, error) {
|
||||
controller.dialed = number
|
||||
return controller.dialResult, controller.dialErr
|
||||
}
|
||||
|
||||
func (controller *telegramTestCallController) AnswerCall(_ context.Context, _ string, id string) (vowifi.Call, error) {
|
||||
for _, call := range controller.calls {
|
||||
if call.ID == id {
|
||||
call.State = "active"
|
||||
return call, nil
|
||||
}
|
||||
}
|
||||
return vowifi.Call{}, errors.New("call not found")
|
||||
}
|
||||
|
||||
func (controller *telegramTestCallController) HangupCall(_ context.Context, _ string, id string) error {
|
||||
controller.hungUp = append(controller.hungUp, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -156,6 +156,10 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey)
|
||||
case <-session.refreshContext.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
if session.callWasTerminated(call.callID) {
|
||||
session.finishCall(call.callID, "ended", 0, "")
|
||||
return
|
||||
}
|
||||
session.finishCall(call.callID, "failed", 0, "SIP INVITE transaction timed out")
|
||||
return
|
||||
case response := <-call.responses:
|
||||
@@ -193,6 +197,11 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey)
|
||||
}
|
||||
session.setCallMediaReady(call.callID)
|
||||
session.setCallState(call.callID, "active")
|
||||
} else if session.callWasTerminated(call.callID) {
|
||||
// CANCEL normally causes the pending INVITE transaction to finish
|
||||
// with 487 Request Terminated. It is the expected response to our
|
||||
// local hang-up, not a new network rejection.
|
||||
session.finishCall(call.callID, "ended", response.StatusCode, response.Reason)
|
||||
} else {
|
||||
session.finishCall(call.callID, "failed", response.StatusCode, response.Reason)
|
||||
}
|
||||
@@ -241,6 +250,7 @@ func (session *Session) HangupCall(ctx context.Context, id string) error {
|
||||
state := call.public.State
|
||||
direction := call.public.Direction
|
||||
request, respond := call.invite, call.respond
|
||||
call.terminated = true
|
||||
session.callMu.Unlock()
|
||||
if direction == "incoming" && state == "ringing" && request != nil && respond != nil {
|
||||
response, err := buildSIPResponseWithBody(request, 486, session.fromTag, nil)
|
||||
@@ -264,6 +274,13 @@ func (session *Session) HangupCall(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (session *Session) callWasTerminated(id string) bool {
|
||||
session.callMu.Lock()
|
||||
defer session.callMu.Unlock()
|
||||
call := session.calls[id]
|
||||
return call != nil && call.terminated
|
||||
}
|
||||
|
||||
func (session *Session) handleCallRequest(request *sipRequest, respond func([]byte) error) bool {
|
||||
switch request.Method {
|
||||
case "INVITE":
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
@@ -80,6 +81,36 @@ func TestRejectedOutgoingCallRetainsSIPReason(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelledOutgoingInviteDoesNotBecomeFailedOn487(t *testing.T) {
|
||||
call := &imsCall{
|
||||
public: vowifi.Call{ID: "cancelled", State: "dialing"},
|
||||
callID: "cancelled",
|
||||
responses: make(chan *sipResponse, 1),
|
||||
terminated: true,
|
||||
}
|
||||
session := &Session{
|
||||
calls: map[string]*imsCall{call.callID: call},
|
||||
transactions: make(map[sipTransactionKey]chan *sipResponse),
|
||||
refreshContext: context.Background(),
|
||||
}
|
||||
key := sipTransactionKey{callID: call.callID, cseq: 1, method: "INVITE"}
|
||||
go session.watchOutgoingCall(call, key)
|
||||
call.responses <- &sipResponse{StatusCode: 487, Reason: "Request Terminated"}
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
calls := session.Calls()
|
||||
if len(calls) == 1 && calls[0].EndedAt != nil {
|
||||
if calls[0].State != "ended" || calls[0].SIPCode != 487 {
|
||||
t.Fatalf("cancelled INVITE = %#v", calls[0])
|
||||
}
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatal("cancelled INVITE did not reach a terminal state")
|
||||
}
|
||||
|
||||
func TestValidCallNumber(t *testing.T) {
|
||||
if !validCallNumber("+447700900000") || validCallNumber("12\r\nBYE") {
|
||||
t.Fatal("call number validation mismatch")
|
||||
|
||||
Reference in New Issue
Block a user