File size: 9,585 Bytes
69fb140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
"""
語音綁定狀態機
處理語音帳號綁定流程(關鍵字匹配,無 GPT)
"""

import logging
import time
from datetime import datetime
from pathlib import Path
from typing import Dict, Any, Optional, List

from fastapi import WebSocket

from core.logging import get_logger

logger = get_logger("services.voice_binding")


def get_available_speaker_labels() -> List[str]:
    """讀取可用的 speaker label 列表"""
    classes_file = Path("models/speaker_identification/models_cnn/classes.txt")
    if classes_file.exists():
        with open(classes_file, "r", encoding="utf-8") as f:
            return [line.strip() for line in f if line.strip()]
    return []


class VoiceBindingStateMachine:
    """
    語音帳號綁定狀態機(硬編碼關鍵字匹配)

    流程:
    1. 用戶說「綁定語音登入」
    2. 系統詢問「你要綁定哪個帳號?」並顯示可用的 label 列表
    3. 用戶輸入帳號名稱(label)
    4. 系統綁定 speaker_label 到用戶帳號
    5. 系統回應「綁定成功囉!」
    """

    # 觸發關鍵字
    TRIGGER_KEYWORDS = [
        "綁定語音登入",
        "語音登入綁定",
        "綁定語音",
        "設定語音登入",
    ]

    # 狀態超時時間(秒)
    STATE_TIMEOUT = 300  # 5 分鐘

    def __init__(self):
        # 用戶狀態:{user_id: {state: str, timestamp: datetime}}
        self.user_states: Dict[str, Dict[str, Any]] = {}

    def check_binding_trigger(self, user_id: str, message: str) -> Optional[str]:
        """
        檢查是否觸發綁定流程

        Returns:
            - "TRIGGER": 觸發綁定流程
            - "AWAITING_LABEL": 等待用戶輸入帳號名稱
            - None: 不是綁定相關訊息
        """
        message_lower = message.lower().replace(" ", "")

        # 檢測觸發關鍵字
        for keyword in self.TRIGGER_KEYWORDS:
            if keyword.replace(" ", "") in message_lower:
                self.user_states[user_id] = {
                    "state": "AWAITING_LABEL",
                    "timestamp": datetime.now()
                }
                return "TRIGGER"

        # 檢查是否在等待輸入帳號名稱狀態
        if user_id in self.user_states:
            state_info = self.user_states[user_id]
            if state_info.get("state") == "AWAITING_LABEL":
                # 檢查是否超時
                elapsed = (datetime.now() - state_info.get("timestamp")).total_seconds()
                if elapsed > self.STATE_TIMEOUT:
                    del self.user_states[user_id]
                    return None
                return "AWAITING_LABEL"

        return None

    async def handle_binding_flow(
        self,
        user_id: str,
        message: str,
        websocket: WebSocket,
        voice_service: Optional[Any] = None,
        get_user_by_id: Optional[Any] = None,
    ) -> bool:
        """
        處理綁定流程

        Args:
            user_id: 用戶 ID
            message: 用戶訊息
            websocket: WebSocket 連線
            voice_service: 語音認證服務(可選)
            get_user_by_id: 取得用戶資料的函數(可選)

        Returns:
            True: 已處理(不要繼續到 Agent)
            False: 未處理(繼續到 Agent)
        """
        state = self.check_binding_trigger(user_id, message)

        if state == "TRIGGER":
            logger.info(f"🎙️ 用戶 {user_id} 觸發語音綁定流程")

            # 檢查使用者是否已經綁定過 speaker_label
            if get_user_by_id:
                try:
                    user_data = await get_user_by_id(user_id)
                    if user_data and user_data.get("speaker_label"):
                        existing_label = user_data.get("speaker_label")
                        logger.info(f"⚠️ 用戶 {user_id} 已綁定 speaker_label: {existing_label}")

                        await websocket.send_json({
                            "type": "bot_message",
                            "message": (
                                f"你已經綁定過語音了!目前的聲紋標籤是:{existing_label}。"
                                "如果需要重新綁定,請聯繫管理員。"
                            ),
                            "timestamp": time.time()
                        })

                        self.clear_state(user_id)
                        return True

                except Exception as e:
                    logger.error(f"❌ 檢查使用者綁定狀態失敗: {e}")
                    await websocket.send_json({
                        "type": "error",
                        "message": "系統錯誤,無法檢查綁定狀態"
                    })
                    return True

            # 未綁定,顯示可用的 label 列表
            available_labels = get_available_speaker_labels()
            labels_str = "、".join(available_labels) if available_labels else "(無可用帳號)"
            
            logger.info(f"✅ 用戶 {user_id} 尚未綁定,詢問要綁定的帳號")

            await websocket.send_json({
                "type": "bot_message",
                "message": f"好的,你要綁定哪個帳號呢?\n可用的帳號有:{labels_str}\n請輸入帳號名稱:",
                "timestamp": time.time()
            })

            return True

        elif state == "AWAITING_LABEL":
            # 用戶輸入了帳號名稱
            label_input = message.strip().lower()
            available_labels = get_available_speaker_labels()
            
            logger.info(f"🎙️ 用戶 {user_id} 輸入帳號名稱: {label_input}")

            # 檢查輸入的 label 是否有效
            if label_input not in [l.lower() for l in available_labels]:
                labels_str = "、".join(available_labels)
                await websocket.send_json({
                    "type": "bot_message",
                    "message": f"找不到這個帳號喔!可用的帳號有:{labels_str}\n請重新輸入:",
                    "timestamp": time.time()
                })
                return True

            # 找到匹配的 label(保持原始大小寫)
            matched_label = next((l for l in available_labels if l.lower() == label_input), label_input)

            # 執行綁定
            try:
                from core.database import get_user_by_speaker_label, set_user_speaker_label

                # 檢查這個 label 是否已被其他用戶綁定
                existing_user = await get_user_by_speaker_label(matched_label)
                if existing_user and existing_user.get("id") != user_id:
                    await websocket.send_json({
                        "type": "bot_message",
                        "message": f"這個帳號({matched_label})已經被其他人綁定了,請選擇其他帳號。",
                        "timestamp": time.time()
                    })
                    return True

                # 綁定到當前用戶
                bind_result = await set_user_speaker_label(user_id, matched_label)

                if bind_result.get("success"):
                    logger.info(f"✅ 用戶 {user_id} 成功綁定 speaker_label: {matched_label}")
                    await websocket.send_json({
                        "type": "bot_message",
                        "message": f"綁定成功囉!🎉 你的語音帳號已設定為:{matched_label}",
                        "timestamp": time.time()
                    })
                    await websocket.send_json({
                        "type": "voice_binding_success",
                        "speaker_label": matched_label
                    })
                else:
                    error_msg = bind_result.get("error", "未知錯誤")
                    await websocket.send_json({
                        "type": "bot_message",
                        "message": f"綁定失敗:{error_msg}",
                        "timestamp": time.time()
                    })

            except Exception as e:
                logger.error(f"❌ 綁定 speaker_label 失敗: {e}")
                await websocket.send_json({
                    "type": "bot_message",
                    "message": "系統錯誤,綁定失敗,請稍後再試。",
                    "timestamp": time.time()
                })

            # 清理狀態,返回待機模式
            self.clear_state(user_id)
            return True

        return False

    def clear_state(self, user_id: str) -> None:
        """清理用戶狀態"""
        self.user_states.pop(user_id, None)

    def is_awaiting_label(self, user_id: str) -> bool:
        """檢查用戶是否在等待輸入帳號名稱狀態"""
        if user_id not in self.user_states:
            return False

        state_info = self.user_states[user_id]
        if state_info.get("state") != "AWAITING_LABEL":
            return False

        # 檢查是否超時
        elapsed = (datetime.now() - state_info.get("timestamp")).total_seconds()
        if elapsed > self.STATE_TIMEOUT:
            del self.user_states[user_id]
            return False

        return True

    # 保留舊方法名稱以保持相容性
    def is_awaiting_voice(self, user_id: str) -> bool:
        """檢查用戶是否在等待語音錄製狀態(已棄用,保留相容性)"""
        return self.is_awaiting_label(user_id)


# 全域單例
voice_binding_fsm = VoiceBindingStateMachine()