feat: enhance Telegram call handling with VoWiFi and cellular support

This commit is contained in:
MengMengCode
2026-08-10 06:07:56 +08:00
parent 4df0ae0c7d
commit 5b8d1a86e8
5 changed files with 749 additions and 46 deletions
+6 -1
View File
@@ -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
View File
@@ -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 提交 %sCall-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) {
+143
View File
@@ -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
}
+17
View File
@@ -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":
+31
View File
@@ -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")