Spaces:
Paused
Paused
| import time | |
| import requests | |
| import os | |
| import json | |
| import sys | |
| import traceback | |
| from datetime import datetime, timedelta | |
| from zoneinfo import ZoneInfo | |
| import websocket | |
| import threading | |
| import logging | |
| import ssl | |
| import base64 | |
| import jwt as pyjwt # PyJWTライブラリをインストール: pip install PyJWT | |
| # ロギングの設定 | |
| logging.basicConfig( | |
| level=logging.DEBUG, | |
| format='%(asctime)s [%(levelname)s] %(name)s: %(message)s', | |
| handlers=[ | |
| logging.FileHandler('bot.log', encoding='utf-8'), | |
| logging.StreamHandler(sys.stdout) | |
| ] | |
| ) | |
| logger = logging.getLogger(__name__) | |
| BASE_URL = "https://desk-api.channel.io/desk/channels/240996" | |
| API_URL = "https://api.channel.works/desk/channels/240996" | |
| TARGET_GROUP_CHAT_ID = "574628" | |
| TARGET_GROUP_CHAT_ID2 = "574317" | |
| WEBSOCKET_URL = "wss://desk-ws.channel.io/socket.io/?platform=web&EIO=4&transport=websocket" | |
| # 環境変数の確認 | |
| DMSENDER_TOKEN = os.getenv("dmsendertoken") | |
| if not DMSENDER_TOKEN: | |
| print("環境変数 'dmsendertoken' が設定されていません") | |
| sys.exit(1) | |
| HEADERS = { | |
| "accept-language": "ja", | |
| "x-account": DMSENDER_TOKEN | |
| } | |
| JST = ZoneInfo("Asia/Tokyo") | |
| # JWT管理クラス | |
| class JWTManager: | |
| """JWTトークンの管理とリフレッシュを行うクラス""" | |
| def __init__(self, initial_token=None): | |
| self.token = initial_token | |
| self.token_expiry = None | |
| self.refresh_endpoint = "https://api.channel.works/desk/account/auth/refresh" | |
| self.account_id = None | |
| self._parse_token(initial_token) | |
| def _parse_token(self, token): | |
| """JWTを解析して有効期限を取得""" | |
| if not token: | |
| return | |
| try: | |
| # JWTのペイロード部分をデコード | |
| parts = token.split('.') | |
| if len(parts) != 3: | |
| return | |
| # ペイロードをデコード | |
| payload = parts[1] | |
| # Base64URLデコード用のパディング追加 | |
| payload += '=' * (4 - len(payload) % 4) | |
| decoded = base64.urlsafe_b64decode(payload) | |
| payload_data = json.loads(decoded) | |
| # 有効期限(exp)を取得 | |
| if 'exp' in payload_data: | |
| self.token_expiry = datetime.fromtimestamp(payload_data['exp'], JST) | |
| # アカウントID(key)を取得 | |
| if 'key' in payload_data: | |
| self.account_id = payload_data['key'] | |
| print(f"JWT解析: 有効期限={self.token_expiry}, アカウントID={self.account_id}") | |
| except Exception as e: | |
| print(f"JWT解析エラー: {e}") | |
| def is_expired(self): | |
| """トークンが期限切れかチェック(5分の余裕を持たせる)""" | |
| if not self.token_expiry: | |
| return True | |
| # 5分前を期限として扱う(余裕を持たせる) | |
| buffer_time = timedelta(minutes=5) | |
| return datetime.now(JST) + buffer_time >= self.token_expiry | |
| def should_refresh(self): | |
| """リフレッシュが必要かチェック(15分以内の期限)""" | |
| if not self.token_expiry: | |
| return True | |
| # 15分以内に期限切れになる場合はリフレッシュ | |
| threshold = timedelta(minutes=15) | |
| return datetime.now(JST) + threshold >= self.token_expiry | |
| def refresh_token(self): | |
| """トークンをリフレッシュ""" | |
| if not self.token: | |
| print("JWT: リフレッシュに失敗(トークンがありません)") | |
| return None | |
| try: | |
| print(f"JWT: トークンリフレッシュを開始 (有効期限: {self.token_expiry})") | |
| headers = { | |
| "x-account": self.token, | |
| "accept-language": "ja" | |
| } | |
| response = requests.post( | |
| self.refresh_endpoint, | |
| headers=headers, | |
| timeout=10 | |
| ) | |
| if response.status_code == 200: | |
| data = response.json() | |
| new_token = data.get('jwt') or data.get('token') | |
| if new_token: | |
| print(f"JWT: トークンリフレッシュ成功 (新しい有効期限を解析中...)") | |
| self.token = new_token | |
| self._parse_token(new_token) | |
| return new_token | |
| else: | |
| print(f"JWT: リフレッシュ応答にトークンがありません: {data}") | |
| return None | |
| else: | |
| print(f"JWT: リフレッシュ失敗: status={response.status_code}, body={response.text[:200]}") | |
| return None | |
| except Exception as e: | |
| print(f"JWT: リフレッシュエラー: {e}") | |
| return None | |
| def get_token(self): | |
| """現在のトークンを返す(必要に応じてリフレッシュ)""" | |
| if self.should_refresh(): | |
| print("JWT: リフレッシュが必要なため、更新を試みます") | |
| self.refresh_token() | |
| return self.token | |
| def get_auth_headers(self): | |
| """認証ヘッダーを返す""" | |
| token = self.get_token() | |
| if token: | |
| return { | |
| "x-account": token, | |
| "accept-language": "ja" | |
| } | |
| return {} | |
| # JWTマネージャーのインスタンスを作成 | |
| jwt_manager = JWTManager(DMSENDER_TOKEN) | |
| # WebSocket接続管理用 | |
| ws_app = None | |
| ws_connected = False | |
| ws_reconnect_count = 0 | |
| MAX_RECONNECT_ATTEMPTS = 10 | |
| ws_auth_attempted = False | |
| # 統計情報 | |
| stats = { | |
| "messages_sent": 0, | |
| "messages_received": 0, | |
| "errors": 0, | |
| "ws_reconnects": 0, | |
| "token_refreshes": 0, | |
| "start_time": datetime.now(JST) | |
| } | |
| # ウェルカムメッセージのテンプレート | |
| WELCOME_MESSAGE = """🎉 ようこそ! | |
| <b>チャンネルにご参加いただきありがとうございます!</b> | |
| このグループは○○のためのコミュニティスペースです。 | |
| ご質問やご相談があれば、いつでもお気軽に投稿してください。 | |
| 🔹 ルール | |
| - 相互尊重を心がけましょう | |
| - 宣伝やスパムはご遠慮ください | |
| それでは、楽しい時間をお過ごしください!😊""" | |
| # ---------------- JWT関連の関数 ---------------- | |
| def refresh_jwt_token(): | |
| """JWTトークンをリフレッシュする(グローバル関数)""" | |
| global HEADERS | |
| print("=== JWTトークンリフレッシュを開始 ===") | |
| new_token = jwt_manager.refresh_token() | |
| if new_token: | |
| HEADERS["x-account"] = new_token | |
| stats["token_refreshes"] += 1 | |
| print(f"JWT: トークン更新完了 (リフレッシュ回数: {stats['token_refreshes']})") | |
| return True | |
| else: | |
| print("JWT: トークン更新に失敗しました") | |
| return False | |
| def get_jwt_for_websocket(): | |
| """WebSocket用のJWTを取得(必要な場合はリフレッシュ)""" | |
| token = jwt_manager.get_token() | |
| if not token: | |
| print("JWT: WebSocket用トークン取得失敗") | |
| return None | |
| print(f"JWT: WebSocket用トークン取得成功 (有効期限: {jwt_manager.token_expiry})") | |
| return token | |
| def check_and_refresh_token(): | |
| """定期的にトークンをチェックし、必要ならリフレッシュ""" | |
| if jwt_manager.should_refresh(): | |
| print("JWT: 定期チェックでリフレッシュが必要と判断") | |
| return refresh_jwt_token() | |
| return True | |
| # ---------------- ヘッダー更新関数 ---------------- | |
| def update_headers_with_new_token(): | |
| """新しいトークンでヘッダーを更新""" | |
| global HEADERS | |
| token = jwt_manager.get_token() | |
| if token: | |
| HEADERS["x-account"] = token | |
| print("ヘッダーを新しいトークンで更新しました") | |
| return True | |
| return False | |
| # ---------------- WebSocket処理 ---------------- | |
| def log_stats(): | |
| """統計情報をログ出力""" | |
| uptime = datetime.now(JST) - stats["start_time"] | |
| print(f"=== 統計情報 ===") | |
| print(f"稼働時間: {uptime}") | |
| print(f"送信メッセージ数: {stats['messages_sent']}") | |
| print(f"受信メッセージ数: {stats['messages_received']}") | |
| print(f"エラー数: {stats['errors']}") | |
| print(f"WebSocket再接続回数: {stats['ws_reconnects']}") | |
| print(f"JWTリフレッシュ回数: {stats['token_refreshes']}") | |
| print(f"JWT有効期限: {jwt_manager.token_expiry}") | |
| def on_message(ws, message): | |
| """WebSocketメッセージ受信時の処理""" | |
| global ws_connected, ws_reconnect_count, ws_auth_attempted | |
| try: | |
| print(f"WebSocket raw message: {message[:200]}") | |
| # Socket.IOプロトコルの処理 | |
| if message == "2": | |
| print("WebSocket: ping受信、pong送信") | |
| ws.send("3") | |
| return | |
| if message == "3": | |
| print("WebSocket: pong受信") | |
| return | |
| if message.startswith("0{"): | |
| # 接続確立時の初期メッセージ - Socket.IO v4 | |
| print(f"WebSocket: 接続確立、初期メッセージ受信") | |
| try: | |
| init_data = json.loads(message[1:]) | |
| ping_interval = init_data.get("pingInterval", 60000) | |
| ping_timeout = init_data.get("pingTimeout", 25000) | |
| print(f"WebSocket: ping間隔={ping_interval}ms, ping timeout={ping_timeout}ms") | |
| # JWTを取得(リフレッシュが必要な場合は自動更新) | |
| jwt_token = get_jwt_for_websocket() | |
| if not jwt_token: | |
| print("WebSocket: JWT tokenが取得できませんでした") | |
| return | |
| auth_message = f'40/desk/channel,{{"channelId":"240996","jwt":"{jwt_token}"}}' | |
| print(f"WebSocket: 認証メッセージ送信: {auth_message[:100]}...") | |
| ws.send(auth_message) | |
| ws_auth_attempted = True | |
| except json.JSONDecodeError as e: | |
| print(f"WebSocket: 初期メッセージ解析エラー: {e}") | |
| stats["errors"] += 1 | |
| except Exception as e: | |
| print(f"WebSocket: 認証情報送信エラー: {e}") | |
| stats["errors"] += 1 | |
| return | |
| if message.startswith("40"): | |
| print(f"WebSocket: 認証応答受信: {message[:100]}") | |
| ws_connected = True | |
| ws_reconnect_count = 0 | |
| ws_auth_attempted = True | |
| print("WebSocket: 認証成功、接続確立") | |
| # 認証成功後にトークンの有効期限をチェック | |
| if jwt_manager.should_refresh(): | |
| print("WebSocket: 認証成功後、トークンリフレッシュをスケジュール") | |
| return | |
| if message.startswith("42"): | |
| try: | |
| if "," in message: | |
| json_start = message.find(",") + 1 | |
| json_str = message[json_start:] | |
| print(f"WebSocket: Extracted JSON: {json_str[:200]}") | |
| data = json.loads(json_str) | |
| if isinstance(data, list) and len(data) >= 2: | |
| event_type = data[0] | |
| event_data = data[1] | |
| print(f"WebSocket: イベント受信 type={event_type}") | |
| if event_type == "push": | |
| handle_push_event(event_data) | |
| elif event_type == "create": | |
| handle_create_event(event_data) | |
| elif event_type == "update": | |
| handle_update_event(event_data) | |
| elif event_type == "delete": | |
| handle_delete_event(event_data) | |
| elif event_type == "joined": | |
| print(f"WebSocket: 参加確認: {event_data}") | |
| elif event_type == "ready": | |
| print(f"WebSocket: 準備完了: {event_data}") | |
| elif event_type == "refresh": | |
| # サーバーからのリフレッシュ要求 | |
| print(f"WebSocket: サーバーからトークンリフレッシュ要求を受信") | |
| handle_token_refresh_request(ws, event_data) | |
| else: | |
| print(f"WebSocket: 未処理のイベントタイプ: {event_type}") | |
| else: | |
| print(f"WebSocket: 予期しないデータ形式: {json_str[:200]}") | |
| except json.JSONDecodeError as e: | |
| print(f"WebSocket: JSON解析エラー: {e}, データ: {message[:200]}") | |
| stats["errors"] += 1 | |
| except Exception as e: | |
| print(f"WebSocket: メッセージ処理エラー: {e}") | |
| print(traceback.format_exc()) | |
| stats["errors"] += 1 | |
| return | |
| if message.startswith("44"): | |
| print(f"WebSocket: エラーメッセージ受信: {message}") | |
| # 認証エラー(トークン期限切れなど)の処理 | |
| if "auth" in message.lower() or "token" in message.lower(): | |
| print("WebSocket: 認証エラーが発生しました。トークンをリフレッシュします。") | |
| handle_auth_error(ws) | |
| return | |
| print(f"WebSocket: その他のメッセージ: {message[:100]}") | |
| except Exception as e: | |
| print(f"WebSocket on_message エラー: {e}") | |
| print(traceback.format_exc()) | |
| stats["errors"] += 1 | |
| def handle_token_refresh_request(ws, event_data): | |
| """サーバーからのトークンリフレッシュ要求を処理""" | |
| try: | |
| print("JWT: サーバーからリフレッシュ要求を受信") | |
| # トークンをリフレッシュ | |
| if refresh_jwt_token(): | |
| # 新しいトークンで再認証 | |
| new_token = jwt_manager.get_token() | |
| if new_token and ws: | |
| auth_message = f'40/desk/channel,{{"channelId":"240996","jwt":"{new_token}"}}' | |
| print(f"WebSocket: 再認証メッセージ送信") | |
| ws.send(auth_message) | |
| print("JWT: 再認証メッセージ送信完了") | |
| else: | |
| print("JWT: リフレッシュに失敗しました") | |
| except Exception as e: | |
| print(f"JWT: リフレッシュ要求処理エラー: {e}") | |
| stats["errors"] += 1 | |
| def handle_auth_error(ws): | |
| """認証エラー時の処理""" | |
| try: | |
| print("=== 認証エラー処理 ===") | |
| # トークンをリフレッシュ | |
| if refresh_jwt_token(): | |
| new_token = jwt_manager.get_token() | |
| if new_token and ws: | |
| # 再接続を試みる | |
| print("WebSocket: 新しいトークンで再接続を試みます") | |
| ws.close() | |
| # 新しいスレッドで再接続 | |
| threading.Thread(target=start_websocket, daemon=True).start() | |
| else: | |
| print("認証エラー: トークンリフレッシュに失敗しました") | |
| except Exception as e: | |
| print(f"認証エラー処理中にエラー: {e}") | |
| stats["errors"] += 1 | |
| def handle_push_event(event_data): | |
| """pushイベントの処理""" | |
| try: | |
| entity = event_data.get("entity", {}) | |
| chat_id = entity.get("chatId") | |
| chat_type = entity.get("chatType") | |
| person_type = entity.get("personType") | |
| person_id = entity.get("personId") | |
| message_text = entity.get("plainText", "") | |
| message_id = entity.get("id", "unknown") | |
| refers = event_data.get("refers", {}) | |
| manager_info = refers.get("manager", {}) | |
| manager_name = manager_info.get("name", "不明") | |
| print(f"メッセージ受信: id={message_id}, chatType={chat_type}, chatId={chat_id}, " | |
| f"personType={person_type}, personId={person_id}, from={manager_name}") | |
| stats["messages_received"] += 1 | |
| if chat_type == "group" and chat_id == TARGET_GROUP_CHAT_ID2: | |
| print(f"対象グループのメッセージ: '{message_text[:100]}' from {manager_name}") | |
| else: | |
| print(f"対象外のチャットからのメッセージ: chatType={chat_type}, chatId={chat_id}") | |
| except Exception as e: | |
| print(f"push イベント処理エラー: {e}") | |
| print(traceback.format_exc()) | |
| stats["errors"] += 1 | |
| def handle_create_event(event_data): | |
| """createイベントの処理(新規ユーザー検出時にウェルカムメッセージ送信)""" | |
| try: | |
| event = event_data.get("event") | |
| event_type = event_data.get("type") | |
| entity = event_data.get("entity", {}) | |
| print(f"createイベント: event={event}, type={event_type}") | |
| # 新規マネージャー(ユーザー)が参加した場合 | |
| if event_type == "manager": | |
| manager_id = entity.get("managerId") or entity.get("id") | |
| name = entity.get("name") | |
| email = entity.get("email") | |
| print("=== 新しいマネージャーが参加 ===") | |
| print(f"ID : {manager_id}") | |
| print(f"名前 : {name}") | |
| print(f"メール : {email}") | |
| # ウェルカムメッセージを送信 | |
| if name: | |
| try: | |
| welcome_body = create_welcome_message(name) | |
| post_group_message(TARGET_GROUP_CHAT_ID, welcome_body) | |
| print(f"ウェルカムメッセージ送信完了: {name} (ID: {manager_id})") | |
| except Exception as e: | |
| print(f"ウェルカムメッセージ送信エラー: {e}") | |
| stats["errors"] += 1 | |
| # ユーザーチャット作成(新規ユーザーからのメッセージ) | |
| elif event_type == "userChat": | |
| chat_id = entity.get("id") | |
| user_id = entity.get("userId") | |
| person_type = entity.get("personType", "user") | |
| message = entity.get("message", {}) | |
| print("=== 新規ユーザーチャット ===") | |
| print(f"チャットID: {chat_id}") | |
| print(f"ユーザーID: {user_id}") | |
| print(f"メッセージ: {message.get('plainText', '')[:100]}") | |
| # ウェルカムメッセージを送信(ユーザーチャット作成時も) | |
| try: | |
| welcome_body = create_welcome_message("新規ユーザー") | |
| post_group_message(TARGET_GROUP_CHAT_ID, welcome_body) | |
| print(f"ウェルカムメッセージ送信完了: ユーザー {user_id}") | |
| except Exception as e: | |
| print(f"ウェルカムメッセージ送信エラー: {e}") | |
| stats["errors"] += 1 | |
| else: | |
| print(f"未処理のcreateイベント: {event_type}") | |
| except Exception as e: | |
| print(f"createイベント処理エラー: {e}") | |
| print(traceback.format_exc()) | |
| stats["errors"] += 1 | |
| def handle_update_event(event_data): | |
| """updateイベントの処理(チャットセッションの更新など)""" | |
| try: | |
| event = event_data.get("event") | |
| entity = event_data.get("entity", {}) | |
| event_type = event_data.get("type", "unknown") | |
| print(f"update イベント: event={event}, type={event_type}") | |
| if event_type == "chatSession": | |
| chat_id = entity.get("chatId") | |
| chat_key = entity.get("chatKey") | |
| unread = entity.get("unread", 0) | |
| alert = entity.get("alert", 0) | |
| updated_at = entity.get("updatedAt") | |
| print(f" チャットセッション更新: chatId={chat_id}, chatKey={chat_key}, " | |
| f"unread={unread}, alert={alert}") | |
| if chat_id == TARGET_GROUP_CHAT_ID: | |
| print(f" 対象グループのセッションが更新されました (unread={unread})") | |
| elif event_type == "managerBadge": | |
| manager_id = entity.get("managerId") | |
| team_chat_unread = entity.get("teamChatUnread", 0) | |
| user_chat_unread = entity.get("userChatUnread", 0) | |
| print(f" マネージャーバッジ更新: managerId={manager_id}, " | |
| f"teamChatUnread={team_chat_unread}, userChatUnread={user_chat_unread}") | |
| elif event_type == "group": | |
| group_id = entity.get("id") | |
| group_name = entity.get("name", "unknown") | |
| print(f" グループ更新: id={group_id}, name={group_name}") | |
| elif event_type == "manager": | |
| manager_id = entity.get("id") | |
| manager_name = entity.get("name", "unknown") | |
| print(f" マネージャー更新: id={manager_id}, name={manager_name}") | |
| else: | |
| print(f" その他のupdateイベント: type={event_type}, event={event}") | |
| except Exception as e: | |
| print(f"update イベント処理エラー: {e}") | |
| print(traceback.format_exc()) | |
| stats["errors"] += 1 | |
| def handle_delete_event(event_data): | |
| """deleteイベントの処理(オンライン状態の削除など)""" | |
| try: | |
| event = event_data.get("event") | |
| entity = event_data.get("entity", {}) | |
| event_type = event_data.get("type", "unknown") | |
| print(f"delete イベント: event={event}, type={event_type}") | |
| if event_type == "online": | |
| entity_id = entity.get("id") | |
| person_id = entity.get("personId") | |
| person_type = entity.get("personType") | |
| channel_id = entity.get("channelId") | |
| print(f" オンライン状態削除: id={entity_id}, personId={person_id}, " | |
| f"personType={person_type}, channelId={channel_id}") | |
| if person_type == "manager": | |
| print(f" マネージャーがオフラインになりました: {person_id}") | |
| elif event_type == "manager": | |
| manager_id = entity.get("id") | |
| manager_name = entity.get("name", "unknown") | |
| removed = entity.get("removed", False) | |
| if removed: | |
| print(f" マネージャー削除: id={manager_id}, name={manager_name}") | |
| else: | |
| print(f" マネージャー削除イベント: id={manager_id}, name={manager_name}") | |
| elif event_type == "group": | |
| group_id = entity.get("id") | |
| group_name = entity.get("name", "unknown") | |
| print(f" グループ削除: id={group_id}, name={group_name}") | |
| else: | |
| print(f" その他のdeleteイベント: type={event_type}, event={event}") | |
| except Exception as e: | |
| print(f"delete イベント処理エラー: {e}") | |
| print(traceback.format_exc()) | |
| stats["errors"] += 1 | |
| def on_error(ws, error): | |
| """WebSocketエラー時の処理""" | |
| print(f"WebSocket エラー: {error}") | |
| print(f"エラータイプ: {type(error)}") | |
| if hasattr(error, '__class__'): | |
| print(f"エラークラス: {error.__class__.__name__}") | |
| stats["errors"] += 1 | |
| error_str = str(error) | |
| if "Connection to remote host was lost" in error_str: | |
| print("WebSocket: リモートホストとの接続が切断されました(再接続されます)") | |
| elif "Connection refused" in error_str: | |
| print("WebSocket: 接続が拒否されました") | |
| elif "handshake" in error_str.lower(): | |
| print("WebSocket: ハンドシェイクエラー") | |
| elif "auth" in error_str.lower() or "token" in error_str.lower(): | |
| print("WebSocket: 認証エラーが発生しました。トークンをリフレッシュします。") | |
| # 認証エラーの場合はトークンをリフレッシュ | |
| refresh_jwt_token() | |
| else: | |
| print(f"WebSocket: 未分類のエラー: {error_str}") | |
| def on_close(ws, close_status_code, close_msg): | |
| """WebSocket接続クローズ時の処理""" | |
| global ws_connected, ws_reconnect_count | |
| ws_connected = False | |
| ws_reconnect_count += 1 | |
| stats["ws_reconnects"] += 1 | |
| print(f"WebSocket 接続が閉じられました: status_code={close_status_code}, " | |
| f"message={close_msg}, reconnect_count={ws_reconnect_count}/{MAX_RECONNECT_ATTEMPTS}") | |
| if close_status_code: | |
| print(f"クローズステータスコード: {close_status_code}") | |
| if close_msg: | |
| print(f"クローズメッセージ: {close_msg}") | |
| # 認証エラーが原因でクローズした場合 | |
| if close_msg and ("auth" in close_msg.lower() or "token" in close_msg.lower()): | |
| print("WebSocket: 認証エラーによりクローズしました。トークンをリフレッシュします。") | |
| refresh_jwt_token() | |
| if ws_reconnect_count > MAX_RECONNECT_ATTEMPTS: | |
| print(f"WebSocket: 最大再接続回数({MAX_RECONNECT_ATTEMPTS})を超えました") | |
| def on_open(ws): | |
| """WebSocket接続開始時の処理""" | |
| global ws_reconnect_count, ws_auth_attempted | |
| print("WebSocket 接続が開かれました") | |
| print(f"接続URL: {WEBSOCKET_URL}") | |
| ws_reconnect_count = 0 | |
| ws_auth_attempted = False | |
| # 接続時にトークンの状態を確認 | |
| if jwt_manager.should_refresh(): | |
| print("WebSocket: 接続時にトークンリフレッシュが必要です") | |
| refresh_jwt_token() | |
| def on_ping(ws, message): | |
| """WebSocket ping受信時の処理""" | |
| print(f"WebSocket ping受信: {message}") | |
| def on_pong(ws, message): | |
| """WebSocket pong受信時の処理""" | |
| print(f"WebSocket pong受信: {message}") | |
| def start_websocket(): | |
| """WebSocket接続を開始""" | |
| global ws_app | |
| print(f"WebSocket接続を開始します: {WEBSOCKET_URL}") | |
| try: | |
| websocket.enableTrace(True) | |
| ssl_opt = { | |
| "cert_reqs": ssl.CERT_NONE, | |
| } | |
| ws_app = websocket.WebSocketApp( | |
| WEBSOCKET_URL, | |
| on_open=on_open, | |
| on_message=on_message, | |
| on_error=on_error, | |
| on_close=on_close, | |
| on_ping=on_ping, | |
| on_pong=on_pong | |
| ) | |
| ws_app.header = { | |
| "Origin": "https://desk.channel.io", | |
| "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" | |
| } | |
| print("WebSocket: run_foreverを開始します") | |
| ws_app.run_forever( | |
| ping_interval=30, | |
| ping_timeout=10, | |
| ping_payload="2", | |
| sslopt=ssl_opt, | |
| reconnect=5 | |
| ) | |
| except Exception as e: | |
| print(f"WebSocket接続の開始に失敗: {e}") | |
| print(traceback.format_exc()) | |
| stats["errors"] += 1 | |
| time.sleep(10) | |
| start_websocket() | |
| # ---------------- メッセージ送信 ---------------- | |
| def post_group_message(chat_id, body, retry_count=3): | |
| """WebSocket APIを使用してグループメッセージを送信(リトライ機能付き)""" | |
| url = f"{API_URL}/groups/{chat_id}/messages" | |
| # 最新のトークンでヘッダーを更新 | |
| update_headers_with_new_token() | |
| for attempt in range(retry_count): | |
| try: | |
| print(f"メッセージ送信試行: chat_id={chat_id}, attempt={attempt + 1}/{retry_count}") | |
| print(f"送信URL: {url}") | |
| print(f"送信データ: {json.dumps(body, ensure_ascii=False)[:200]}") | |
| response = requests.post(url, headers=HEADERS, json=body, timeout=10) | |
| print(f"レスポンスステータス: {response.status_code}") | |
| print(f"レスポンスボディ: {response.text[:200]}") | |
| if response.status_code == 200: | |
| response_data = response.json() | |
| print(f"メッセージ送信成功: chat_id={chat_id}") | |
| stats["messages_sent"] += 1 | |
| return response_data | |
| elif response.status_code == 401: | |
| print(f"認証エラー: status={response.status_code}") | |
| # トークンをリフレッシュして再試行 | |
| if refresh_jwt_token(): | |
| update_headers_with_new_token() | |
| if attempt < retry_count - 1: | |
| print("トークンをリフレッシュしました。再試行します。") | |
| continue | |
| raise Exception("認証エラー: APIキーが無効です") | |
| elif response.status_code == 429: | |
| wait_time = (attempt + 1) * 5 | |
| print(f"レート制限: {wait_time}秒待機") | |
| if attempt < retry_count - 1: | |
| time.sleep(wait_time) | |
| else: | |
| raise Exception("レート制限により送信失敗") | |
| else: | |
| print(f"送信エラー: status={response.status_code}, body={response.text[:200]}") | |
| if attempt < retry_count - 1: | |
| time.sleep(2) | |
| else: | |
| response.raise_for_status() | |
| except requests.exceptions.Timeout: | |
| print(f"リクエストタイムアウト: attempt={attempt + 1}") | |
| if attempt < retry_count - 1: | |
| time.sleep(2) | |
| except requests.exceptions.ConnectionError as e: | |
| print(f"接続エラー: {e}, attempt={attempt + 1}") | |
| if attempt < retry_count - 1: | |
| time.sleep(5) | |
| except requests.exceptions.RequestException as e: | |
| print(f"リクエストエラー: {e}, attempt={attempt + 1}") | |
| if attempt < retry_count - 1: | |
| time.sleep(2) | |
| except Exception as e: | |
| print(f"予期しない送信エラー: {e}") | |
| print(traceback.format_exc()) | |
| stats["errors"] += 1 | |
| raise | |
| print("メッセージ送信に失敗しました(全リトライ終了)") | |
| stats["errors"] += 1 | |
| raise Exception("メッセージ送信に失敗しました") | |
| # ---------------- ウェルカムメッセージ ---------------- | |
| def create_welcome_message(user_name=None): | |
| """ウェルカムメッセージを作成""" | |
| try: | |
| now = datetime.now(JST) | |
| if user_name: | |
| welcome_text = f"{user_name}さん、ようこそ!🎉\n\n{WELCOME_MESSAGE}" | |
| else: | |
| welcome_text = WELCOME_MESSAGE | |
| message = f"{welcome_text}\n\n({now.strftime('%Y年%m月%d日 %H:%M')}に参加)" | |
| print(f"ウェルカムメッセージ作成: {user_name if user_name else '新規ユーザー'}") | |
| return { | |
| "requestId": f"desk-web-welcome-{int(time.time())}", | |
| "blocks": [{"type": "text", "value": message}], | |
| } | |
| except Exception as e: | |
| print(f"ウェルカムメッセージ作成エラー: {e}") | |
| raise | |
| # ---------------- 時報 ---------------- | |
| def create_time_signal_body(): | |
| """時報メッセージを作成""" | |
| try: | |
| now = datetime.now(JST) | |
| hour = now.hour | |
| if hour < 12: | |
| period = "午前" | |
| display_hour = hour if hour != 0 else 12 | |
| else: | |
| period = "午後" | |
| display_hour = hour - 12 if hour > 12 else 12 | |
| message = f"<b>時報</b>\n{period}{display_hour}時をお知らせします。" | |
| print(f"時報メッセージ作成: {message}") | |
| return { | |
| "requestId": f"desk-web-time-signal-{int(time.time())}", | |
| "blocks": [{"type": "text", "value": message}], | |
| } | |
| except Exception as e: | |
| print(f"時報メッセージ作成エラー: {e}") | |
| raise | |
| # ---------------- メインループ ---------------- | |
| def main_loop(): | |
| """メイン処理ループ""" | |
| print("メインループを開始します") | |
| last_time_signal = None | |
| check_interval = 30 | |
| loop_count = 0 | |
| token_check_counter = 0 | |
| while True: | |
| try: | |
| loop_count += 1 | |
| token_check_counter += 1 | |
| now = datetime.now(JST) | |
| current_date = now.date() | |
| current_hour = now.hour | |
| current_minute = now.minute | |
| if loop_count % 20 == 0: | |
| print(f"ループ実行中: カウント={loop_count}, 時刻={now.strftime('%Y-%m-%d %H:%M:%S')}, " | |
| f"接続状態={ws_connected}") | |
| # 5分ごとにトークンの状態をチェック | |
| if token_check_counter >= 10: # 30秒 * 10 = 5分 | |
| token_check_counter = 0 | |
| print(f"JWT: 定期チェック実行 (有効期限: {jwt_manager.token_expiry})") | |
| if jwt_manager.should_refresh(): | |
| print("JWT: 定期チェックでリフレッシュが必要です") | |
| refresh_jwt_token() | |
| # WebSocketが接続中なら再認証 | |
| if ws_connected and ws_app: | |
| try: | |
| new_token = jwt_manager.get_token() | |
| if new_token: | |
| auth_message = f'40/desk/channel,{{"channelId":"240996","jwt":"{new_token}"}}' | |
| ws_app.send(auth_message) | |
| print("JWT: WebSocketに新しいトークンを送信しました") | |
| except Exception as e: | |
| print(f"JWT: WebSocketへのトークン送信エラー: {e}") | |
| # 毎正時の時報 | |
| if current_minute == 0 and last_time_signal != current_hour: | |
| try: | |
| print(f"時報送信: {current_hour}時") | |
| body = create_time_signal_body() | |
| post_group_message(TARGET_GROUP_CHAT_ID, body) | |
| last_time_signal = current_hour | |
| print(f"時報送信完了: {now.strftime('%H:%M')}") | |
| except Exception as e: | |
| print(f"時報送信エラー: {e}") | |
| stats["errors"] += 1 | |
| # 1時間ごとに統計情報をログ出力 | |
| if current_minute == 0 and current_hour != last_time_signal: | |
| log_stats() | |
| # WebSocket接続状態の監視 | |
| if not ws_connected and loop_count % 10 == 0: | |
| print(f"WebSocketが未接続状態です(再接続試行中: {ws_reconnect_count})") | |
| # 再接続を試みる | |
| if ws_reconnect_count < MAX_RECONNECT_ATTEMPTS: | |
| print("WebSocket: 再接続を試みます") | |
| threading.Thread(target=start_websocket, daemon=True).start() | |
| time.sleep(check_interval) | |
| except KeyboardInterrupt: | |
| print("プログラムを終了します(Ctrl+C)") | |
| log_stats() | |
| break | |
| except Exception as e: | |
| print(f"メインループエラー: {e}") | |
| print(traceback.format_exc()) | |
| stats["errors"] += 1 | |
| time.sleep(10) | |
| if __name__ == "__main__": | |
| try: | |
| print("=== ボット起動 ===") | |
| print(f"開始時刻: {datetime.now(JST).strftime('%Y-%m-%d %H:%M:%S')}") | |
| print(f"ターゲットグループID: {TARGET_GROUP_CHAT_ID}") | |
| print(f"API URL: {API_URL}") | |
| print(f"WebSocket URL: {WEBSOCKET_URL}") | |
| print(f"環境変数 dmsendertoken: {'設定済み' if DMSENDER_TOKEN else '未設定'} ({len(DMSENDER_TOKEN)}文字)") | |
| print(f"JWT有効期限: {jwt_manager.token_expiry}") | |
| print(f"JWTアカウントID: {jwt_manager.account_id}") | |
| # 初期トークンチェック | |
| if jwt_manager.should_refresh(): | |
| print("JWT: 初期トークンが期限切れ間近のためリフレッシュします") | |
| refresh_jwt_token() | |
| # WebSocket接続を別スレッドで開始 | |
| ws_thread = threading.Thread(target=start_websocket, daemon=True, name="WebSocketThread") | |
| ws_thread.start() | |
| print(f"WebSocketスレッドを開始しました: {ws_thread.name}") | |
| # WebSocketの接続確立を待機 | |
| time.sleep(2) | |
| # メインループを実行 | |
| main_loop() | |
| except KeyboardInterrupt: | |
| print("プログラムを終了します") | |
| except Exception as e: | |
| print(f"致命的なエラーが発生しました: {e}") | |
| print(traceback.format_exc()) | |
| sys.exit(1) | |
| finally: | |
| print("=== ボット終了 ===") | |
| log_stats() |