#!/usr/bin/env python3 """ Base utilities for daily-oncall-metrics Common methods for Feishu, Bitable, and notifications """ import os import requests import json import re # ======== Load Environment from bashrc ======== def load_env_from_bashrc(): """Load environment variables from ~/.bashrc""" bashrc = os.path.expanduser("~/.bashrc") if os.path.exists(bashrc): with open(bashrc) as f: for line in f: line = line.strip() if line.startswith("export ") and "=" in line: rest = line.replace("export ", "", 1) if "=" in rest: key = rest.split("=")[0].strip() value = rest.split("=", 1)[1].strip().strip('"').strip("'") value = os.path.expanduser(os.path.expandvars(value)) os.environ[key] = value # Load env at module import load_env_from_bashrc() # ======== Configuration ======== # Feishu App Credentials (from environment) APP_ID = os.environ.get("CRONJOB_TEAM_WORKFLOW_APP_ID") APP_SECRET = os.environ.get("CRONJOB_TEAM_WORKFLOW_APP_SECRET") # Bitable configs (from environment) ONCALL_RECORDS_APP_TOKEN = os.environ.get("ONCALL_RECORDS_APP_TOKEN") ONCALL_RECORDS_TABLE_ID = os.environ.get("ONCALL_RECORDS_TABLE_ID") FAILED_JOBS_APP_TOKEN = os.environ.get("FAILED_JOBS_APP_TOKEN") FAILED_JOBS_TABLE_ID = os.environ.get("FAILED_JOBS_TABLE_ID") # Feishu Chat ID (from environment) ONCALL_METRICS_CHAT_ID = os.environ.get("ONCALL_METRICS_CHAT_ID") # Grafana Token (from environment) GRAFANA_TOKEN = os.environ.get("DATASERVICE_GRAFANA_TOKEN") # ======== Feishu Token ======== def get_feishu_token(): """Get Feishu tenant access token using app credentials""" if not APP_ID or not APP_SECRET: print("Error: CRONJOB_TEAM_WORKFLOW_APP_ID or CRONJOB_TEAM_WORKFLOW_APP_SECRET not set") return None try: resp = requests.post( "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", headers={"Content-Type": "application/json"}, json={"app_id": APP_ID, "app_secret": APP_SECRET}, timeout=10, ) data = resp.json() if data.get("code") == 0: return data.get("tenant_access_token") print(f"Error getting token: {data.get('msg')}") return None except Exception as e: print(f"Error getting Feishu token: {e}") return None # ======== Bitable Operations ======== def get_bitable_records(app_token: str, table_id: str, field_names: list = None) -> dict: """Fetch records from Bitable""" token = get_feishu_token() if not token: return {} url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records" headers = {"Authorization": f"Bearer {token}"} records = {} page_token = None while True: params = {"page_size": 100} if page_token: params["page_token"] = page_token if field_names: params["field_names"] = str(field_names).replace("'", '"') try: resp = requests.get(url, headers=headers, params=params, timeout=30) data = resp.json() if not data: print("Warning: Empty response from API") break if data.get("code") != 0: print(f"Warning: API returned code {data.get('code')}: {data.get('msg')}") break for record in data.get("data", {}).get("items", []): fields = record.get("fields", {}) link_field = fields.get("Link") or {} key = fields.get("Job ID") or (link_field.get("text") if isinstance(link_field, dict) else "") or "" if key: records[f"{key}|"] = record.get("record_id") page_token = data.get("data", {}).get("page_token") if not page_token: break except Exception as e: import traceback print(f"Error fetching Bitable records: {e}") traceback.print_exc() break return records def add_to_bitable(app_token: str, table_id: str, fields: dict) -> bool: """Add a record to Bitable""" token = get_feishu_token() if not token: return False url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records" headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} try: resp = requests.post(url, headers=headers, json={"fields": fields}, timeout=30) data = resp.json() if data.get("code") == 0: return True print(f"Error adding to Bitable: {data.get('msg', 'Unknown error')}") return False except Exception as e: print(f"Error adding to Bitable: {e}") return False def update_bitable_record(app_token: str, table_id: str, record_id: str, fields: dict) -> bool: """Update a record in Bitable""" token = get_feishu_token() if not token: return False url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}" headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} try: resp = requests.put(url, headers=headers, json={"fields": fields}, timeout=30) return resp.status_code == 200 except Exception as e: print(f"Error updating Bitable: {e}") return False DEFAULT_CHAT_ID = None def get_or_create_direct_chat(user_open_id: str) -> str: """Get or create a direct chat with user""" global DEFAULT_CHAT_ID if DEFAULT_CHAT_ID: return DEFAULT_CHAT_ID token = get_feishu_token() if not token: return None url = "https://open.feishu.cn/open-apis/im/v1/chats" headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} try: resp = requests.post(url, headers=headers, json={ "user_id": user_open_id, "chat_type": "direct", }) data = resp.json() if data.get("code") == 0: DEFAULT_CHAT_ID = data["data"]["chat_id"] return DEFAULT_CHAT_ID except Exception as e: print(f"Error creating chat: {e}") return None # ======== Feishu Notifications ======== def _parse_feishu_target(target: str) -> tuple[str, str]: """Parse OpenClaw-style Feishu targets into API receive_id_type + receive_id.""" value = str(target or "").strip() if not value: raise ValueError("Empty Feishu target") if value.startswith("user:"): return "open_id", value.split(":", 1)[1] if value.startswith("chat:"): return "chat_id", value.split(":", 1)[1] if re.match(r"^ou_[A-Za-z0-9]+$", value): return "open_id", value return "chat_id", value def _send_feishu_payload(target: str, msg_type: str, content: dict) -> bool: """Send a Feishu payload to a target.""" token = get_feishu_token() if not token: print("No token, cannot send message") return False receive_id_type, receive_id = _parse_feishu_target(target) url = f"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type={receive_id_type}" headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} payload = { "receive_id": receive_id, "msg_type": msg_type, "content": json.dumps(content, ensure_ascii=False), } try: resp = requests.post(url, headers=headers, json=payload, timeout=30) data = resp.json() if resp.status_code == 200 and data.get("code") == 0: print(f"Message sent to {target}") return True print(f"Failed to send message: {resp.text}") return False except Exception as e: print(f"Error sending Feishu message: {e}") return False def send_feishu_message(target: str, message: str) -> bool: """Send a text message to Feishu target.""" return _send_feishu_payload(target, "text", {"text": message}) def send_feishu_card(target: str, card: dict) -> bool: """Send an interactive card to Feishu target.""" return _send_feishu_payload(target, "interactive", card) def get_bitable_record(app_token: str, table_id: str, record_id: str) -> dict: """Fetch a single record from Bitable by ID""" token = get_feishu_token() if not token: return {} url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}" headers = {"Authorization": f"Bearer {token}"} try: resp = requests.get(url, headers=headers, timeout=30) data = resp.json() if data.get("code") == 0: return data.get("data", {}).get("fields", {}) print(f"Warning: Failed to get record: {data.get('msg')}") return {} except Exception as e: print(f"Error getting record: {e}") return {}