From 7545a4b42e3bf95dec1a037afe84cdf12e50be8d Mon Sep 17 00:00:00 2001 From: nctllnty Date: Mon, 3 Nov 2025 16:05:08 +0800 Subject: [PATCH 1/4] add feishu servicedesk provider --- .../feishu-servicedesk-provider-en.mdx | 522 +++++ .../public/icons/feishu_servicedesk-icon.png | Bin 0 -> 13812 bytes .../feishu_servicedesk_provider/__init__.py | 7 + .../feishu_servicedesk_provider.py | 1750 +++++++++++++++++ 4 files changed, 2279 insertions(+) create mode 100644 docs/providers/documentation/feishu-servicedesk-provider-en.mdx create mode 100644 keep-ui/public/icons/feishu_servicedesk-icon.png create mode 100644 keep/providers/feishu_servicedesk_provider/__init__.py create mode 100644 keep/providers/feishu_servicedesk_provider/feishu_servicedesk_provider.py diff --git a/docs/providers/documentation/feishu-servicedesk-provider-en.mdx b/docs/providers/documentation/feishu-servicedesk-provider-en.mdx new file mode 100644 index 0000000000..e6b223f2bf --- /dev/null +++ b/docs/providers/documentation/feishu-servicedesk-provider-en.mdx @@ -0,0 +1,522 @@ +--- +title: "Feishu Service Desk (飞书服务台)" +sidebarTitle: "Feishu Service Desk" +description: "The Feishu Service Desk provider enables automatic creation and management of service desk tickets from Keep, with email conversion, auto-enrichment, and rich text cards" +--- + +## Overview + +Feishu Service Desk is an enterprise-level service support tool provided by ByteDance's Feishu platform. With Keep's Feishu Service Desk provider, you can: + +- ✅ **Automatically Create Service Desk Tickets** - Create tickets automatically from alerts or incidents +- ✅ **Email Auto-Conversion** - Use email addresses, automatically convert to Feishu User IDs +- ✅ **Intelligent Enrichment** - Automatically add complete event details, Keep links, time information, etc. +- ✅ **Rich Text Cards** - Send formatted message cards to ticket conversations +- ✅ **Agent Assignment** - Automatically assign tickets to specified agents +- ✅ **Update Ticket Status** - Support ticket status synchronization and updates +- ✅ **Custom Fields** - Support service desk custom fields +- ✅ **Incident Support** - Support both Alert and Incident triggers + +## ✨ Core Features + +### 🎯 Minimal Configuration + +Create tickets with just **3 parameters**, everything else is handled automatically: + +```yaml +with: + title: "{{ alert.name }}" + user_email: "user@example.com" + agent_email: "agent@example.com" +``` + +The Provider automatically: +- Converts emails to Feishu User IDs +- Enriches complete event details (time, status, source, environment, etc.) +- Generates Keep platform links (direct jump to event details) +- Includes original monitoring system links +- Sends rich text cards to ticket conversations + +### 📊 Auto-Enrichment + +Ticket content automatically includes: +- Event name and severity +- Current status (formatted in readable form) +- Time information (last received, first triggered, trigger count) +- Source and environment information +- Keep platform event details page link (clickable) +- Original monitoring system links (e.g., Prometheus, Grafana) +- Associated Incident links +- Event assignee information + +### 🎨 Rich Text Cards + +Formatted rich text cards are automatically sent to ticket conversations, including: +- Structured event information +- Clickable hyperlinks +- Icon and style optimization + +## Authentication + +To use the Feishu Service Desk provider, you need to create a Feishu app and obtain the appropriate credentials. + +### Create a Feishu App + +1. Visit [Feishu Open Platform](https://open.feishu.cn/app) +2. Click "Create Enterprise Self-built App" +3. Fill in the app information and create +4. Get the following from the "Credentials & Basic Info" page: + - **App ID** + - **App Secret** + +### Configure Permissions + +In the Feishu Open Platform app management page, go to "Permission Management" and add the following permissions: + +**Required Permissions**: +- `helpdesk:ticket` - Read ticket information +- `helpdesk:ticket:create` - Create tickets +- `helpdesk:ticket:update` - Update tickets +- `helpdesk:agent` - Read agent information (for agent assignment) + +**Optional Permissions**: +- `contact:user.base:readonly` - Read user information (for email conversion feature) + +After configuring permissions, **remember to publish the app version and add it to the enterprise**. + +### Get Service Desk Credentials + +1. In the Feishu admin backend, go to the "Service Desk" app +2. Click "Settings" > "API Settings" +3. Get the following information: + - **Helpdesk ID** + - **Helpdesk Token** + +### Configuration Parameters + +#### Basic Authentication Parameters + + + **Feishu App ID**, obtained from the Feishu Open Platform app details page + + Example: `cli_a1234567890abcde` + + + + **Feishu App Secret**, obtained from the Feishu Open Platform app details page + + Example: `xxxxxxxxxxxxxxxxxxxxx` + + + + **Service Desk Token**, obtained from the Feishu Service Desk settings page, required for creating tickets + + Example: `ht-37eda72b-cb1d-140e-7433-b51dae3077f8` + + +#### Optional Parameters + + + **Feishu Server Address** + + - Domestic version: `https://open.feishu.cn` (default) + - International version (Lark): `https://open.larksuite.com` + + + + **Service Desk ID** (optional), obtained from the Feishu Service Desk settings page + + Example: `7567218981669896211` + + If not provided, the default service desk will be used + + + + **Default User Open ID** (optional), used as the default reporter when creating tickets + + Example: `ou_036f2ff9187e01e440e95a629abaef6c` + + If `user_email` or `open_id` is not specified in the workflow, this value will be used + + +## Usage in Workflows + +### Minimal Mode (Recommended) ⭐ + +Just 3 parameters, everything else is automatic: + +```yaml +workflow: + id: create-feishu-ticket-simple + description: Minimal configuration - auto-enrichment + triggers: + - type: alert + filters: + - key: severity + value: [critical, high] + actions: + - name: create-ticket + provider: + type: feishu_servicedesk + config: "{{ providers.feishu_servicedesk }}" + with: + # Only need these 3 lines! + title: "{{ alert.name }}" + user_email: "{{ alert.assignee }}" # Use alert assignee email + agent_email: "oncall@example.com" # On-call agent email +``` + +**Automatically included content**: +- ✅ Complete event details (time, status, source, environment) +- ✅ Keep platform link (http://localhost:3000/alerts/feed?cel=...) +- ✅ Original monitoring system links (Prometheus, Grafana, etc.) +- ✅ Rich text card format +- ✅ Automatic agent assignment + +### Advanced Configuration + +Use more parameters for fine-grained control: + +```yaml +workflow: + id: create-feishu-ticket-advanced + description: Advanced configuration example + triggers: + - type: alert + filters: + - key: severity + value: critical + actions: + - name: create-ticket + provider: + type: feishu_servicedesk + config: "{{ providers.feishu_servicedesk }}" + with: + # Basic parameters + title: "Urgent Alert: {{ alert.name }}" + user_email: "user@example.com" + agent_email: "agent@example.com" + + # Advanced parameters + priority: 4 # 1-Low, 2-Medium, 3-High, 4-Urgent + tags: ["production", "database"] # Tag list + category_id: "category_123" # Ticket category + description: "Custom description" # Override auto-enrichment + auto_enrich: false # Disable auto-enrichment + + # Custom fields + customized_fields: + - id: "field_12345" + value: "Affects all users" +``` + +### Incident Triggered + +Support creating tickets from Incidents: + +```yaml +workflow: + id: create-feishu-ticket-from-incident + description: Create ticket from Incident + triggers: + - type: incident + filters: + - key: severity + value: [critical, high] + actions: + - name: create-ticket + provider: + type: feishu_servicedesk + config: "{{ providers.feishu_servicedesk }}" + with: + title: "{{ incident.user_generated_name }}" + user_email: "{{ incident.assignee }}" + agent_email: "sre-team@example.com" +``` + +**Incident tickets automatically include**: +- Incident name and severity +- Associated alert count +- Alert source list +- Associated service list +- Incident details link + +### Update Ticket Status + +Automatically update ticket status when alert is resolved: + +```yaml +workflow: + id: update-feishu-ticket-on-resolve + description: Update ticket when alert is resolved + triggers: + - type: alert + filters: + - key: status + value: resolved + actions: + - name: complete-ticket + provider: + type: feishu_servicedesk + config: "{{ providers.feishu_servicedesk }}" + with: + ticket_id: "{{ alert.ticket_id }}" + status: 50 # Completed + add_comment: "Alert automatically resolved" # Add comment +``` + +### Using Email Assignment (Recommended) + +Use email addresses, automatically convert to Feishu User IDs: + +```yaml +with: + title: "{{ alert.name }}" + user_email: "reporter@example.com" # Reporter email → open_id + agent_email: "handler@example.com" # Handler agent email → agent_id +``` + +**Advantages**: +- ✅ Easier to remember than `open_id` +- ✅ Automatic conversion, no manual lookup needed +- ✅ Supports dynamic variables (e.g., `{{ alert.assignee }}`) + +## Ticket Status + +Feishu Service Desk supports the following ticket statuses: + +| Status Code | Status Name | Description | +|-------------|-------------|-------------| +| 1 | Pending | Ticket created, waiting to be handled | +| 2 | Processing | Ticket is being processed | +| 3 | Confirming | Ticket processing completed, waiting for confirmation | +| 50 | Completed | Ticket completed and closed | + +## Custom Fields + +Feishu Service Desk supports custom fields. You can use the `customized_fields` parameter when creating or updating tickets: + +```yaml +actions: + - name: create-ticket-with-custom-fields + provider: + type: feishu_servicedesk + config: "{{ providers.feishu_servicedesk }}" + with: + title: "Alert Ticket" + description: "System anomaly detected" + customized_fields: + - id: "field_12345" + value: "high" + - id: "field_67890" + value: "database" +``` + +## Workflow Parameters + +### Main Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `title` | string | ✅ Yes | Ticket title | +| `user_email` | string | No | Reporter email (auto-converted to open_id) | +| `agent_email` | string | No | Agent email (auto-converted to agent_id) | + +### Advanced Parameters (via YAML) + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `description` | string | Auto-enriched | Ticket description | +| `priority` | int | - | Priority: 1-Low, 2-Medium, 3-High, 4-Urgent | +| `tags` | list | - | Ticket tags | +| `category_id` | string | - | Ticket category ID | +| `ticket_id` | string | - | Ticket ID (for updating existing ticket) | +| `status` | int | - | Ticket status (for update) | +| `add_comment` | string | - | Add comment (for update) | +| `customized_fields` | list | - | Custom fields | +| `auto_enrich` | bool | true | Enable auto-enrichment | + +## Auto-Enrichment Format + +### For Alerts + +Automatically includes: +- 🔴 Event name +- 📊 Severity +- 🏷️ Current status +- ⏰ Last received time +- 🔥 First triggered time (if available) +- 🔢 Trigger count (if available) +- 📍 Source information +- 🌐 Deployment environment +- ⚙️ Associated service (if available) +- 🔗 Keep event details link +- 🔗 Alert details link (if available) +- 🔗 Monitoring dashboard (if available) +- 🔗 Playbook link (if available) +- 🎯 Associated Incident link (if available) +- 👤 Event assignee (if available) + +### For Incidents + +Automatically includes: +- 🔴 Incident name +- 📊 Severity +- 🏷️ Current status +- 🔍 Associated alert count +- ⏰ Creation time +- ⏰ Start time (if available) +- 📍 Alert sources +- ⚙️ Associated services +- 🔗 Incident details link +- 👤 Incident assignee (if available) + +## Provider Methods + +The provider also exposes several methods for advanced use cases: + +| Method | Description | +|--------|-------------| +| `Get Helpdesks` | Retrieve list of helpdesks | +| `Get Agents` | Retrieve list of agents | +| `Get Users` | Retrieve list of users | +| `Get User By Email` | Get user info by email | +| `Get Ticket Categories` | Retrieve list of ticket categories | +| `Get Ticket Custom Fields` | Retrieve custom field definitions | +| `Add Ticket Comment` | Add comment to a ticket | +| `Assign Ticket` | Assign ticket to an agent | + +## Troubleshooting + +### Authentication Failed + +**Issue**: Unable to connect to Feishu Service Desk + +**Solution**: +1. Check if App ID and App Secret are correct +2. Confirm the app is published and added to the enterprise +3. Verify app permission configuration is correct +4. Ensure Helpdesk Token is correctly configured + +### Ticket Creation Failed + +**Issue**: Ticket creation returns an error + +**Solution**: +1. Confirm `helpdesk:ticket:create` permission is granted +2. Check if helpdesk_id is correct (if provided) +3. Verify custom field format matches service desk configuration +4. Ensure `user_email` or `open_id` or `default_open_id` is provided + +### Email Conversion Failed + +**Issue**: Email to User ID conversion fails + +**Solution**: +1. Ensure `contact:user.base:readonly` permission is granted +2. Verify the email address exists in the Feishu organization +3. Check if the email format is correct +4. Try using `open_id` directly as a fallback + +### Rich Card Not Displayed + +**Issue**: Rich text card not showing in ticket + +**Solution**: +1. Check if the ticket was created successfully +2. Verify Helpdesk Token is correct +3. Ensure the app has message sending permissions +4. The card is sent as a separate message after ticket creation + +### International Version Users (Lark) + +If you're using the international version of Feishu (Lark), please set the `host` parameter to `https://open.larksuite.com` + +## Useful Links + + + View complete Feishu Open Platform documentation + + + + View detailed Service Desk API documentation + + + + Visit Feishu Open Platform to create an app + + + + For Lark (international version) users + + +## Best Practices + +### 1. Use Email-based Assignment + +```yaml +# ✅ Recommended +with: + user_email: "{{ alert.assignee }}" + agent_email: "oncall@example.com" + +# ❌ Not recommended +with: + open_id: "ou_xxx..." # Hard to remember + agent_id: "ou_yyy..." # Hard to maintain +``` + +### 2. Let Auto-Enrichment Work + +```yaml +# ✅ Recommended - minimal config, full details +with: + title: "{{ alert.name }}" + user_email: "user@example.com" + agent_email: "agent@example.com" + +# ❌ Not recommended - manual template, error-prone +with: + title: "{{ alert.name }}" + description: | + Event: {{ alert.name }} + Severity: {{ alert.severity }} + ... (50+ lines of manual template) +``` + +### 3. Use Priority for Critical Alerts + +```yaml +with: + title: "{{ alert.name }}" + user_email: "{{ alert.assignee }}" + agent_email: "oncall@example.com" + priority: 4 # Urgent for critical alerts +``` + +### 4. Use Tags for Better Organization + +```yaml +with: + title: "{{ alert.name }}" + user_email: "{{ alert.assignee }}" + agent_email: "oncall@example.com" + tags: ["{{ alert.environment }}", "{{ alert.service }}"] +``` + +## Support + +If you encounter issues using the Feishu Service Desk provider, please: + +1. Review the troubleshooting section above +2. Visit Feishu Open Platform documentation +3. Submit an issue in Keep's GitHub repository +4. Check Keep's documentation at https://docs.keephq.dev/ + +## Examples + +Check the `examples/workflows/` directory in the Keep repository for more workflow examples: +- `feishu_servicedesk_simple.yml` - Minimal configuration example +- `feishu_servicedesk_best_practice.yml` - Best practices guide +- `feishu_servicedesk_with_email.yml` - Email conversion example + diff --git a/keep-ui/public/icons/feishu_servicedesk-icon.png b/keep-ui/public/icons/feishu_servicedesk-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c0b0446c8396fbb6cb63bbfd2eab46efac8626cc GIT binary patch literal 13812 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 p`1K$I0{j2?KYaclvi30GP&4 str: + if self._host is not None: + return self._host + host = self.authentication_config.host + if not host.startswith("https://") and not host.startswith("http://"): + host = f"https://{host}" + self._host = host + return self._host + + def dispose(self): + """ + No need to dispose of anything, so just do nothing. + """ + pass + + def __get_access_token(self) -> str: + """ + 获取飞书 tenant_access_token + Get Feishu tenant access token. + """ + try: + # 检查 token 是否还有效 + import datetime + if self._access_token and self._token_expiry: + if datetime.datetime.now() < self._token_expiry: + return self._access_token + + url = urljoin( + self.feishu_host, + "/open-apis/auth/v3/tenant_access_token/internal/", + ) + + payload = { + "app_id": self.authentication_config.app_id, + "app_secret": self.authentication_config.app_secret, + } + + response = requests.post(url, json=payload) + response.raise_for_status() + + result = response.json() + if result.get("code") != 0: + raise ProviderException( + f"Failed to get access token: {result.get('msg')}" + ) + + self._access_token = result.get("tenant_access_token") + # 设置 token 过期时间(提前 5 分钟过期) + expire_seconds = result.get("expire", 7200) - 300 + self._token_expiry = datetime.datetime.now() + datetime.timedelta( + seconds=expire_seconds + ) + + return self._access_token + except Exception as e: + raise ProviderException(f"Failed to get access token: {e}") + + def __get_headers(self, use_helpdesk_auth: bool = False): + """ + Helper method to build the headers for Feishu API requests. + + Args: + use_helpdesk_auth (bool): 如果为True且配置了helpdesk_token, + 同时发送服务台特殊认证头 + + Note: 服务台API需要同时发送两个认证头: + 1. Authorization: Bearer {tenant_access_token} + 2. X-Lark-Helpdesk-Authorization: base64(helpdesk_id:helpdesk_token) + """ + headers = { + "Content-Type": "application/json; charset=utf-8", + } + + # 总是添加标准的 tenant_access_token 认证 + access_token = self.__get_access_token() + headers["Authorization"] = f"Bearer {access_token}" + + # 如果需要服务台特殊认证,同时添加服务台认证头 + if (use_helpdesk_auth and + self.authentication_config.helpdesk_id and + self.authentication_config.helpdesk_token): + import base64 + auth_string = f"{self.authentication_config.helpdesk_id}:{self.authentication_config.helpdesk_token}" + encoded = base64.b64encode(auth_string.encode()).decode() + headers["X-Lark-Helpdesk-Authorization"] = encoded + self.logger.info(f"Using dual authentication: Bearer token + Helpdesk auth") + + return headers + + def __get_url(self, path: str): + """ + Helper method to build the url for Feishu API requests. + """ + return urljoin(self.feishu_host, path) + + def __create_ticket( + self, + title: str, + description: str = "", + customized_fields: List[dict] = None, + category_id: Optional[str] = None, + priority: Optional[int] = None, + tags: Optional[List[str]] = None, + open_id: Optional[str] = None, + agent_id: Optional[str] = None, + **kwargs: dict, + ): + """ + 创建飞书服务台工单(启动人工服务) + Helper method to create a ticket in Feishu Service Desk. + + Note: 飞书服务台使用 StartServiceTicket API (启动人工服务) + 需要 helpdesk_token 和特殊的认证头 + """ + try: + self.logger.info("Creating a ticket in Feishu Service Desk...") + + # 飞书服务台API:启动人工服务 + url = self.__get_url("/open-apis/helpdesk/v1/start_service") + + # 🆕 直接使用enriched描述作为customized_info + # 不再使用简化格式,因为后续的消息/评论API都不可用 + # customized_info会作为首条消息显示在服务台对话中 + if description: + ticket_content = description + else: + # 如果没有description,使用简单格式 + ticket_content = f"【工单标题】{title}\n\n请查看Keep平台获取详细信息" + + # 如果有额外信息,添加到内容末尾 + if category_id: + ticket_content += f"\n\n【分类ID】{category_id}" + if priority: + ticket_content += f"\n【优先级】{priority}" + if tags: + ticket_content += f"\n【标签】{', '.join(tags)}" + + # 构建请求体(符合飞书API格式) + ticket_data = { + "human_service": True, # 启用人工服务 + "customized_info": ticket_content, # 完整的enriched内容 + } + + # 添加用户open_id(必需) + if open_id: + ticket_data["open_id"] = open_id + elif kwargs.get("open_id"): + ticket_data["open_id"] = kwargs.get("open_id") + elif self.authentication_config.default_open_id: + ticket_data["open_id"] = self.authentication_config.default_open_id + self.logger.info(f"Using default open_id: {self.authentication_config.default_open_id}") + else: + # open_id是必需的 + raise ProviderException( + "open_id is required to create a ticket. " + "Please provide open_id parameter or set default_open_id in configuration." + ) + + # 添加指定客服(可选) + if agent_id: + ticket_data["appointed_agents"] = [agent_id] + + # 记录请求信息(用于调试) + self.logger.info(f"Creating ticket with URL: {url}") + self.logger.info(f"Request data: {json.dumps(ticket_data, ensure_ascii=False)}") + + # 使用服务台特殊认证 + response = requests.post( + url=url, + json=ticket_data, + headers=self.__get_headers(use_helpdesk_auth=True), + ) + + # 记录响应状态和内容(用于调试) + self.logger.info(f"Response status: {response.status_code}") + self.logger.info(f"Response headers: {dict(response.headers)}") + + # 先获取原始文本,以便调试 + response_text = response.text + self.logger.info(f"Response text (first 500 chars): {response_text[:500]}") + + # 尝试解析JSON + try: + result = json.loads(response_text) + except json.JSONDecodeError as e: + self.logger.error(f"Failed to parse JSON response: {e}") + self.logger.error(f"Full response text: {response_text}") + raise ProviderException( + f"Failed to parse Feishu API response. Status: {response.status_code}, " + f"Response: {response_text[:200]}" + ) + + # 检查HTTP状态码 + try: + response.raise_for_status() + except Exception as e: + self.logger.exception( + "Failed to create a ticket", extra={"result": result, "status": response.status_code} + ) + raise ProviderException( + f"Failed to create a ticket. HTTP {response.status_code}: {result}" + ) + + # 检查飞书API返回的code + if result.get("code") != 0: + error_msg = result.get("msg", "Unknown error") + self.logger.error(f"Feishu API returned error code {result.get('code')}: {error_msg}") + raise ProviderException( + f"Failed to create ticket: {error_msg} (code: {result.get('code')})" + ) + + self.logger.info("Created a ticket in Feishu Service Desk!") + + # 返回完整信息供后续使用 + ticket_data = result.get("data", {}) + ticket_id = ticket_data.get("ticket_id") + chat_id = ticket_data.get("chat_id") + + # 🆕 使用正确的服务台消息API发送详细描述 + # API: POST /open-apis/helpdesk/v1/tickets/{ticket_id}/messages + if ticket_id and description and len(description) > 200: + try: + success = self.__send_ticket_message(ticket_id, description) + if success: + self.logger.info("✅ Sent detailed description via ticket messages API") + else: + self.logger.warning("⚠️ Failed to send message, but ticket created successfully") + self.logger.info("Enriched content is in customized_info") + except Exception as e: + # 发送失败不影响工单创建 + self.logger.warning(f"Failed to send ticket message: {e}") + self.logger.info("Enriched content is in customized_info") + else: + self.logger.info("✅ Full enriched content sent via customized_info") + + return { + "ticket": ticket_data, + "ticket_id": ticket_id, + "chat_id": chat_id, + # 这些信息可以保存到Keep的alert/incident中,用于后续同步 + "feishu_ticket_id": ticket_id, + "feishu_chat_id": chat_id, + } + except Exception as e: + raise ProviderException(f"Failed to create a ticket: {e}") + + def __build_rich_card_content(self, enriched_text: str) -> list: + """ + 将enriched文本转换为飞书富文本卡片格式 + Convert enriched text to Feishu rich text card format with clickable links. + + Args: + enriched_text: Enriched描述文本 + + Returns: + list: 飞书post格式的content数组 + """ + content_lines = [] + + lines = enriched_text.split('\n') + i = 0 + + while i < len(lines): + line = lines[i].strip() + i += 1 + + # 跳过空行和分隔线 + if not line or line.startswith('━'): + continue + + # 检测URL行(下一行是链接) + if i < len(lines) and (lines[i].strip().startswith('http://') or lines[i].strip().startswith('https://')): + # 当前行是描述,下一行是URL + label = line + url = lines[i].strip() + i += 1 + + # 根据标签选择合适的显示文本 + if '告警详情' in label or 'alert-his-events' in url or 'nalert' in url: + link_text = "🔔 查看告警详情" + elif 'Keep事件详情' in label: + link_text = "📱 查看Keep事件" + elif 'Incident' in label: + link_text = "🎯 查看Incident" + elif '生成器' in label or 'generator' in label.lower(): + link_text = "⚙️ 打开生成器" + elif '运行手册' in label or 'playbook' in label.lower(): + link_text = "📖 查看手册" + else: + link_text = "🔗 点击打开" + + # 创建可点击的超链接 + content_lines.append([ + { + "tag": "text", + "text": label + " " + }, + { + "tag": "a", + "text": link_text, + "href": url + } + ]) + # 检测直接的URL行 + elif line.startswith('http://') or line.startswith('https://'): + # 根据URL类型设置友好文本 + if 'alerts/feed' in line: + link_text = "📱 点击查看Keep事件详情" + elif '/incidents/' in line: + link_text = "🎯 点击查看Incident详情" + elif 'alert-his-events' in line or 'nalert' in line: + link_text = "🔔 查看告警详情" + elif 'prometheus' in line or 'grafana' in line: + link_text = "📊 打开监控系统" + else: + link_text = "🔗 点击打开链接" + + content_lines.append([{ + "tag": "a", + "text": link_text, + "href": line + }]) + # 章节标题(包含emoji或特殊字符) + elif any(emoji in line for emoji in ['📋', '🔗', '📍', '🔍', '⚠️', '📝']): + content_lines.append([{ + "tag": "text", + "text": line, + "un_escape": True + }]) + else: + # 普通文本行 + if line: + content_lines.append([{ + "tag": "text", + "text": line + }]) + + # 如果没有解析出内容,使用原始文本 + if not content_lines: + content_lines = [[{ + "tag": "text", + "text": enriched_text + }]] + + return content_lines + + def __send_ticket_message(self, ticket_id: str, content: str): + """ + 向工单发送消息(使用飞书服务台专用消息API) + Send a message to helpdesk ticket. + + Args: + ticket_id: Ticket ID + content: 消息内容(enriched描述) + + Returns: + bool: 是否发送成功 + + API: POST /open-apis/helpdesk/v1/tickets/{ticket_id}/messages + """ + try: + self.logger.info(f"Sending rich card message to ticket {ticket_id}...") + + # 飞书服务台消息API + url = self.__get_url(f"/open-apis/helpdesk/v1/tickets/{ticket_id}/messages") + + # 🎨 构建富文本卡片格式 + # 参考:https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/helpdesk-v1/ticket-message/create + card_content = self.__build_rich_card_content(content) + + message_data = { + "msg_type": "post", + "content": { + "post": { + "zh_cn": { + "title": "📋 事件详细信息", + "content": card_content + } + } + } + } + + self.logger.info(f"Sending ticket message to URL: {url}") + + # 🔧 服务台消息API需要双认证 + response = requests.post( + url=url, + json=message_data, + headers=self.__get_headers(use_helpdesk_auth=True), # ← 关键:使用服务台认证 + ) + + self.logger.info(f"Ticket message response: {response.status_code}") + + # 尝试解析响应 + try: + result = response.json() + self.logger.info(f"Response: {result}") + except: + result = {"text": response.text} + + if response.status_code == 200: + if result.get("code") == 0: + self.logger.info("✅ Message sent successfully to ticket") + return True + else: + self.logger.warning(f"Failed to send ticket message: {result.get('msg')}") + return False + else: + self.logger.warning(f"Failed to send ticket message: HTTP {response.status_code}, {result}") + return False + + except Exception as e: + self.logger.warning(f"Exception while sending ticket message: {e}") + import traceback + self.logger.debug(f"Traceback: {traceback.format_exc()}") + return False + + def __update_ticket( + self, + ticket_id: str, + status: Optional[int] = None, + customized_fields: List[dict] = None, + **kwargs: dict, + ): + """ + 更新飞书服务台工单 + Helper method to update a ticket in Feishu Service Desk. + """ + try: + self.logger.info(f"Updating ticket {ticket_id} in Feishu Service Desk...") + + url = self.__get_url(f"/open-apis/helpdesk/v1/tickets/{ticket_id}") + + update_data = {} + + # 更新工单状态 + if status is not None: + update_data["status"] = status + + # 更新自定义字段 + if customized_fields: + update_data["customized_fields"] = customized_fields + + response = requests.patch( + url=url, + json=update_data, + headers=self.__get_headers(), + ) + + # 记录响应(调试用) + self.logger.info(f"Update response status: {response.status_code}") + response_text = response.text + self.logger.info(f"Update response text: {response_text[:500]}") + + # 解析响应 + try: + result = json.loads(response_text) + except json.JSONDecodeError as e: + self.logger.error(f"Failed to parse update response: {e}") + self.logger.error(f"Full response: {response_text}") + raise ProviderException( + f"Failed to parse update response. Status: {response.status_code}, " + f"Response: {response_text[:200]}" + ) + + # 检查HTTP状态码 + try: + response.raise_for_status() + except Exception as e: + self.logger.exception( + "Failed to update a ticket", + extra={"result": result, "status": response.status_code} + ) + raise ProviderException( + f"Failed to update a ticket. HTTP {response.status_code}: {result}" + ) + + # 检查飞书API返回码 + if result.get("code") != 0: + error_msg = result.get("msg", "Unknown error") + self.logger.error(f"Feishu API update error: code={result.get('code')}, msg={error_msg}") + raise ProviderException( + f"Failed to update ticket: {error_msg} (code: {result.get('code')})" + ) + + self.logger.info("Updated a ticket in Feishu Service Desk!") + return {"ticket": result.get("data", {})} + except ProviderException: + raise + except Exception as e: + raise ProviderException(f"Failed to update a ticket: {e}") + + def __get_ticket(self, ticket_id: str): + """ + 获取工单详情 + Helper method to get ticket details. + + Note: 飞书服务台的查询工单API也需要服务台特殊认证 + """ + try: + self.logger.info(f"Fetching ticket {ticket_id} from Feishu Service Desk...") + + url = self.__get_url(f"/open-apis/helpdesk/v1/tickets/{ticket_id}") + + # 使用服务台特殊认证 + response = requests.get( + url=url, + headers=self.__get_headers(use_helpdesk_auth=True), + ) + + # 记录响应(调试用) + self.logger.info(f"Get ticket response status: {response.status_code}") + response_text = response.text + self.logger.info(f"Get ticket response: {response_text[:500]}") + + # 解析响应 + try: + result = json.loads(response_text) + except json.JSONDecodeError as e: + self.logger.error(f"Failed to parse get ticket response: {e}") + # 如果无法获取工单详情,返回基本信息 + self.logger.warning("Could not fetch ticket details, using minimal info") + return { + "ticket_id": ticket_id, + "ticket_url": f"{self.feishu_host}/helpdesk/ticket/{ticket_id}" + } + + # 检查状态码 + if response.status_code == 401 or response.status_code == 404: + # 查询API可能不可用,返回基本信息 + self.logger.warning(f"Ticket detail API returned {response.status_code}, using basic info") + return { + "ticket_id": ticket_id, + "ticket_url": f"{self.feishu_host}/helpdesk/ticket/{ticket_id}" + } + + response.raise_for_status() + + if result.get("code") != 0: + self.logger.warning(f"Failed to get ticket details: {result.get('msg')}") + # 返回基本信息而不是抛出异常 + return { + "ticket_id": ticket_id, + "ticket_url": f"{self.feishu_host}/helpdesk/ticket/{ticket_id}" + } + + self.logger.info("Fetched ticket from Feishu Service Desk!") + return result.get("data", {}) + except Exception as e: + # 如果获取工单详情失败,返回基本信息而不是失败 + self.logger.warning(f"Could not fetch ticket details: {e}, returning basic info") + return { + "ticket_id": ticket_id, + "ticket_url": f"{self.feishu_host}/helpdesk/ticket/{ticket_id}" + } + + # ==================== Provider Methods (for frontend) ==================== + + def get_helpdesks(self) -> Dict[str, Any]: + """ + 获取服务台列表 + Get list of helpdesks (for frontend dropdown). + + Returns: + dict: List of helpdesks with their IDs and names + + Note: ⚠️ 此API端点需要验证是否存在。 + 如果失败,可能需要调整端点路径或使用其他方式获取服务台列表。 + """ + try: + self.logger.info("Fetching helpdesks list...") + + url = self.__get_url("/open-apis/helpdesk/v1/helpdesks") + + response = requests.get( + url=url, + headers=self.__get_headers(), + ) + + response.raise_for_status() + + result = response.json() + if result.get("code") != 0: + raise ProviderException( + f"Failed to get helpdesks: {result.get('msg')}" + ) + + helpdesks = result.get("data", {}).get("helpdesks", []) + + # 格式化返回数据,方便前端使用 + formatted_helpdesks = [ + { + "id": helpdesk.get("id"), + "name": helpdesk.get("name"), + "avatar": helpdesk.get("avatar"), + } + for helpdesk in helpdesks + ] + + self.logger.info(f"Fetched {len(formatted_helpdesks)} helpdesks") + return { + "helpdesks": formatted_helpdesks, + "total": len(formatted_helpdesks) + } + except Exception as e: + self.logger.exception("Failed to get helpdesks") + raise ProviderException(f"Failed to get helpdesks: {e}") + + def get_agents(self, helpdesk_id: Optional[str] = None) -> Dict[str, Any]: + """ + 获取服务台客服列表 + Get list of agents (for frontend dropdown). + + Args: + helpdesk_id (str): Helpdesk ID (optional, uses configured helpdesk_id if not provided) + + Returns: + dict: List of agents with their IDs and names + + Note: ⚠️ 此API可能需要特殊认证或使用不同端点。 + 如果失败,尝试: + 1. 使用 use_helpdesk_auth=True 启用服务台特殊认证 + 2. 或使用通讯录API获取用户信息 + """ + try: + helpdesk_id = helpdesk_id or self.authentication_config.helpdesk_id + if not helpdesk_id: + # 如果没有指定服务台ID,获取第一个服务台 + helpdesks = self.get_helpdesks() + if helpdesks.get("helpdesks"): + helpdesk_id = helpdesks["helpdesks"][0]["id"] + else: + raise ProviderException("No helpdesk found") + + self.logger.info(f"Fetching agents for helpdesk {helpdesk_id}...") + + url = self.__get_url(f"/open-apis/helpdesk/v1/agents") + params = {"helpdesk_id": helpdesk_id} + + response = requests.get( + url=url, + params=params, + headers=self.__get_headers(), + ) + + response.raise_for_status() + + result = response.json() + if result.get("code") != 0: + raise ProviderException( + f"Failed to get agents: {result.get('msg')}" + ) + + agents = result.get("data", {}).get("agents", []) + + # 格式化返回数据 + formatted_agents = [ + { + "id": agent.get("user_id"), + "name": agent.get("name"), + "email": agent.get("email"), + "status": agent.get("status"), # 1: 在线, 2: 离线, 3: 忙碌 + } + for agent in agents + ] + + self.logger.info(f"Fetched {len(formatted_agents)} agents") + return { + "agents": formatted_agents, + "total": len(formatted_agents) + } + except Exception as e: + self.logger.exception("Failed to get agents") + raise ProviderException(f"Failed to get agents: {e}") + + def get_ticket_categories(self, helpdesk_id: Optional[str] = None) -> Dict[str, Any]: + """ + 获取工单分类列表 + Get list of ticket categories (for frontend dropdown). + + Args: + helpdesk_id (str): Helpdesk ID (optional) + + Returns: + dict: List of categories with their IDs and names + """ + try: + helpdesk_id = helpdesk_id or self.authentication_config.helpdesk_id + + self.logger.info(f"Fetching ticket categories for helpdesk {helpdesk_id}...") + + url = self.__get_url("/open-apis/helpdesk/v1/categories") + params = {} + if helpdesk_id: + params["helpdesk_id"] = helpdesk_id + + response = requests.get( + url=url, + params=params, + headers=self.__get_headers(), + ) + + response.raise_for_status() + + result = response.json() + if result.get("code") != 0: + raise ProviderException( + f"Failed to get categories: {result.get('msg')}" + ) + + categories = result.get("data", {}).get("categories", []) + + # 格式化返回数据 + formatted_categories = [ + { + "id": category.get("category_id"), + "name": category.get("name"), + "parent_id": category.get("parent_id"), + } + for category in categories + ] + + self.logger.info(f"Fetched {len(formatted_categories)} categories") + return { + "categories": formatted_categories, + "total": len(formatted_categories) + } + except Exception as e: + self.logger.exception("Failed to get categories") + raise ProviderException(f"Failed to get categories: {e}") + + def get_ticket_custom_fields(self, helpdesk_id: Optional[str] = None) -> Dict[str, Any]: + """ + 获取工单自定义字段配置 + Get ticket custom fields configuration (for frontend form). + + Args: + helpdesk_id (str): Helpdesk ID (optional) + + Returns: + dict: List of custom fields with their configurations + """ + try: + helpdesk_id = helpdesk_id or self.authentication_config.helpdesk_id + + self.logger.info(f"Fetching custom fields for helpdesk {helpdesk_id}...") + + url = self.__get_url("/open-apis/helpdesk/v1/ticket_customized_fields") + params = {} + if helpdesk_id: + params["helpdesk_id"] = helpdesk_id + + response = requests.get( + url=url, + params=params, + headers=self.__get_headers(), + ) + + response.raise_for_status() + + result = response.json() + if result.get("code") != 0: + raise ProviderException( + f"Failed to get custom fields: {result.get('msg')}" + ) + + fields = result.get("data", {}).get("customized_fields", []) + + # 格式化返回数据 + formatted_fields = [ + { + "id": field.get("field_id"), + "name": field.get("display_name"), + "type": field.get("field_type"), # text, dropdown, multi_select, etc. + "required": field.get("required", False), + "options": field.get("dropdown_allowed", []) if field.get("field_type") == "dropdown" else None, + } + for field in fields + ] + + self.logger.info(f"Fetched {len(formatted_fields)} custom fields") + return { + "fields": formatted_fields, + "total": len(formatted_fields) + } + except Exception as e: + self.logger.exception("Failed to get custom fields") + raise ProviderException(f"Failed to get custom fields: {e}") + + def add_ticket_comment( + self, + ticket_id: str, + content: str, + comment_type: int = 1 # 1: 文本, 2: 富文本 + ) -> Dict[str, Any]: + """ + 添加工单评论 + Add comment to a ticket. + + Args: + ticket_id (str): Ticket ID + content (str): Comment content + comment_type (int): Comment type (1: plain text, 2: rich text) + + Returns: + dict: Comment result + + Note: ⚠️ 此API端点需要验证。 + 评论功能可能需要: + 1. 不同的API端点 + 2. 使用飞书消息API + 3. 不同的参数格式(msg_type字段名) + """ + try: + self.logger.info(f"Adding comment to ticket {ticket_id}...") + + url = self.__get_url(f"/open-apis/helpdesk/v1/tickets/{ticket_id}/comments") + + comment_data = { + "content": content, + "msg_type": comment_type, + } + + response = requests.post( + url=url, + json=comment_data, + headers=self.__get_headers(), + ) + + response.raise_for_status() + + result = response.json() + if result.get("code") != 0: + raise ProviderException( + f"Failed to add comment: {result.get('msg')}" + ) + + self.logger.info("Comment added successfully!") + return { + "success": True, + "comment": result.get("data", {}), + "ticket_id": ticket_id + } + except Exception as e: + self.logger.exception("Failed to add comment") + raise ProviderException(f"Failed to add comment: {e}") + + def assign_ticket( + self, + ticket_id: str, + agent_id: str, + comment: Optional[str] = None + ) -> Dict[str, Any]: + """ + 分配工单给指定客服 + Assign ticket to a specific agent. + + Args: + ticket_id (str): Ticket ID + agent_id (str): Agent user ID + comment (str): Optional comment for the assignment + + Returns: + dict: Assignment result + + Note: ⚠️ 飞书服务台不支持后续分配API(返回404) + 建议在创建工单时通过appointed_agents参数指定客服 + 此方法保留以供兼容性,但可能不可用 + """ + try: + self.logger.warning( + f"⚠️ Assign ticket API may not be available in Feishu Service Desk. " + f"Recommend using agent_email/agent_id in ticket creation instead." + ) + self.logger.info(f"Attempting to assign ticket {ticket_id} to agent {agent_id}...") + + # 尝试通过发送消息通知客服 + # 因为直接的分配API不可用 + message = f"@{agent_id} 此工单已分配给你处理" + if comment: + message += f"\n备注:{comment}" + + # 使用消息API通知(作为替代方案) + success = self.__send_ticket_message(ticket_id, message) + + if success: + self.logger.info("✅ Notified agent via ticket message") + return { + "success": True, + "ticket_id": ticket_id, + "agent_id": agent_id, + "method": "message_notification" + } + else: + self.logger.warning("Failed to notify agent, but not critical") + return { + "success": False, + "ticket_id": ticket_id, + "agent_id": agent_id, + "error": "Failed to send notification" + } + + except Exception as e: + self.logger.warning(f"Failed to assign ticket: {e}") + # 不抛出异常,因为工单已创建成功 + return { + "success": False, + "ticket_id": ticket_id, + "agent_id": agent_id, + "error": str(e) + } + + def get_user_by_email(self, email: str) -> Dict[str, Any]: + """ + 通过邮箱获取用户信息(包括open_id) + Get user information by email. + + Args: + email (str): 用户邮箱 + + Returns: + dict: 用户信息,包含open_id + + Note: 用于在工作流中通过邮箱自动获取open_id + """ + try: + self.logger.info(f"Getting user info for email: {email}") + + # 飞书通讯录API:批量获取用户信息 + # 参考:https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/contact-v3/user/batch_get_id + url = self.__get_url("/open-apis/contact/v3/users/batch_get_id") + + # 🔧 使用POST请求,emails放在请求体中,格式为数组 + params = { + "user_id_type": "open_id" # 返回open_id格式 + } + + body = { + "emails": [email], # 数组格式 + "include_resigned": False # 不包括离职用户 + } + + self.logger.info(f"Request URL: {url}") + self.logger.info(f"Request body: {json.dumps(body, ensure_ascii=False)}") + + response = requests.post( # ← POST而不是GET + url=url, + params=params, + json=body, + headers=self.__get_headers(), + ) + + self.logger.info(f"Response status: {response.status_code}") + + # 解析响应 + try: + result = response.json() + self.logger.info(f"Response: {result}") + except: + self.logger.error(f"Failed to parse response: {response.text}") + raise + + response.raise_for_status() + + if result.get("code") != 0: + raise ProviderException( + f"Failed to get user by email: {result.get('msg')} (code: {result.get('code')})" + ) + + # 提取user_list + user_list = result.get("data", {}).get("user_list", []) + + if not user_list: + raise ProviderException(f"User not found for email: {email}") + + # 提取第一个匹配的用户 + user_info = user_list[0] + user_id = user_info.get("user_id") + + self.logger.info(f"✅ Found user for {email}: {user_id}") + + return { + "open_id": user_id, # open_id + "email": email, + "user_id": user_id, + } + except Exception as e: + self.logger.exception("Failed to get user by email") + raise ProviderException(f"Failed to get user by email: {e}") + + def get_users(self, page_size: int = 50) -> Dict[str, Any]: + """ + 获取企业用户列表 + Get list of users in the organization. + + Args: + page_size (int): 每页数量 + + Returns: + dict: 用户列表 + + Note: 用于前端下拉选择用户 + """ + try: + self.logger.info("Fetching users list...") + + url = self.__get_url("/open-apis/contact/v3/users") + + params = { + "page_size": page_size + } + + response = requests.get( + url=url, + params=params, + headers=self.__get_headers(), + ) + + response.raise_for_status() + result = response.json() + + if result.get("code") != 0: + raise ProviderException( + f"Failed to get users: {result.get('msg')}" + ) + + items = result.get("data", {}).get("items", []) + + # 格式化返回数据 + formatted_users = [ + { + "open_id": user.get("open_id"), + "user_id": user.get("user_id"), + "name": user.get("name"), + "email": user.get("enterprise_email") or user.get("email"), + } + for user in items + ] + + self.logger.info(f"Fetched {len(formatted_users)} users") + return { + "users": formatted_users, + "total": len(formatted_users) + } + except Exception as e: + self.logger.exception("Failed to get users") + raise ProviderException(f"Failed to get users: {e}") + + # ==================== End of Provider Methods ==================== + + def __auto_enrich_description(self, title: str, description: str, **kwargs) -> str: + """ + 🆕 自动enrichment工单描述,添加Keep平台链接和事件详细信息 + Auto-enrich ticket description with Keep platform links and event details. + + 如果检测到工作流上下文中有alert或incident,自动添加: + - Keep平台事件详情页链接(可直接点击) + - 完整的时间信息(触发时间、次数等) + - 所有来源和环境信息 + - 关联Incident链接 + - 原始监控系统链接 + + Args: + title: 工单标题 + description: 原始描述 + **kwargs: 其他参数 + + Returns: + enriched_description: enrichment后的描述 + """ + try: + # 获取工作流上下文 + context = self.context_manager.get_full_context() if hasattr(self, 'context_manager') else {} + + # 尝试从上下文中获取alert或incident + alert = context.get('event', None) + incident = context.get('incident', None) + + # 如果没有找到,返回原始描述 + if not alert and not incident: + self.logger.debug("No alert or incident found in context, using original description") + return description if description else "无详细描述 / No description provided" + + # 辅助函数:安全获取属性值 + def get_attr(obj, attr, default='N/A'): + """安全获取对象属性,支持dict和对象""" + if obj is None: + return default + # 如果是dict,使用get方法 + if isinstance(obj, dict): + return obj.get(attr, default) + # 如果是对象,使用getattr + return getattr(obj, attr, default) + + # 辅助函数:格式化状态 + def format_status(status): + """格式化状态,去除前缀,保持英文""" + if not status or status == 'N/A': + return 'N/A' + status_str = str(status) + # 去除 INCIDENTSTATUS. 或 ALERTSTATUS. 前缀 + if '.' in status_str: + status_str = status_str.split('.')[-1] + return status_str.upper() + + # 辅助函数:格式化严重程度 + def format_severity(severity): + """格式化严重程度,保持英文""" + if not severity or severity == 'N/A': + return 'N/A' + return str(severity).upper() + + # 构建enrichment描述(参考用户提供的格式) + enriched = "" + + if alert: + # Alert基本信息 + enriched += f"🔴 事件名称: {title}\n" + enriched += f"📊 严重程度: {format_severity(get_attr(alert, 'severity'))}\n" + enriched += f"🏷️ 当前状态: {format_status(get_attr(alert, 'status'))}\n" + enriched += f"⏰ 最后接收: {get_attr(alert, 'lastReceived')}\n" + + firing_start = get_attr(alert, 'firingStartTime', None) + if firing_start and firing_start != 'N/A' and firing_start != 'null' and str(firing_start).lower() != 'none': + enriched += f"🔥 首次触发: {firing_start}\n" + + firing_counter = get_attr(alert, 'firingCounter', None) + # 注意:firing_counter可能是0,0也是有效值 + if firing_counter is not None and firing_counter != 'N/A' and str(firing_counter).lower() != 'none': + enriched += f"🔢 触发次数: {firing_counter}\n" + + # 来源信息(一行显示) + sources = get_attr(alert, 'source', []) + if sources and sources != 'N/A': + if isinstance(sources, list): + enriched += f"\n📍 来源信息: {', '.join(str(s) for s in sources)}\n" + else: + enriched += f"\n📍 来源信息: {sources}\n" + else: + enriched += f"\n📍 来源信息: N/A\n" + + enriched += f"🌐 部署环境: {get_attr(alert, 'environment')}\n" + + service = get_attr(alert, 'service', None) + if service and service != 'N/A' and service != 'null' and str(service).lower() != 'none': + enriched += f"⚙️ 关联服务: {service}\n" + + # 🔧 获取Keep前端URL(不是API URL) + keep_api_url = None + keep_context = context.get('keep') + if isinstance(keep_context, dict): + keep_api_url = keep_context.get('api_url') + + # 如果context中没有,尝试从环境变量或配置获取 + if not keep_api_url: + import os + keep_api_url = os.environ.get('KEEP_API_URL') + if not keep_api_url: + # 使用默认值(本地开发环境) + keep_api_url = "http://localhost:3000/api/v1" + + # 🔧 将API URL转换为前端UI URL + # API: http://0.0.0.0:8080/api/v1 → 前端: http://localhost:3000 + # API: http://localhost:8080/api/v1 → 前端: http://localhost:3000 + keep_frontend_url = keep_api_url.replace('/api/v1', '') + # 如果是后端端口(8080, 8000等),替换为前端端口(3000) + keep_frontend_url = keep_frontend_url.replace(':8080', ':3000') + keep_frontend_url = keep_frontend_url.replace(':8000', ':3000') + keep_frontend_url = keep_frontend_url.replace('0.0.0.0', 'localhost') + + self.logger.debug(f"Keep API URL: {keep_api_url}") + self.logger.debug(f"Keep Frontend URL: {keep_frontend_url}") + + alert_id = get_attr(alert, 'id', None) + + # 重要链接 + link_added = False + if alert_id and alert_id != 'N/A': + keep_url = f"{keep_frontend_url}/alerts/feed?cel=id%3D%3D%22{alert_id}%22" + enriched += f"\n🔗 事件详情: {keep_url}\n" + link_added = True + + # 告警详情URL(alert.url字段) + alert_url = get_attr(alert, 'url', None) + if alert_url and alert_url != 'N/A' and alert_url != 'null' and str(alert_url).lower() != 'none': + if not link_added: + enriched += "\n" + enriched += f"🔗 告警详情: {alert_url}\n" + link_added = True + + # 其他链接 + generator_url = get_attr(alert, 'generatorURL', None) + if generator_url and generator_url != 'N/A' and generator_url != 'null' and str(generator_url).lower() != 'none': + enriched += f"🔗 监控面板: {generator_url}\n" + link_added = True + + playbook_url = get_attr(alert, 'playbook_url', None) + if playbook_url and playbook_url != 'N/A' and playbook_url != 'null' and str(playbook_url).lower() != 'none': + enriched += f"🔗 处理手册: {playbook_url}\n" + link_added = True + + # Incident关联 + incident_id = get_attr(alert, 'incident', None) + if incident_id and incident_id != 'N/A' and incident_id != 'null' and str(incident_id).lower() != 'none': + # 确保keep_api_url可用 + if not keep_api_url: + import os + keep_api_url = os.environ.get('KEEP_API_URL', "http://localhost:3000/api/v1") + # 转换为前端URL + keep_frontend_url = keep_api_url.replace('/api/v1', '') + keep_frontend_url = keep_frontend_url.replace(':8080', ':3000').replace(':8000', ':3000').replace('0.0.0.0', 'localhost') + enriched += f"🎯 关联Incident: {keep_frontend_url}/incidents/{incident_id}\n" + + elif incident: + # Incident信息 + incident_name = get_attr(incident, 'user_generated_name', None) or get_attr(incident, 'ai_generated_name', None) or title + enriched += f"🔴 事件名称: {incident_name}\n" + enriched += f"📊 严重程度: {format_severity(get_attr(incident, 'severity'))}\n" + enriched += f"🏷️ 当前状态: {format_status(get_attr(incident, 'status'))}\n" + enriched += f"🔍 关联告警数: {get_attr(incident, 'alerts_count', 0)}\n" + enriched += f"⏰ 创建时间: {get_attr(incident, 'creation_time')}\n" + + start_time = get_attr(incident, 'start_time', None) + if start_time and start_time != 'N/A' and start_time != 'null' and str(start_time).lower() != 'none': + enriched += f"⏰ 开始时间: {start_time}\n" + + # 告警来源(Incident特有字段) + alert_sources = get_attr(incident, 'alert_sources', []) + if alert_sources and alert_sources != 'N/A': + if isinstance(alert_sources, list) and len(alert_sources) > 0: + enriched += f"\n📍 告警来源: {', '.join(str(s) for s in alert_sources)}\n" + else: + enriched += f"\n📍 告警来源: {alert_sources}\n" + + # 关联服务(Incident中是services数组) + services = get_attr(incident, 'services', []) + if services and services != 'N/A': + if isinstance(services, list) and len(services) > 0: + enriched += f"⚙️ 关联服务: {', '.join(str(s) for s in services)}\n" + else: + enriched += f"⚙️ 关联服务: {services}\n" + + # 🔧 获取Keep前端URL(不是API URL) + keep_api_url = None + keep_context = context.get('keep') + if isinstance(keep_context, dict): + keep_api_url = keep_context.get('api_url') + + if not keep_api_url: + import os + keep_api_url = os.environ.get('KEEP_API_URL', "http://localhost:3000/api/v1") + + # 🔧 将API URL转换为前端UI URL + keep_frontend_url = keep_api_url.replace('/api/v1', '') + keep_frontend_url = keep_frontend_url.replace(':8080', ':3000') + keep_frontend_url = keep_frontend_url.replace(':8000', ':3000') + keep_frontend_url = keep_frontend_url.replace('0.0.0.0', 'localhost') + + incident_id = get_attr(incident, 'id', None) + + # Keep链接 + if incident_id and incident_id != 'N/A' and incident_id != 'null' and str(incident_id).lower() != 'none': + keep_url = f"{keep_frontend_url}/incidents/{incident_id}" + enriched += f"\n🔗 事件详情: {keep_url}\n" + + # 添加原始描述 + if description: + enriched += f"\n📝 详细描述: {description}\n" + + # 负责人 + if alert: + assignee = get_attr(alert, 'assignee', None) + if assignee and assignee != 'N/A' and assignee != 'null' and str(assignee).lower() != 'none': + enriched += f"\n👤 事件负责人: {assignee}\n" + elif incident: + assignee = get_attr(incident, 'assignee', None) + if assignee and assignee != 'N/A' and assignee != 'null' and str(assignee).lower() != 'none': + enriched += f"\n👤 事件负责人: {assignee}\n" + + # 添加提示 + enriched += f"\n⚠️ 请点击上方事件详情链接查看完整信息并及时处理" + + self.logger.info("✅ Auto-enriched ticket description with event context") + return enriched + + except Exception as e: + self.logger.warning(f"Failed to auto-enrich description: {e}, using original") + import traceback + self.logger.debug(f"Traceback: {traceback.format_exc()}") + return description if description else "无详细描述 / No description provided" + + def _notify( + self, + title: Optional[str] = None, + user_email: Optional[str] = None, + agent_email: Optional[str] = None, + **kwargs: dict, + ): + """ + Create or update a Feishu Service Desk ticket. + + Args: + title: Ticket title (required for creating, optional for updating) + user_email: Reporter email address (auto-converts to Feishu User ID) + agent_email: Agent email address (auto-converts to Feishu Agent ID) + + Advanced parameters (passed via workflow YAML): + description, ticket_id, status, customized_fields, category_id, + agent_id, priority, tags, add_comment, open_id, auto_enrich + + The provider automatically: + - Converts emails to Feishu IDs + - Enriches ticket with event details, Keep links, timestamps + - Sends rich text cards to ticket conversation + - Includes original alert URLs from monitoring systems + """ + try: + self.logger.info("Notifying Feishu Service Desk...") + + # 从kwargs中获取其他参数 + description = kwargs.get("description", "") + ticket_id = kwargs.get("ticket_id", None) + + # 如果title在kwargs中,也支持从kwargs获取(兼容性) + if title is None: + title = kwargs.get("title", None) + status = kwargs.get("status", None) + customized_fields = kwargs.get("customized_fields", None) + category_id = kwargs.get("category_id", None) + agent_id = kwargs.get("agent_id", None) + priority = kwargs.get("priority", None) + tags = kwargs.get("tags", None) + add_comment = kwargs.get("add_comment", None) + open_id = kwargs.get("open_id", None) + auto_enrich = kwargs.get("auto_enrich", True) + + # 🆕 如果提供了user_email,自动转换为open_id + if user_email and not open_id: + try: + self.logger.info(f"🔄 Converting user email to open_id: {user_email}") + user_info = self.get_user_by_email(user_email) + open_id = user_info.get("open_id") + self.logger.info(f"✅ Converted user email to open_id: {open_id}") + except Exception as e: + self.logger.warning(f"Failed to convert user email to open_id: {e}") + # 继续执行,使用default_open_id或报错 + + # 🆕 如果提供了agent_email,自动转换为agent_id + if agent_email and not agent_id: + try: + self.logger.info(f"🔄 Converting agent email to agent_id: {agent_email}") + agent_info = self.get_user_by_email(agent_email) + agent_id = agent_info.get("open_id") + self.logger.info(f"✅ Converted agent email to agent_id: {agent_id}") + except Exception as e: + self.logger.warning(f"Failed to convert agent email to agent_id: {e}") + # 继续执行,不分配客服 + + # 🆕 自动enrichment:如果启用且description较短或为空,自动添加完整的事件信息 + # 只在创建工单时(有title)或更新工单时(有description)才enrich + if auto_enrich and title and (not description or len(description) < 300): + original_desc = description + # 创建一个新的kwargs副本,移除已经提取的参数以避免冲突 + enrich_kwargs = {k: v for k, v in kwargs.items() + if k not in ['description', 'ticket_id', 'status', 'customized_fields', + 'category_id', 'agent_id', 'priority', 'tags', + 'add_comment', 'open_id', 'auto_enrich', 'title']} + description = self.__auto_enrich_description(title, description, **enrich_kwargs) + if description != original_desc: + self.logger.info("✅ Auto-enriched description with alert/incident context") + + if ticket_id: + # 更新现有工单 + # 创建一个清理过的kwargs,移除已经作为显式参数传递的值 + update_kwargs = {k: v for k, v in kwargs.items() + if k not in ['description', 'ticket_id', 'status', 'customized_fields', + 'category_id', 'agent_id', 'priority', 'tags', + 'add_comment', 'open_id', 'auto_enrich', 'user_email', 'agent_email', 'title']} + + result = self.__update_ticket( + ticket_id=ticket_id, + status=status, + customized_fields=customized_fields, + **update_kwargs, + ) + + # 如果提供了评论,添加评论 + if add_comment: + self.add_ticket_comment(ticket_id, add_comment) + result["comment_added"] = True + + # 如果提供了客服 ID,分配工单 + if agent_id: + self.assign_ticket(ticket_id, agent_id) + result["assigned_to"] = agent_id + + # 获取工单详情以获取完整的 ticket_url + ticket_details = self.__get_ticket(ticket_id) + result["ticket_url"] = ticket_details.get("ticket_url", "") + + self.logger.info("Updated a Feishu Service Desk ticket: " + str(result)) + return result + else: + # 创建新工单 + if not title: + raise ProviderException("Title is required to create a ticket!") + + # 创建一个清理过的kwargs,移除已经作为显式参数传递的值 + create_kwargs = {k: v for k, v in kwargs.items() + if k not in ['description', 'ticket_id', 'status', 'customized_fields', + 'category_id', 'agent_id', 'priority', 'tags', + 'add_comment', 'open_id', 'auto_enrich', 'user_email', 'agent_email', 'title']} + + result = self.__create_ticket( + title=title, + description=description, + customized_fields=customized_fields, + category_id=category_id, + priority=priority, + tags=tags, + open_id=open_id, + agent_id=agent_id, + **create_kwargs, + ) + + # 获取创建的工单 ID 和 URL + ticket_data = result.get("ticket", {}) + created_ticket_id = ticket_data.get("ticket_id") + + if created_ticket_id: + # Note: agent_id已经在__create_ticket中通过appointed_agents参数指定 + # 不需要后续调用assign_ticket(该API返回404) + if agent_id: + result["assigned_to"] = agent_id + self.logger.info(f"✅ Agent assigned via appointed_agents: {agent_id}") + + # 获取工单详情 + ticket_details = self.__get_ticket(created_ticket_id) + result["ticket_url"] = ticket_details.get("ticket_url", "") + + self.logger.info("Notified Feishu Service Desk!") + return result + except Exception as e: + raise ProviderException(f"Failed to notify Feishu Service Desk: {e}") + + def _query( + self, + ticket_id: Optional[str] = None, + **kwargs: dict + ): + """ + Query Feishu Service Desk tickets. + + Args: + ticket_id: Ticket ID (query specific ticket, leave empty to list tickets) + + Advanced filters (via workflow YAML): + status, category_id, agent_id, page_size, page_token + """ + try: + if ticket_id: + # 查询单个工单 + ticket = self.__get_ticket(ticket_id) + return {"ticket": ticket} + else: + # 从 kwargs 提取高级参数 + status = kwargs.get("status", None) + category_id = kwargs.get("category_id", None) + agent_id = kwargs.get("agent_id", None) + page_size = kwargs.get("page_size", 50) + page_token = kwargs.get("page_token", None) + + # 列出工单 + self.logger.info("Listing tickets from Feishu Service Desk...") + + url = self.__get_url("/open-apis/helpdesk/v1/tickets") + + params = { + "page_size": page_size, + } + + # 添加可选的过滤参数 + if page_token: + params["page_token"] = page_token + if status is not None: + params["status"] = status + if category_id: + params["category_id"] = category_id + if agent_id: + params["agent_id"] = agent_id + + # 添加服务台 ID(如果已配置) + if self.authentication_config.helpdesk_id: + params["helpdesk_id"] = self.authentication_config.helpdesk_id + + response = requests.get( + url=url, + params=params, + headers=self.__get_headers(), + ) + + response.raise_for_status() + + result = response.json() + if result.get("code") != 0: + raise ProviderException( + f"Failed to list tickets: {result.get('msg')}" + ) + + data = result.get("data", {}) + tickets = data.get("tickets", []) + has_more = data.get("has_more", False) + next_page_token = data.get("page_token", None) + + return { + "tickets": tickets, + "total": len(tickets), + "has_more": has_more, + "page_token": next_page_token + } + except Exception as e: + raise ProviderException(f"Failed to query Feishu Service Desk: {e}") + + +if __name__ == "__main__": + # Output debug messages + import logging + + logging.basicConfig(level=logging.DEBUG, handlers=[logging.StreamHandler()]) + context_manager = ContextManager( + tenant_id="singletenant", + workflow_id="test", + ) + # Load environment variables + import os + + feishu_app_id = os.environ.get("FEISHU_APP_ID") + feishu_app_secret = os.environ.get("FEISHU_APP_SECRET") + feishu_host = os.environ.get("FEISHU_HOST", "https://open.feishu.cn") + + # Initialize the provider and provider config + config = ProviderConfig( + description="Feishu Service Desk Provider", + authentication={ + "app_id": feishu_app_id, + "app_secret": feishu_app_secret, + "host": feishu_host, + }, + ) + provider = FeishuServicedeskProvider( + context_manager, provider_id="feishu_servicedesk", config=config + ) + scopes = provider.validate_scopes() + print(f"Scopes: {scopes}") + + # Example 1: Create ticket + result = provider.notify( + title="测试工单", + description="这是一个测试工单", + ) + print(f"Created ticket: {result}") + + # Example 2: Update ticket + if result.get("ticket", {}).get("ticket_id"): + ticket_id = result["ticket"]["ticket_id"] + update_result = provider.notify( + ticket_id=ticket_id, + status=50, # 已完成 + ) + print(f"Updated ticket: {update_result}") + + # Example 3: Query ticket + if result.get("ticket", {}).get("ticket_id"): + ticket_id = result["ticket"]["ticket_id"] + query_result = provider.query(ticket_id=ticket_id) + print(f"Queried ticket: {query_result}") + From d99f3e9c082a0396dad2f54f4906dd6f9c2430aa Mon Sep 17 00:00:00 2001 From: nctllnty Date: Tue, 11 Nov 2025 10:20:03 +0800 Subject: [PATCH 2/4] Translate Chinese text in documents, code, and comments into English. --- .../feishu-servicedesk-provider-en.mdx | 468 ++-------- .../feishu_servicedesk_provider.py | 813 ++++++++---------- 2 files changed, 460 insertions(+), 821 deletions(-) diff --git a/docs/providers/documentation/feishu-servicedesk-provider-en.mdx b/docs/providers/documentation/feishu-servicedesk-provider-en.mdx index e6b223f2bf..78733dc688 100644 --- a/docs/providers/documentation/feishu-servicedesk-provider-en.mdx +++ b/docs/providers/documentation/feishu-servicedesk-provider-en.mdx @@ -1,154 +1,68 @@ --- -title: "Feishu Service Desk (飞书服务台)" +title: "Feishu Service Desk" sidebarTitle: "Feishu Service Desk" -description: "The Feishu Service Desk provider enables automatic creation and management of service desk tickets from Keep, with email conversion, auto-enrichment, and rich text cards" +description: "How to configure and use the Feishu Service Desk provider in Keep." --- ## Overview -Feishu Service Desk is an enterprise-level service support tool provided by ByteDance's Feishu platform. With Keep's Feishu Service Desk provider, you can: +The Feishu Service Desk provider lets a workflow create, update, and enrich Feishu Service Desk tickets directly from alerts or incidents in Keep. This guide focuses on the exact steps required to configure the integration and use it inside workflows. -- ✅ **Automatically Create Service Desk Tickets** - Create tickets automatically from alerts or incidents -- ✅ **Email Auto-Conversion** - Use email addresses, automatically convert to Feishu User IDs -- ✅ **Intelligent Enrichment** - Automatically add complete event details, Keep links, time information, etc. -- ✅ **Rich Text Cards** - Send formatted message cards to ticket conversations -- ✅ **Agent Assignment** - Automatically assign tickets to specified agents -- ✅ **Update Ticket Status** - Support ticket status synchronization and updates -- ✅ **Custom Fields** - Support service desk custom fields -- ✅ **Incident Support** - Support both Alert and Incident triggers +## Prerequisites -## ✨ Core Features +1. **Create a Feishu app** + - Visit the [Feishu Open Platform](https://open.feishu.cn/app). + - Click **Create Enterprise Self-built App** and fill in the basic information. + - On the **Credentials & Basic Info** page record the **App ID** and **App Secret**. +2. **Configure app permissions** + - Open the app management page, select **Permission Management**, and add the following scopes. + - Required: `helpdesk:ticket`, `helpdesk:ticket:create`, `helpdesk:ticket:update`, `helpdesk:agent`. + - Recommended: `contact:user.base:readonly` (needed for email to user-id conversion). + - Publish the app version and add it to your enterprise. +3. **Collect service desk credentials** + - In the Feishu admin console open **Service Desk > Settings > API Settings**. + - Record the **Helpdesk ID** and **Helpdesk Token**. -### 🎯 Minimal Configuration +## Configuration Parameters -Create tickets with just **3 parameters**, everything else is handled automatically: - -```yaml -with: - title: "{{ alert.name }}" - user_email: "user@example.com" - agent_email: "agent@example.com" -``` - -The Provider automatically: -- Converts emails to Feishu User IDs -- Enriches complete event details (time, status, source, environment, etc.) -- Generates Keep platform links (direct jump to event details) -- Includes original monitoring system links -- Sends rich text cards to ticket conversations - -### 📊 Auto-Enrichment - -Ticket content automatically includes: -- Event name and severity -- Current status (formatted in readable form) -- Time information (last received, first triggered, trigger count) -- Source and environment information -- Keep platform event details page link (clickable) -- Original monitoring system links (e.g., Prometheus, Grafana) -- Associated Incident links -- Event assignee information - -### 🎨 Rich Text Cards - -Formatted rich text cards are automatically sent to ticket conversations, including: -- Structured event information -- Clickable hyperlinks -- Icon and style optimization - -## Authentication - -To use the Feishu Service Desk provider, you need to create a Feishu app and obtain the appropriate credentials. - -### Create a Feishu App - -1. Visit [Feishu Open Platform](https://open.feishu.cn/app) -2. Click "Create Enterprise Self-built App" -3. Fill in the app information and create -4. Get the following from the "Credentials & Basic Info" page: - - **App ID** - - **App Secret** - -### Configure Permissions - -In the Feishu Open Platform app management page, go to "Permission Management" and add the following permissions: - -**Required Permissions**: -- `helpdesk:ticket` - Read ticket information -- `helpdesk:ticket:create` - Create tickets -- `helpdesk:ticket:update` - Update tickets -- `helpdesk:agent` - Read agent information (for agent assignment) - -**Optional Permissions**: -- `contact:user.base:readonly` - Read user information (for email conversion feature) - -After configuring permissions, **remember to publish the app version and add it to the enterprise**. - -### Get Service Desk Credentials - -1. In the Feishu admin backend, go to the "Service Desk" app -2. Click "Settings" > "API Settings" -3. Get the following information: - - **Helpdesk ID** - - **Helpdesk Token** - -### Configuration Parameters - -#### Basic Authentication Parameters +### Required parameters - **Feishu App ID**, obtained from the Feishu Open Platform app details page - - Example: `cli_a1234567890abcde` + Feishu App ID from the Open Platform credentials page. - **Feishu App Secret**, obtained from the Feishu Open Platform app details page - - Example: `xxxxxxxxxxxxxxxxxxxxx` + Feishu App Secret from the Open Platform credentials page. - **Service Desk Token**, obtained from the Feishu Service Desk settings page, required for creating tickets - - Example: `ht-37eda72b-cb1d-140e-7433-b51dae3077f8` + Service Desk token from the Feishu Service Desk API settings page. -#### Optional Parameters +### Optional parameters - **Feishu Server Address** - - - Domestic version: `https://open.feishu.cn` (default) - - International version (Lark): `https://open.larksuite.com` + Feishu API host. Use `https://open.larksuite.com` for the international (Lark) deployment. - **Service Desk ID** (optional), obtained from the Feishu Service Desk settings page - - Example: `7567218981669896211` - - If not provided, the default service desk will be used + Service Desk ID. When omitted the default service desk is used. - **Default User Open ID** (optional), used as the default reporter when creating tickets - - Example: `ou_036f2ff9187e01e440e95a629abaef6c` - - If `user_email` or `open_id` is not specified in the workflow, this value will be used + Default reporter open_id that is used when the workflow does not supply `user_email` or `open_id`. -## Usage in Workflows +## Workflow Usage -### Minimal Mode (Recommended) ⭐ +### Minimal workflow -Just 3 parameters, everything else is automatic: +Create tickets with three parameters; enrichment and rich-card messaging run automatically. ```yaml workflow: id: create-feishu-ticket-simple - description: Minimal configuration - auto-enrichment + description: Minimal configuration triggers: - type: alert filters: @@ -160,27 +74,17 @@ workflow: type: feishu_servicedesk config: "{{ providers.feishu_servicedesk }}" with: - # Only need these 3 lines! title: "{{ alert.name }}" - user_email: "{{ alert.assignee }}" # Use alert assignee email - agent_email: "oncall@example.com" # On-call agent email + user_email: "{{ alert.assignee }}" + agent_email: "oncall@example.com" ``` -**Automatically included content**: -- ✅ Complete event details (time, status, source, environment) -- ✅ Keep platform link (http://localhost:3000/alerts/feed?cel=...) -- ✅ Original monitoring system links (Prometheus, Grafana, etc.) -- ✅ Rich text card format -- ✅ Automatic agent assignment - -### Advanced Configuration - -Use more parameters for fine-grained control: +### Advanced options ```yaml workflow: id: create-feishu-ticket-advanced - description: Advanced configuration example + description: Advanced configuration triggers: - type: alert filters: @@ -192,32 +96,25 @@ workflow: type: feishu_servicedesk config: "{{ providers.feishu_servicedesk }}" with: - # Basic parameters title: "Urgent Alert: {{ alert.name }}" user_email: "user@example.com" agent_email: "agent@example.com" - - # Advanced parameters - priority: 4 # 1-Low, 2-Medium, 3-High, 4-Urgent - tags: ["production", "database"] # Tag list - category_id: "category_123" # Ticket category - description: "Custom description" # Override auto-enrichment - auto_enrich: false # Disable auto-enrichment - - # Custom fields + priority: 4 + tags: ["production", "database"] + category_id: "category_123" + description: "Custom description" + auto_enrich: false customized_fields: - id: "field_12345" value: "Affects all users" ``` -### Incident Triggered - -Support creating tickets from Incidents: +### Creating tickets from incidents ```yaml workflow: id: create-feishu-ticket-from-incident - description: Create ticket from Incident + description: Create ticket from incident context triggers: - type: incident filters: @@ -234,21 +131,12 @@ workflow: agent_email: "sre-team@example.com" ``` -**Incident tickets automatically include**: -- Incident name and severity -- Associated alert count -- Alert source list -- Associated service list -- Incident details link - -### Update Ticket Status - -Automatically update ticket status when alert is resolved: +### Updating an existing ticket ```yaml workflow: id: update-feishu-ticket-on-resolve - description: Update ticket when alert is resolved + description: Update ticket when alert resolves triggers: - type: alert filters: @@ -261,262 +149,94 @@ workflow: config: "{{ providers.feishu_servicedesk }}" with: ticket_id: "{{ alert.ticket_id }}" - status: 50 # Completed - add_comment: "Alert automatically resolved" # Add comment + status: 50 + add_comment: "Alert automatically resolved" ``` -### Using Email Assignment (Recommended) - -Use email addresses, automatically convert to Feishu User IDs: +### Email-based assignment ```yaml with: title: "{{ alert.name }}" - user_email: "reporter@example.com" # Reporter email → open_id - agent_email: "handler@example.com" # Handler agent email → agent_id + user_email: "reporter@example.com" + agent_email: "handler@example.com" ``` -**Advantages**: -- ✅ Easier to remember than `open_id` -- ✅ Automatic conversion, no manual lookup needed -- ✅ Supports dynamic variables (e.g., `{{ alert.assignee }}`) +Using email addresses avoids hard-coding open_id values and works with workflow variables. -## Ticket Status +## Auto-Enrichment Details -Feishu Service Desk supports the following ticket statuses: +When `auto_enrich` is kept at its default value (`true`), the provider builds the ticket description with: -| Status Code | Status Name | Description | -|-------------|-------------|-------------| -| 1 | Pending | Ticket created, waiting to be handled | -| 2 | Processing | Ticket is being processed | -| 3 | Confirming | Ticket processing completed, waiting for confirmation | -| 50 | Completed | Ticket completed and closed | +- Title, severity, status, and timing data (last received, first triggered, trigger count). +- Source, environment, and service metadata. +- Links to the Keep alert or incident page and to the original monitoring system when available. +- Associated incident information and assignee details when present. +- A follow-up rich text message card containing the same structured information. -## Custom Fields +Disable enrichment by setting `auto_enrich: false` and providing your own `description`. -Feishu Service Desk supports custom fields. You can use the `customized_fields` parameter when creating or updating tickets: +## Ticket Status Values -```yaml -actions: - - name: create-ticket-with-custom-fields - provider: - type: feishu_servicedesk - config: "{{ providers.feishu_servicedesk }}" - with: - title: "Alert Ticket" - description: "System anomaly detected" - customized_fields: - - id: "field_12345" - value: "high" - - id: "field_67890" - value: "database" -``` - -## Workflow Parameters - -### Main Parameters - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `title` | string | ✅ Yes | Ticket title | -| `user_email` | string | No | Reporter email (auto-converted to open_id) | -| `agent_email` | string | No | Agent email (auto-converted to agent_id) | - -### Advanced Parameters (via YAML) - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `description` | string | Auto-enriched | Ticket description | -| `priority` | int | - | Priority: 1-Low, 2-Medium, 3-High, 4-Urgent | -| `tags` | list | - | Ticket tags | -| `category_id` | string | - | Ticket category ID | -| `ticket_id` | string | - | Ticket ID (for updating existing ticket) | -| `status` | int | - | Ticket status (for update) | -| `add_comment` | string | - | Add comment (for update) | -| `customized_fields` | list | - | Custom fields | -| `auto_enrich` | bool | true | Enable auto-enrichment | - -## Auto-Enrichment Format - -### For Alerts - -Automatically includes: -- 🔴 Event name -- 📊 Severity -- 🏷️ Current status -- ⏰ Last received time -- 🔥 First triggered time (if available) -- 🔢 Trigger count (if available) -- 📍 Source information -- 🌐 Deployment environment -- ⚙️ Associated service (if available) -- 🔗 Keep event details link -- 🔗 Alert details link (if available) -- 🔗 Monitoring dashboard (if available) -- 🔗 Playbook link (if available) -- 🎯 Associated Incident link (if available) -- 👤 Event assignee (if available) - -### For Incidents - -Automatically includes: -- 🔴 Incident name -- 📊 Severity -- 🏷️ Current status -- 🔍 Associated alert count -- ⏰ Creation time -- ⏰ Start time (if available) -- 📍 Alert sources -- ⚙️ Associated services -- 🔗 Incident details link -- 👤 Incident assignee (if available) - -## Provider Methods - -The provider also exposes several methods for advanced use cases: - -| Method | Description | -|--------|-------------| -| `Get Helpdesks` | Retrieve list of helpdesks | -| `Get Agents` | Retrieve list of agents | -| `Get Users` | Retrieve list of users | -| `Get User By Email` | Get user info by email | -| `Get Ticket Categories` | Retrieve list of ticket categories | -| `Get Ticket Custom Fields` | Retrieve custom field definitions | -| `Add Ticket Comment` | Add comment to a ticket | -| `Assign Ticket` | Assign ticket to an agent | +| Status Code | Status Name | Description | +|-------------|-------------|-------------| +| 1 | Pending | Ticket was created and is waiting to be handled. | +| 2 | Processing | Ticket is being processed. | +| 3 | Confirming | Work is complete and waiting for confirmation. | +| 50 | Completed | Ticket is closed. | ## Troubleshooting -### Authentication Failed - -**Issue**: Unable to connect to Feishu Service Desk +### Authentication fails -**Solution**: -1. Check if App ID and App Secret are correct -2. Confirm the app is published and added to the enterprise -3. Verify app permission configuration is correct -4. Ensure Helpdesk Token is correctly configured +1. Confirm the App ID and App Secret are correct. +2. Ensure the app is published and installed in the enterprise. +3. Verify all required permissions are enabled. +4. Double-check the Helpdesk Token value. -### Ticket Creation Failed +### Ticket creation fails -**Issue**: Ticket creation returns an error +1. Confirm the app includes `helpdesk:ticket:create`. +2. Verify the Helpdesk ID is valid (when provided). +3. Check that any custom fields match the Service Desk configuration. +4. Provide either `user_email`, `open_id`, or `default_open_id`. -**Solution**: -1. Confirm `helpdesk:ticket:create` permission is granted -2. Check if helpdesk_id is correct (if provided) -3. Verify custom field format matches service desk configuration -4. Ensure `user_email` or `open_id` or `default_open_id` is provided +### Email conversion fails -### Email Conversion Failed +1. Enable the `contact:user.base:readonly` permission. +2. Confirm the email address exists in the Feishu organization. +3. Validate the email address format. +4. Use an explicit `open_id` as a fallback. -**Issue**: Email to User ID conversion fails +### Rich text card is missing -**Solution**: -1. Ensure `contact:user.base:readonly` permission is granted -2. Verify the email address exists in the Feishu organization -3. Check if the email format is correct -4. Try using `open_id` directly as a fallback - -### Rich Card Not Displayed - -**Issue**: Rich text card not showing in ticket - -**Solution**: -1. Check if the ticket was created successfully -2. Verify Helpdesk Token is correct -3. Ensure the app has message sending permissions -4. The card is sent as a separate message after ticket creation - -### International Version Users (Lark) - -If you're using the international version of Feishu (Lark), please set the `host` parameter to `https://open.larksuite.com` +1. Confirm the ticket was created successfully. +2. Make sure the Helpdesk Token is correct. +3. Ensure the app has permission to send messages. +4. Remember that the card is sent as a separate API call immediately after creation. ## Useful Links - View complete Feishu Open Platform documentation + Full Feishu Open Platform reference. - - View detailed Service Desk API documentation + + Detailed Service Desk API specification. - - Visit Feishu Open Platform to create an app + + Manage Feishu apps and obtain credentials. - - For Lark (international version) users + + Documentation for the Lark deployment. -## Best Practices - -### 1. Use Email-based Assignment - -```yaml -# ✅ Recommended -with: - user_email: "{{ alert.assignee }}" - agent_email: "oncall@example.com" - -# ❌ Not recommended -with: - open_id: "ou_xxx..." # Hard to remember - agent_id: "ou_yyy..." # Hard to maintain -``` - -### 2. Let Auto-Enrichment Work - -```yaml -# ✅ Recommended - minimal config, full details -with: - title: "{{ alert.name }}" - user_email: "user@example.com" - agent_email: "agent@example.com" - -# ❌ Not recommended - manual template, error-prone -with: - title: "{{ alert.name }}" - description: | - Event: {{ alert.name }} - Severity: {{ alert.severity }} - ... (50+ lines of manual template) -``` - -### 3. Use Priority for Critical Alerts - -```yaml -with: - title: "{{ alert.name }}" - user_email: "{{ alert.assignee }}" - agent_email: "oncall@example.com" - priority: 4 # Urgent for critical alerts -``` - -### 4. Use Tags for Better Organization - -```yaml -with: - title: "{{ alert.name }}" - user_email: "{{ alert.assignee }}" - agent_email: "oncall@example.com" - tags: ["{{ alert.environment }}", "{{ alert.service }}"] -``` - -## Support - -If you encounter issues using the Feishu Service Desk provider, please: - -1. Review the troubleshooting section above -2. Visit Feishu Open Platform documentation -3. Submit an issue in Keep's GitHub repository -4. Check Keep's documentation at https://docs.keephq.dev/ - ## Examples -Check the `examples/workflows/` directory in the Keep repository for more workflow examples: -- `feishu_servicedesk_simple.yml` - Minimal configuration example -- `feishu_servicedesk_best_practice.yml` - Best practices guide -- `feishu_servicedesk_with_email.yml` - Email conversion example +Additional workflow samples are available in `examples/workflows/`: +- `feishu_servicedesk_simple.yml` +- `feishu_servicedesk_best_practice.yml` +- `feishu_servicedesk_with_email.yml` diff --git a/keep/providers/feishu_servicedesk_provider/feishu_servicedesk_provider.py b/keep/providers/feishu_servicedesk_provider/feishu_servicedesk_provider.py index 46951e8418..9ffe8aa797 100644 --- a/keep/providers/feishu_servicedesk_provider/feishu_servicedesk_provider.py +++ b/keep/providers/feishu_servicedesk_provider/feishu_servicedesk_provider.py @@ -26,7 +26,7 @@ class FeishuServicedeskProviderAuthConfig: app_id: str = dataclasses.field( metadata={ "required": True, - "description": "飞书应用 ID (Feishu App ID)", + "description": "Feishu App ID", "sensitive": False, "documentation_url": "https://open.feishu.cn/document/ukTMukTMukTM/ukDNz4SO0MjL5QzM/auth-v3/auth/tenant_access_token_internal", } @@ -35,7 +35,7 @@ class FeishuServicedeskProviderAuthConfig: app_secret: str = dataclasses.field( metadata={ "required": True, - "description": "飞书应用密钥 (Feishu App Secret)", + "description": "Feishu App Secret", "sensitive": True, "documentation_url": "https://open.feishu.cn/document/ukTMukTMukTM/ukDNz4SO0MjL5QzM/auth-v3/auth/tenant_access_token_internal", } @@ -44,7 +44,7 @@ class FeishuServicedeskProviderAuthConfig: host: HttpsUrl = dataclasses.field( metadata={ "required": False, - "description": "飞书服务器地址 (Feishu Server Host)", + "description": "Feishu server host", "sensitive": False, "hint": "https://open.feishu.cn", "validation": "https_url", @@ -55,7 +55,7 @@ class FeishuServicedeskProviderAuthConfig: helpdesk_id: str = dataclasses.field( metadata={ "required": False, - "description": "服务台 ID (Helpdesk ID), 如不提供则使用默认服务台", + "description": "Helpdesk ID. Leave empty to use the default helpdesk.", "sensitive": False, "hint": "Leave empty to use default helpdesk", }, @@ -65,7 +65,7 @@ class FeishuServicedeskProviderAuthConfig: helpdesk_token: str = dataclasses.field( metadata={ "required": True, - "description": "服务台 Token (Helpdesk Token), 创建工单必需", + "description": "Helpdesk token required for creating tickets.", "sensitive": True, "hint": "Required for creating tickets. Get from Feishu Service Desk settings", }, @@ -75,7 +75,7 @@ class FeishuServicedeskProviderAuthConfig: default_open_id: str = dataclasses.field( metadata={ "required": False, - "description": "默认用户 Open ID, 创建工单时如未指定则使用此ID", + "description": "Default user Open ID used when creating tickets if not specified.", "sensitive": False, "hint": "Default user open_id for creating tickets", }, @@ -86,37 +86,37 @@ class FeishuServicedeskProviderAuthConfig: class FeishuServicedeskProvider(BaseProvider): """Enrich alerts with Feishu Service Desk tickets.""" - OAUTH2_URL = None # 飞书服务台不使用 OAuth2 认证 + OAUTH2_URL = None # Feishu Service Desk does not use OAuth2 authentication PROVIDER_CATEGORY = ["Ticketing"] PROVIDER_SCOPES = [ ProviderScope( name="helpdesk:ticket", - description="工单读取权限 (Read Tickets)", + description="Permission to read tickets", mandatory=True, alias="Read tickets", ), ProviderScope( name="helpdesk:ticket:create", - description="工单创建权限 (Create Tickets)", + description="Permission to create tickets", mandatory=True, alias="Create tickets", ), ProviderScope( name="helpdesk:ticket:update", - description="工单更新权限 (Update Tickets)", + description="Permission to update tickets", mandatory=False, alias="Update tickets", ), ProviderScope( name="helpdesk:agent", - description="客服信息读取权限 (Read Agent Info)", + description="Permission to read agent information", mandatory=False, alias="Read agents", ), ProviderScope( name="contact:user.base:readonly", - description="用户信息读取权限 (Read User Info)", + description="Permission to read user information", mandatory=False, alias="Read user info", ), @@ -125,7 +125,7 @@ class FeishuServicedeskProvider(BaseProvider): PROVIDER_METHODS = [] PROVIDER_TAGS = ["ticketing"] - PROVIDER_DISPLAY_NAME = "飞书服务台 (Feishu Service Desk)" + PROVIDER_DISPLAY_NAME = "Feishu Service Desk" def __init__( self, context_manager: ContextManager, provider_id: str, config: ProviderConfig @@ -136,12 +136,9 @@ def __init__( self._token_expiry = None def validate_scopes(self): - """ - 验证 provider 是否具有所需的权限。 - Validate that the provider has the required scopes. - """ + """Validate that the provider has the required scopes.""" try: - # 尝试获取 access token 来验证凭据 + # Attempt to obtain an access token to validate the credentials access_token = self.__get_access_token() if not access_token: scopes = { @@ -150,8 +147,8 @@ def validate_scopes(self): } return scopes - # 如果成功获取 token,返回所有权限为 True - # Note: 飞书的权限验证在创建应用时配置,这里简化验证逻辑 + # If the token was obtained successfully, mark all scopes as granted + # Note: Feishu permissions are configured when the app is created, so this validation is simplified scopes = { scope.name: True for scope in FeishuServicedeskProvider.PROVIDER_SCOPES @@ -187,12 +184,9 @@ def dispose(self): pass def __get_access_token(self) -> str: - """ - 获取飞书 tenant_access_token - Get Feishu tenant access token. - """ + """Retrieve the Feishu tenant access token.""" try: - # 检查 token 是否还有效 + # Reuse the cached token if it is still valid import datetime if self._access_token and self._token_expiry: if datetime.datetime.now() < self._token_expiry: @@ -218,7 +212,7 @@ def __get_access_token(self) -> str: ) self._access_token = result.get("tenant_access_token") - # 设置 token 过期时间(提前 5 分钟过期) + # Set the token expiration time (expire five minutes earlier than the official TTL) expire_seconds = result.get("expire", 7200) - 300 self._token_expiry = datetime.datetime.now() + datetime.timedelta( seconds=expire_seconds @@ -233,10 +227,10 @@ def __get_headers(self, use_helpdesk_auth: bool = False): Helper method to build the headers for Feishu API requests. Args: - use_helpdesk_auth (bool): 如果为True且配置了helpdesk_token, - 同时发送服务台特殊认证头 - - Note: 服务台API需要同时发送两个认证头: + use_helpdesk_auth (bool): When True and a helpdesk_token is configured, + include the additional helpdesk authentication header. + + Note: Helpdesk APIs require two authentication headers: 1. Authorization: Bearer {tenant_access_token} 2. X-Lark-Helpdesk-Authorization: base64(helpdesk_id:helpdesk_token) """ @@ -244,11 +238,11 @@ def __get_headers(self, use_helpdesk_auth: bool = False): "Content-Type": "application/json; charset=utf-8", } - # 总是添加标准的 tenant_access_token 认证 + # Always add the standard tenant_access_token authentication access_token = self.__get_access_token() headers["Authorization"] = f"Bearer {access_token}" - # 如果需要服务台特殊认证,同时添加服务台认证头 + # Add the helpdesk-specific authentication header when requested if (use_helpdesk_auth and self.authentication_config.helpdesk_id and self.authentication_config.helpdesk_token): @@ -279,42 +273,40 @@ def __create_ticket( **kwargs: dict, ): """ - 创建飞书服务台工单(启动人工服务) - Helper method to create a ticket in Feishu Service Desk. - - Note: 飞书服务台使用 StartServiceTicket API (启动人工服务) - 需要 helpdesk_token 和特殊的认证头 + Helper method to create a ticket in Feishu Service Desk (start human service). + + Note: The StartServiceTicket API requires a helpdesk token and the + special helpdesk authentication header. """ try: self.logger.info("Creating a ticket in Feishu Service Desk...") - # 飞书服务台API:启动人工服务 + # Feishu Service Desk API: start human service url = self.__get_url("/open-apis/helpdesk/v1/start_service") - # 🆕 直接使用enriched描述作为customized_info - # 不再使用简化格式,因为后续的消息/评论API都不可用 - # customized_info会作为首条消息显示在服务台对话中 + # Use the enriched description as customized_info so that the first + # message in the service desk conversation contains full context. if description: ticket_content = description else: - # 如果没有description,使用简单格式 - ticket_content = f"【工单标题】{title}\n\n请查看Keep平台获取详细信息" + # Fall back to a lightweight template when no description is supplied + ticket_content = f"[Ticket Title] {title}\n\nVisit the Keep platform for more details." - # 如果有额外信息,添加到内容末尾 + # Append optional metadata when provided if category_id: - ticket_content += f"\n\n【分类ID】{category_id}" + ticket_content += f"\n\n[Category ID] {category_id}" if priority: - ticket_content += f"\n【优先级】{priority}" + ticket_content += f"\n[Priority] {priority}" if tags: - ticket_content += f"\n【标签】{', '.join(tags)}" + ticket_content += f"\n[Tags] {', '.join(tags)}" - # 构建请求体(符合飞书API格式) + # Build the request payload using the Feishu API schema ticket_data = { - "human_service": True, # 启用人工服务 - "customized_info": ticket_content, # 完整的enriched内容 + "human_service": True, # Enable human service + "customized_info": ticket_content, # Include the enriched content } - # 添加用户open_id(必需) + # An open_id is required for the request if open_id: ticket_data["open_id"] = open_id elif kwargs.get("open_id"): @@ -323,36 +315,35 @@ def __create_ticket( ticket_data["open_id"] = self.authentication_config.default_open_id self.logger.info(f"Using default open_id: {self.authentication_config.default_open_id}") else: - # open_id是必需的 raise ProviderException( "open_id is required to create a ticket. " "Please provide open_id parameter or set default_open_id in configuration." ) - # 添加指定客服(可选) + # Assign specific agents when supplied if agent_id: ticket_data["appointed_agents"] = [agent_id] - # 记录请求信息(用于调试) + # Log the request for debugging purposes self.logger.info(f"Creating ticket with URL: {url}") self.logger.info(f"Request data: {json.dumps(ticket_data, ensure_ascii=False)}") - # 使用服务台特殊认证 + # Use the helpdesk-specific authentication header response = requests.post( url=url, json=ticket_data, headers=self.__get_headers(use_helpdesk_auth=True), ) - # 记录响应状态和内容(用于调试) + # Log the response diagnostics self.logger.info(f"Response status: {response.status_code}") self.logger.info(f"Response headers: {dict(response.headers)}") - # 先获取原始文本,以便调试 + # Capture the raw text for easier troubleshooting response_text = response.text self.logger.info(f"Response text (first 500 chars): {response_text[:500]}") - # 尝试解析JSON + # Parse the JSON response try: result = json.loads(response_text) except json.JSONDecodeError as e: @@ -363,7 +354,7 @@ def __create_ticket( f"Response: {response_text[:200]}" ) - # 检查HTTP状态码 + # Raise for HTTP errors try: response.raise_for_status() except Exception as e: @@ -374,7 +365,7 @@ def __create_ticket( f"Failed to create a ticket. HTTP {response.status_code}: {result}" ) - # 检查飞书API返回的code + # Validate the Feishu API response if result.get("code") != 0: error_msg = result.get("msg", "Unknown error") self.logger.error(f"Feishu API returned error code {result.get('code')}: {error_msg}") @@ -384,13 +375,12 @@ def __create_ticket( self.logger.info("Created a ticket in Feishu Service Desk!") - # 返回完整信息供后续使用 + # Return the full payload for downstream processing ticket_data = result.get("data", {}) ticket_id = ticket_data.get("ticket_id") chat_id = ticket_data.get("chat_id") - # 🆕 使用正确的服务台消息API发送详细描述 - # API: POST /open-apis/helpdesk/v1/tickets/{ticket_id}/messages + # Send the detailed description via the service desk messaging API when needed if ticket_id and description and len(description) > 200: try: success = self.__send_ticket_message(ticket_id, description) @@ -400,7 +390,7 @@ def __create_ticket( self.logger.warning("⚠️ Failed to send message, but ticket created successfully") self.logger.info("Enriched content is in customized_info") except Exception as e: - # 发送失败不影响工单创建 + # Failure to send the follow-up message does not invalidate ticket creation self.logger.warning(f"Failed to send ticket message: {e}") self.logger.info("Enriched content is in customized_info") else: @@ -410,7 +400,7 @@ def __create_ticket( "ticket": ticket_data, "ticket_id": ticket_id, "chat_id": chat_id, - # 这些信息可以保存到Keep的alert/incident中,用于后续同步 + # These identifiers allow Keep alerts/incidents to remain in sync with Feishu "feishu_ticket_id": ticket_id, "feishu_chat_id": chat_id, } @@ -419,14 +409,13 @@ def __create_ticket( def __build_rich_card_content(self, enriched_text: str) -> list: """ - 将enriched文本转换为飞书富文本卡片格式 - Convert enriched text to Feishu rich text card format with clickable links. - + Convert enriched text to the Feishu rich text card format with clickable links. + Args: - enriched_text: Enriched描述文本 - + enriched_text: Enriched description text. + Returns: - list: 飞书post格式的content数组 + list: Content array compatible with the Feishu post schema. """ content_lines = [] @@ -437,32 +426,39 @@ def __build_rich_card_content(self, enriched_text: str) -> list: line = lines[i].strip() i += 1 - # 跳过空行和分隔线 + # Skip empty lines and separators if not line or line.startswith('━'): continue - # 检测URL行(下一行是链接) + # Detect lines where the next line contains a URL if i < len(lines) and (lines[i].strip().startswith('http://') or lines[i].strip().startswith('https://')): - # 当前行是描述,下一行是URL + # Current line is the label, next line is the URL label = line url = lines[i].strip() i += 1 - # 根据标签选择合适的显示文本 - if '告警详情' in label or 'alert-his-events' in url or 'nalert' in url: - link_text = "🔔 查看告警详情" - elif 'Keep事件详情' in label: - link_text = "📱 查看Keep事件" - elif 'Incident' in label: - link_text = "🎯 查看Incident" - elif '生成器' in label or 'generator' in label.lower(): - link_text = "⚙️ 打开生成器" - elif '运行手册' in label or 'playbook' in label.lower(): - link_text = "📖 查看手册" + label_lower = label.lower() + url_lower = url.lower() + + # Determine an appropriate anchor label based on the description + if ( + "alert" in label_lower and "detail" in label_lower + or "alert-his-events" in url_lower + or "nalert" in url_lower + ): + link_text = "🔔 View Alert Details" + elif "keep" in label_lower and "event" in label_lower: + link_text = "📱 View Keep Event" + elif "incident" in label_lower: + link_text = "🎯 View Incident" + elif "generator" in label_lower: + link_text = "⚙️ Open Generator" + elif "playbook" in label_lower or "runbook" in label_lower: + link_text = "📖 View Playbook" else: - link_text = "🔗 点击打开" + link_text = "🔗 Open Link" - # 创建可点击的超链接 + # Build a clickable hyperlink segment content_lines.append([ { "tag": "text", @@ -474,26 +470,26 @@ def __build_rich_card_content(self, enriched_text: str) -> list: "href": url } ]) - # 检测直接的URL行 + # Detect lines that are URLs without labels elif line.startswith('http://') or line.startswith('https://'): - # 根据URL类型设置友好文本 + # Choose a friendly caption based on the URL if 'alerts/feed' in line: - link_text = "📱 点击查看Keep事件详情" + link_text = "📱 View Keep Event Details" elif '/incidents/' in line: - link_text = "🎯 点击查看Incident详情" + link_text = "🎯 View Incident Details" elif 'alert-his-events' in line or 'nalert' in line: - link_text = "🔔 查看告警详情" + link_text = "🔔 View Alert Details" elif 'prometheus' in line or 'grafana' in line: - link_text = "📊 打开监控系统" + link_text = "📊 Open Monitoring Dashboard" else: - link_text = "🔗 点击打开链接" + link_text = "🔗 Open Link" content_lines.append([{ "tag": "a", "text": link_text, "href": line }]) - # 章节标题(包含emoji或特殊字符) + # Section headers containing emojis or special characters elif any(emoji in line for emoji in ['📋', '🔗', '📍', '🔍', '⚠️', '📝']): content_lines.append([{ "tag": "text", @@ -501,14 +497,14 @@ def __build_rich_card_content(self, enriched_text: str) -> list: "un_escape": True }]) else: - # 普通文本行 + # Regular text lines if line: content_lines.append([{ "tag": "text", "text": line }]) - - # 如果没有解析出内容,使用原始文本 + + # Fallback to the raw text when no content blocks were generated if not content_lines: content_lines = [[{ "tag": "text", @@ -519,26 +515,25 @@ def __build_rich_card_content(self, enriched_text: str) -> list: def __send_ticket_message(self, ticket_id: str, content: str): """ - 向工单发送消息(使用飞书服务台专用消息API) - Send a message to helpdesk ticket. - + Send a message to a helpdesk ticket using the Feishu Service Desk message API. + Args: - ticket_id: Ticket ID - content: 消息内容(enriched描述) - + ticket_id: Ticket ID. + content: Message body (typically the enriched description). + Returns: - bool: 是否发送成功 - + bool: True when the message is sent successfully. + API: POST /open-apis/helpdesk/v1/tickets/{ticket_id}/messages """ try: self.logger.info(f"Sending rich card message to ticket {ticket_id}...") - # 飞书服务台消息API + # Feishu Service Desk message API endpoint url = self.__get_url(f"/open-apis/helpdesk/v1/tickets/{ticket_id}/messages") - # 🎨 构建富文本卡片格式 - # 参考:https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/helpdesk-v1/ticket-message/create + # Build the rich text card payload + # Reference: https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/helpdesk-v1/ticket-message/create card_content = self.__build_rich_card_content(content) message_data = { @@ -546,7 +541,7 @@ def __send_ticket_message(self, ticket_id: str, content: str): "content": { "post": { "zh_cn": { - "title": "📋 事件详细信息", + "title": "📋 Incident Details", "content": card_content } } @@ -555,16 +550,16 @@ def __send_ticket_message(self, ticket_id: str, content: str): self.logger.info(f"Sending ticket message to URL: {url}") - # 🔧 服务台消息API需要双认证 + # The service desk messaging API requires both authentication headers response = requests.post( url=url, json=message_data, - headers=self.__get_headers(use_helpdesk_auth=True), # ← 关键:使用服务台认证 + headers=self.__get_headers(use_helpdesk_auth=True), # Ensure helpdesk authentication is supplied ) self.logger.info(f"Ticket message response: {response.status_code}") - # 尝试解析响应 + # Attempt to parse the response payload try: result = response.json() self.logger.info(f"Response: {result}") @@ -595,10 +590,7 @@ def __update_ticket( customized_fields: List[dict] = None, **kwargs: dict, ): - """ - 更新飞书服务台工单 - Helper method to update a ticket in Feishu Service Desk. - """ + """Helper method to update a ticket in Feishu Service Desk.""" try: self.logger.info(f"Updating ticket {ticket_id} in Feishu Service Desk...") @@ -606,11 +598,11 @@ def __update_ticket( update_data = {} - # 更新工单状态 + # Update ticket status if status is not None: update_data["status"] = status - # 更新自定义字段 + # Update custom fields if customized_fields: update_data["customized_fields"] = customized_fields @@ -620,12 +612,12 @@ def __update_ticket( headers=self.__get_headers(), ) - # 记录响应(调试用) + # Log the response for debugging self.logger.info(f"Update response status: {response.status_code}") response_text = response.text self.logger.info(f"Update response text: {response_text[:500]}") - # 解析响应 + # Parse the response body try: result = json.loads(response_text) except json.JSONDecodeError as e: @@ -636,7 +628,7 @@ def __update_ticket( f"Response: {response_text[:200]}" ) - # 检查HTTP状态码 + # Propagate HTTP errors try: response.raise_for_status() except Exception as e: @@ -648,7 +640,7 @@ def __update_ticket( f"Failed to update a ticket. HTTP {response.status_code}: {result}" ) - # 检查飞书API返回码 + # Validate the Feishu API response payload if result.get("code") != 0: error_msg = result.get("msg", "Unknown error") self.logger.error(f"Feishu API update error: code={result.get('code')}, msg={error_msg}") @@ -665,42 +657,42 @@ def __update_ticket( def __get_ticket(self, ticket_id: str): """ - 获取工单详情 - Helper method to get ticket details. - - Note: 飞书服务台的查询工单API也需要服务台特殊认证 + Helper method to retrieve ticket details. + + Note: The Feishu Service Desk ticket detail API also requires the + helpdesk-specific authentication header. """ try: self.logger.info(f"Fetching ticket {ticket_id} from Feishu Service Desk...") url = self.__get_url(f"/open-apis/helpdesk/v1/tickets/{ticket_id}") - # 使用服务台特殊认证 + # Use the helpdesk-specific authentication header response = requests.get( url=url, headers=self.__get_headers(use_helpdesk_auth=True), ) - # 记录响应(调试用) + # Log the response for debugging self.logger.info(f"Get ticket response status: {response.status_code}") response_text = response.text self.logger.info(f"Get ticket response: {response_text[:500]}") - # 解析响应 + # Parse the response body try: result = json.loads(response_text) except json.JSONDecodeError as e: self.logger.error(f"Failed to parse get ticket response: {e}") - # 如果无法获取工单详情,返回基本信息 + # Return minimal information when full details are unavailable self.logger.warning("Could not fetch ticket details, using minimal info") return { "ticket_id": ticket_id, "ticket_url": f"{self.feishu_host}/helpdesk/ticket/{ticket_id}" } - # 检查状态码 + # Gracefully handle authorization and missing resources if response.status_code == 401 or response.status_code == 404: - # 查询API可能不可用,返回基本信息 + # The lookup API may be unavailable; return minimal information self.logger.warning(f"Ticket detail API returned {response.status_code}, using basic info") return { "ticket_id": ticket_id, @@ -711,7 +703,7 @@ def __get_ticket(self, ticket_id: str): if result.get("code") != 0: self.logger.warning(f"Failed to get ticket details: {result.get('msg')}") - # 返回基本信息而不是抛出异常 + # Return minimal information rather than raising an exception return { "ticket_id": ticket_id, "ticket_url": f"{self.feishu_host}/helpdesk/ticket/{ticket_id}" @@ -720,7 +712,7 @@ def __get_ticket(self, ticket_id: str): self.logger.info("Fetched ticket from Feishu Service Desk!") return result.get("data", {}) except Exception as e: - # 如果获取工单详情失败,返回基本信息而不是失败 + # Fall back to minimal information when the API call fails self.logger.warning(f"Could not fetch ticket details: {e}, returning basic info") return { "ticket_id": ticket_id, @@ -731,14 +723,13 @@ def __get_ticket(self, ticket_id: str): def get_helpdesks(self) -> Dict[str, Any]: """ - 获取服务台列表 - Get list of helpdesks (for frontend dropdown). - + Retrieve the list of helpdesks (used for frontend dropdowns). + Returns: - dict: List of helpdesks with their IDs and names - - Note: ⚠️ 此API端点需要验证是否存在。 - 如果失败,可能需要调整端点路径或使用其他方式获取服务台列表。 + dict: Helpdesk metadata, including IDs and names. + + Note: ⚠️ This endpoint may vary between tenants. If the call fails, + adjust the endpoint path or fetch the data via an alternative API. """ try: self.logger.info("Fetching helpdesks list...") @@ -760,7 +751,7 @@ def get_helpdesks(self) -> Dict[str, Any]: helpdesks = result.get("data", {}).get("helpdesks", []) - # 格式化返回数据,方便前端使用 + # Normalize the data for client consumption formatted_helpdesks = [ { "id": helpdesk.get("id"), @@ -781,24 +772,23 @@ def get_helpdesks(self) -> Dict[str, Any]: def get_agents(self, helpdesk_id: Optional[str] = None) -> Dict[str, Any]: """ - 获取服务台客服列表 - Get list of agents (for frontend dropdown). - + Retrieve the list of helpdesk agents (used for frontend dropdowns). + Args: - helpdesk_id (str): Helpdesk ID (optional, uses configured helpdesk_id if not provided) - + helpdesk_id (str): Helpdesk ID (optional — defaults to the configured helpdesk). + Returns: - dict: List of agents with their IDs and names - - Note: ⚠️ 此API可能需要特殊认证或使用不同端点。 - 如果失败,尝试: - 1. 使用 use_helpdesk_auth=True 启用服务台特殊认证 - 2. 或使用通讯录API获取用户信息 + dict: Agent metadata, including IDs and names. + + Note: ⚠️ This API may require helpdesk authentication or an alternative endpoint. + If it fails, try: + 1. Calling with use_helpdesk_auth=True. + 2. Falling back to the contact API to obtain user information. """ try: helpdesk_id = helpdesk_id or self.authentication_config.helpdesk_id if not helpdesk_id: - # 如果没有指定服务台ID,获取第一个服务台 + # If no helpdesk ID is supplied, fall back to the first available helpdesk helpdesks = self.get_helpdesks() if helpdesks.get("helpdesks"): helpdesk_id = helpdesks["helpdesks"][0]["id"] @@ -826,13 +816,13 @@ def get_agents(self, helpdesk_id: Optional[str] = None) -> Dict[str, Any]: agents = result.get("data", {}).get("agents", []) - # 格式化返回数据 + # Normalize the response items formatted_agents = [ { "id": agent.get("user_id"), "name": agent.get("name"), "email": agent.get("email"), - "status": agent.get("status"), # 1: 在线, 2: 离线, 3: 忙碌 + "status": agent.get("status"), # 1: online, 2: offline, 3: busy } for agent in agents ] @@ -848,8 +838,7 @@ def get_agents(self, helpdesk_id: Optional[str] = None) -> Dict[str, Any]: def get_ticket_categories(self, helpdesk_id: Optional[str] = None) -> Dict[str, Any]: """ - 获取工单分类列表 - Get list of ticket categories (for frontend dropdown). + Retrieve ticket categories (used for frontend dropdowns). Args: helpdesk_id (str): Helpdesk ID (optional) @@ -883,7 +872,7 @@ def get_ticket_categories(self, helpdesk_id: Optional[str] = None) -> Dict[str, categories = result.get("data", {}).get("categories", []) - # 格式化返回数据 + # Normalize the result for client consumption formatted_categories = [ { "id": category.get("category_id"), @@ -904,8 +893,7 @@ def get_ticket_categories(self, helpdesk_id: Optional[str] = None) -> Dict[str, def get_ticket_custom_fields(self, helpdesk_id: Optional[str] = None) -> Dict[str, Any]: """ - 获取工单自定义字段配置 - Get ticket custom fields configuration (for frontend form). + Retrieve ticket custom field definitions (used to build frontend forms). Args: helpdesk_id (str): Helpdesk ID (optional) @@ -939,7 +927,7 @@ def get_ticket_custom_fields(self, helpdesk_id: Optional[str] = None) -> Dict[st fields = result.get("data", {}).get("customized_fields", []) - # 格式化返回数据 + # Normalize the result for client consumption formatted_fields = [ { "id": field.get("field_id"), @@ -961,28 +949,26 @@ def get_ticket_custom_fields(self, helpdesk_id: Optional[str] = None) -> Dict[st raise ProviderException(f"Failed to get custom fields: {e}") def add_ticket_comment( - self, - ticket_id: str, + self, + ticket_id: str, content: str, - comment_type: int = 1 # 1: 文本, 2: 富文本 + comment_type: int = 1 # 1: plain text, 2: rich text ) -> Dict[str, Any]: """ - 添加工单评论 - Add comment to a ticket. - + Add a comment to a ticket. + Args: - ticket_id (str): Ticket ID - content (str): Comment content - comment_type (int): Comment type (1: plain text, 2: rich text) - + ticket_id (str): Ticket ID. + content (str): Comment body. + comment_type (int): Comment type (1: plain text, 2: rich text). + Returns: - dict: Comment result - - Note: ⚠️ 此API端点需要验证。 - 评论功能可能需要: - 1. 不同的API端点 - 2. 使用飞书消息API - 3. 不同的参数格式(msg_type字段名) + dict: Comment payload returned by Feishu. + + Note: ⚠️ This endpoint may differ between tenants. If the call fails: + 1. Verify whether another endpoint should be used. + 2. Consider sending a Service Desk message instead. + 3. Confirm whether the payload requires alternative field names (for example, msg_type). """ try: self.logger.info(f"Adding comment to ticket {ticket_id}...") @@ -1019,26 +1005,25 @@ def add_ticket_comment( raise ProviderException(f"Failed to add comment: {e}") def assign_ticket( - self, - ticket_id: str, + self, + ticket_id: str, agent_id: str, comment: Optional[str] = None ) -> Dict[str, Any]: """ - 分配工单给指定客服 - Assign ticket to a specific agent. - + Assign a ticket to a specific agent. + Args: - ticket_id (str): Ticket ID - agent_id (str): Agent user ID - comment (str): Optional comment for the assignment - + ticket_id (str): Ticket ID. + agent_id (str): Agent user ID. + comment (str): Optional comment to include in the notification. + Returns: - dict: Assignment result - - Note: ⚠️ 飞书服务台不支持后续分配API(返回404) - 建议在创建工单时通过appointed_agents参数指定客服 - 此方法保留以供兼容性,但可能不可用 + dict: Result of the assignment attempt. + + Note: ⚠️ Feishu Service Desk does not currently expose an assignment API (returns 404). + Prefer specifying appointed_agents during ticket creation. This method provides + a best-effort notification for compatibility. """ try: self.logger.warning( @@ -1047,13 +1032,12 @@ def assign_ticket( ) self.logger.info(f"Attempting to assign ticket {ticket_id} to agent {agent_id}...") - # 尝试通过发送消息通知客服 - # 因为直接的分配API不可用 - message = f"@{agent_id} 此工单已分配给你处理" + # Notify the agent via ticket messages because the dedicated assignment API is unavailable + message = f"@{agent_id} This ticket has been assigned to you." if comment: - message += f"\n备注:{comment}" + message += f"\nNote: {comment}" - # 使用消息API通知(作为替代方案) + # Send the message as an alternative assignment workflow success = self.__send_ticket_message(ticket_id, message) if success: @@ -1075,7 +1059,7 @@ def assign_ticket( except Exception as e: self.logger.warning(f"Failed to assign ticket: {e}") - # 不抛出异常,因为工单已创建成功 + # Do not raise an exception because the ticket was already created successfully return { "success": False, "ticket_id": ticket_id, @@ -1085,38 +1069,37 @@ def assign_ticket( def get_user_by_email(self, email: str) -> Dict[str, Any]: """ - 通过邮箱获取用户信息(包括open_id) - Get user information by email. - + Retrieve user information (including open_id) by email address. + Args: - email (str): 用户邮箱 - + email (str): User email. + Returns: - dict: 用户信息,包含open_id - - Note: 用于在工作流中通过邮箱自动获取open_id + dict: User information containing the open_id. + + Note: Used by workflows to automatically resolve open_id from email. """ try: self.logger.info(f"Getting user info for email: {email}") - # 飞书通讯录API:批量获取用户信息 - # 参考:https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/contact-v3/user/batch_get_id + # Feishu Contact API: batch get user information + # Reference: https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/contact-v3/user/batch_get_id url = self.__get_url("/open-apis/contact/v3/users/batch_get_id") - # 🔧 使用POST请求,emails放在请求体中,格式为数组 + # Use POST request with the email list in the body params = { - "user_id_type": "open_id" # 返回open_id格式 + "user_id_type": "open_id" # Response should include open_id } body = { - "emails": [email], # 数组格式 - "include_resigned": False # 不包括离职用户 + "emails": [email], + "include_resigned": False } self.logger.info(f"Request URL: {url}") self.logger.info(f"Request body: {json.dumps(body, ensure_ascii=False)}") - response = requests.post( # ← POST而不是GET + response = requests.post( url=url, params=params, json=body, @@ -1125,7 +1108,7 @@ def get_user_by_email(self, email: str) -> Dict[str, Any]: self.logger.info(f"Response status: {response.status_code}") - # 解析响应 + # Parse the response body try: result = response.json() self.logger.info(f"Response: {result}") @@ -1140,20 +1123,20 @@ def get_user_by_email(self, email: str) -> Dict[str, Any]: f"Failed to get user by email: {result.get('msg')} (code: {result.get('code')})" ) - # 提取user_list + # Extract the list of matched users user_list = result.get("data", {}).get("user_list", []) if not user_list: raise ProviderException(f"User not found for email: {email}") - # 提取第一个匹配的用户 + # Use the first matched user user_info = user_list[0] user_id = user_info.get("user_id") self.logger.info(f"✅ Found user for {email}: {user_id}") return { - "open_id": user_id, # open_id + "open_id": user_id, "email": email, "user_id": user_id, } @@ -1163,16 +1146,13 @@ def get_user_by_email(self, email: str) -> Dict[str, Any]: def get_users(self, page_size: int = 50) -> Dict[str, Any]: """ - 获取企业用户列表 - Get list of users in the organization. - + Retrieve a list of users in the organization. + Args: - page_size (int): 每页数量 - + page_size (int): Number of results per page. + Returns: - dict: 用户列表 - - Note: 用于前端下拉选择用户 + dict: User list formatted for frontend dropdowns. """ try: self.logger.info("Fetching users list...") @@ -1199,7 +1179,7 @@ def get_users(self, page_size: int = 50) -> Dict[str, Any]: items = result.get("data", {}).get("items", []) - # 格式化返回数据 + # Normalize user metadata formatted_users = [ { "open_id": user.get("open_id"), @@ -1223,244 +1203,207 @@ def get_users(self, page_size: int = 50) -> Dict[str, Any]: def __auto_enrich_description(self, title: str, description: str, **kwargs) -> str: """ - 🆕 自动enrichment工单描述,添加Keep平台链接和事件详细信息 - Auto-enrich ticket description with Keep platform links and event details. - - 如果检测到工作流上下文中有alert或incident,自动添加: - - Keep平台事件详情页链接(可直接点击) - - 完整的时间信息(触发时间、次数等) - - 所有来源和环境信息 - - 关联Incident链接 - - 原始监控系统链接 - - Args: - title: 工单标题 - description: 原始描述 - **kwargs: 其他参数 - - Returns: - enriched_description: enrichment后的描述 + Auto-enrich the ticket description with Keep platform links and contextual details. + + The enrichment includes: + - Direct links to the Keep UI. + - Timeline information (first trigger, last received, counters). + - Source, environment, and service metadata. + - Associated incident references. + - Monitoring and runbook URLs. """ try: - # 获取工作流上下文 - context = self.context_manager.get_full_context() if hasattr(self, 'context_manager') else {} - - # 尝试从上下文中获取alert或incident - alert = context.get('event', None) - incident = context.get('incident', None) - - # 如果没有找到,返回原始描述 + context = self.context_manager.get_full_context() if hasattr(self, "context_manager") else {} + + alert = context.get("event") + incident = context.get("incident") + if not alert and not incident: self.logger.debug("No alert or incident found in context, using original description") - return description if description else "无详细描述 / No description provided" - - # 辅助函数:安全获取属性值 - def get_attr(obj, attr, default='N/A'): - """安全获取对象属性,支持dict和对象""" + return description if description else "No detailed description provided." + + def get_attr(obj, attr, default="N/A"): + """Safely retrieve an attribute from a dict or object.""" if obj is None: return default - # 如果是dict,使用get方法 if isinstance(obj, dict): return obj.get(attr, default) - # 如果是对象,使用getattr return getattr(obj, attr, default) - - # 辅助函数:格式化状态 + def format_status(status): - """格式化状态,去除前缀,保持英文""" - if not status or status == 'N/A': - return 'N/A' + """Normalize status enums to uppercase strings.""" + if not status or status == "N/A": + return "N/A" status_str = str(status) - # 去除 INCIDENTSTATUS. 或 ALERTSTATUS. 前缀 - if '.' in status_str: - status_str = status_str.split('.')[-1] + if "." in status_str: + status_str = status_str.split(".")[-1] return status_str.upper() - - # 辅助函数:格式化严重程度 + def format_severity(severity): - """格式化严重程度,保持英文""" - if not severity or severity == 'N/A': - return 'N/A' + """Normalize severity values to uppercase strings.""" + if not severity or severity == "N/A": + return "N/A" return str(severity).upper() - - # 构建enrichment描述(参考用户提供的格式) + enriched = "" - + if alert: - # Alert基本信息 - enriched += f"🔴 事件名称: {title}\n" - enriched += f"📊 严重程度: {format_severity(get_attr(alert, 'severity'))}\n" - enriched += f"🏷️ 当前状态: {format_status(get_attr(alert, 'status'))}\n" - enriched += f"⏰ 最后接收: {get_attr(alert, 'lastReceived')}\n" - - firing_start = get_attr(alert, 'firingStartTime', None) - if firing_start and firing_start != 'N/A' and firing_start != 'null' and str(firing_start).lower() != 'none': - enriched += f"🔥 首次触发: {firing_start}\n" - - firing_counter = get_attr(alert, 'firingCounter', None) - # 注意:firing_counter可能是0,0也是有效值 - if firing_counter is not None and firing_counter != 'N/A' and str(firing_counter).lower() != 'none': - enriched += f"🔢 触发次数: {firing_counter}\n" - - # 来源信息(一行显示) - sources = get_attr(alert, 'source', []) - if sources and sources != 'N/A': + enriched += f"🔴 Event Title: {title}\n" + enriched += f"📊 Severity: {format_severity(get_attr(alert, 'severity'))}\n" + enriched += f"🏷️ Status: {format_status(get_attr(alert, 'status'))}\n" + enriched += f"⏰ Last Received: {get_attr(alert, 'lastReceived')}\n" + + firing_start = get_attr(alert, "firingStartTime", None) + if firing_start and str(firing_start).lower() not in {"n/a", "null", "none"}: + enriched += f"🔥 First Triggered: {firing_start}\n" + + firing_counter = get_attr(alert, "firingCounter", None) + if firing_counter is not None and str(firing_counter).lower() not in {"n/a", "null", "none"}: + enriched += f"🔢 Trigger Count: {firing_counter}\n" + + sources = get_attr(alert, "source", []) + if sources and sources != "N/A": if isinstance(sources, list): - enriched += f"\n📍 来源信息: {', '.join(str(s) for s in sources)}\n" + enriched += f"\n📍 Sources: {', '.join(str(s) for s in sources)}\n" else: - enriched += f"\n📍 来源信息: {sources}\n" + enriched += f"\n📍 Sources: {sources}\n" else: - enriched += f"\n📍 来源信息: N/A\n" - - enriched += f"🌐 部署环境: {get_attr(alert, 'environment')}\n" - - service = get_attr(alert, 'service', None) - if service and service != 'N/A' and service != 'null' and str(service).lower() != 'none': - enriched += f"⚙️ 关联服务: {service}\n" - - # 🔧 获取Keep前端URL(不是API URL) + enriched += "\n📍 Sources: N/A\n" + + enriched += f"🌐 Environment: {get_attr(alert, 'environment')}\n" + + service = get_attr(alert, "service", None) + if service and str(service).lower() not in {"n/a", "null", "none"}: + enriched += f"⚙️ Related Service: {service}\n" + keep_api_url = None - keep_context = context.get('keep') + keep_context = context.get("keep") if isinstance(keep_context, dict): - keep_api_url = keep_context.get('api_url') - - # 如果context中没有,尝试从环境变量或配置获取 + keep_api_url = keep_context.get("api_url") + if not keep_api_url: import os - keep_api_url = os.environ.get('KEEP_API_URL') - if not keep_api_url: - # 使用默认值(本地开发环境) - keep_api_url = "http://localhost:3000/api/v1" - - # 🔧 将API URL转换为前端UI URL - # API: http://0.0.0.0:8080/api/v1 → 前端: http://localhost:3000 - # API: http://localhost:8080/api/v1 → 前端: http://localhost:3000 - keep_frontend_url = keep_api_url.replace('/api/v1', '') - # 如果是后端端口(8080, 8000等),替换为前端端口(3000) - keep_frontend_url = keep_frontend_url.replace(':8080', ':3000') - keep_frontend_url = keep_frontend_url.replace(':8000', ':3000') - keep_frontend_url = keep_frontend_url.replace('0.0.0.0', 'localhost') - + keep_api_url = os.environ.get("KEEP_API_URL", "http://localhost:3000/api/v1") + + keep_frontend_url = ( + keep_api_url.replace("/api/v1", "") + .replace(":8080", ":3000") + .replace(":8000", ":3000") + .replace("0.0.0.0", "localhost") + ) + self.logger.debug(f"Keep API URL: {keep_api_url}") self.logger.debug(f"Keep Frontend URL: {keep_frontend_url}") - - alert_id = get_attr(alert, 'id', None) - - # 重要链接 + + alert_id = get_attr(alert, "id", None) + link_added = False - if alert_id and alert_id != 'N/A': + if alert_id and alert_id != "N/A": keep_url = f"{keep_frontend_url}/alerts/feed?cel=id%3D%3D%22{alert_id}%22" - enriched += f"\n🔗 事件详情: {keep_url}\n" + enriched += f"\n🔗 Keep Event: {keep_url}\n" link_added = True - - # 告警详情URL(alert.url字段) - alert_url = get_attr(alert, 'url', None) - if alert_url and alert_url != 'N/A' and alert_url != 'null' and str(alert_url).lower() != 'none': + + alert_url = get_attr(alert, "url", None) + if alert_url and str(alert_url).lower() not in {"n/a", "null", "none"}: if not link_added: enriched += "\n" - enriched += f"🔗 告警详情: {alert_url}\n" + enriched += f"🔗 Alert Details: {alert_url}\n" link_added = True - - # 其他链接 - generator_url = get_attr(alert, 'generatorURL', None) - if generator_url and generator_url != 'N/A' and generator_url != 'null' and str(generator_url).lower() != 'none': - enriched += f"🔗 监控面板: {generator_url}\n" + + generator_url = get_attr(alert, "generatorURL", None) + if generator_url and str(generator_url).lower() not in {"n/a", "null", "none"}: + enriched += f"🔗 Monitoring Dashboard: {generator_url}\n" link_added = True - - playbook_url = get_attr(alert, 'playbook_url', None) - if playbook_url and playbook_url != 'N/A' and playbook_url != 'null' and str(playbook_url).lower() != 'none': - enriched += f"🔗 处理手册: {playbook_url}\n" + + playbook_url = get_attr(alert, "playbook_url", None) + if playbook_url and str(playbook_url).lower() not in {"n/a", "null", "none"}: + enriched += f"🔗 Runbook: {playbook_url}\n" link_added = True - - # Incident关联 - incident_id = get_attr(alert, 'incident', None) - if incident_id and incident_id != 'N/A' and incident_id != 'null' and str(incident_id).lower() != 'none': - # 确保keep_api_url可用 - if not keep_api_url: - import os - keep_api_url = os.environ.get('KEEP_API_URL', "http://localhost:3000/api/v1") - # 转换为前端URL - keep_frontend_url = keep_api_url.replace('/api/v1', '') - keep_frontend_url = keep_frontend_url.replace(':8080', ':3000').replace(':8000', ':3000').replace('0.0.0.0', 'localhost') - enriched += f"🎯 关联Incident: {keep_frontend_url}/incidents/{incident_id}\n" - + + incident_id = get_attr(alert, "incident", None) + if incident_id and str(incident_id).lower() not in {"n/a", "null", "none"}: + keep_frontend_url = ( + keep_api_url.replace("/api/v1", "") + .replace(":8080", ":3000") + .replace(":8000", ":3000") + .replace("0.0.0.0", "localhost") + ) + enriched += f"🎯 Related Incident: {keep_frontend_url}/incidents/{incident_id}\n" + elif incident: - # Incident信息 - incident_name = get_attr(incident, 'user_generated_name', None) or get_attr(incident, 'ai_generated_name', None) or title - enriched += f"🔴 事件名称: {incident_name}\n" - enriched += f"📊 严重程度: {format_severity(get_attr(incident, 'severity'))}\n" - enriched += f"🏷️ 当前状态: {format_status(get_attr(incident, 'status'))}\n" - enriched += f"🔍 关联告警数: {get_attr(incident, 'alerts_count', 0)}\n" - enriched += f"⏰ 创建时间: {get_attr(incident, 'creation_time')}\n" - - start_time = get_attr(incident, 'start_time', None) - if start_time and start_time != 'N/A' and start_time != 'null' and str(start_time).lower() != 'none': - enriched += f"⏰ 开始时间: {start_time}\n" - - # 告警来源(Incident特有字段) - alert_sources = get_attr(incident, 'alert_sources', []) - if alert_sources and alert_sources != 'N/A': + incident_name = ( + get_attr(incident, "user_generated_name", None) + or get_attr(incident, "ai_generated_name", None) + or title + ) + enriched += f"🔴 Incident Title: {incident_name}\n" + enriched += f"📊 Severity: {format_severity(get_attr(incident, 'severity'))}\n" + enriched += f"🏷️ Status: {format_status(get_attr(incident, 'status'))}\n" + enriched += f"🔍 Alert Count: {get_attr(incident, 'alerts_count', 0)}\n" + enriched += f"⏰ Created At: {get_attr(incident, 'creation_time')}\n" + + start_time = get_attr(incident, "start_time", None) + if start_time and str(start_time).lower() not in {"n/a", "null", "none"}: + enriched += f"⏰ Started At: {start_time}\n" + + alert_sources = get_attr(incident, "alert_sources", []) + if alert_sources and alert_sources != "N/A": if isinstance(alert_sources, list) and len(alert_sources) > 0: - enriched += f"\n📍 告警来源: {', '.join(str(s) for s in alert_sources)}\n" + enriched += f"\n📍 Alert Sources: {', '.join(str(s) for s in alert_sources)}\n" else: - enriched += f"\n📍 告警来源: {alert_sources}\n" - - # 关联服务(Incident中是services数组) - services = get_attr(incident, 'services', []) - if services and services != 'N/A': + enriched += f"\n📍 Alert Sources: {alert_sources}\n" + + services = get_attr(incident, "services", []) + if services and services != "N/A": if isinstance(services, list) and len(services) > 0: - enriched += f"⚙️ 关联服务: {', '.join(str(s) for s in services)}\n" + enriched += f"⚙️ Related Services: {', '.join(str(s) for s in services)}\n" else: - enriched += f"⚙️ 关联服务: {services}\n" - - # 🔧 获取Keep前端URL(不是API URL) + enriched += f"⚙️ Related Services: {services}\n" + keep_api_url = None - keep_context = context.get('keep') + keep_context = context.get("keep") if isinstance(keep_context, dict): - keep_api_url = keep_context.get('api_url') - + keep_api_url = keep_context.get("api_url") + if not keep_api_url: import os - keep_api_url = os.environ.get('KEEP_API_URL', "http://localhost:3000/api/v1") - - # 🔧 将API URL转换为前端UI URL - keep_frontend_url = keep_api_url.replace('/api/v1', '') - keep_frontend_url = keep_frontend_url.replace(':8080', ':3000') - keep_frontend_url = keep_frontend_url.replace(':8000', ':3000') - keep_frontend_url = keep_frontend_url.replace('0.0.0.0', 'localhost') - - incident_id = get_attr(incident, 'id', None) - - # Keep链接 - if incident_id and incident_id != 'N/A' and incident_id != 'null' and str(incident_id).lower() != 'none': + keep_api_url = os.environ.get("KEEP_API_URL", "http://localhost:3000/api/v1") + + keep_frontend_url = ( + keep_api_url.replace("/api/v1", "") + .replace(":8080", ":3000") + .replace(":8000", ":3000") + .replace("0.0.0.0", "localhost") + ) + + incident_id = get_attr(incident, "id", None) + + if incident_id and str(incident_id).lower() not in {"n/a", "null", "none"}: keep_url = f"{keep_frontend_url}/incidents/{incident_id}" - enriched += f"\n🔗 事件详情: {keep_url}\n" - - # 添加原始描述 + enriched += f"\n🔗 Incident Details: {keep_url}\n" + if description: - enriched += f"\n📝 详细描述: {description}\n" - - # 负责人 + enriched += f"\n📝 Description: {description}\n" + if alert: - assignee = get_attr(alert, 'assignee', None) - if assignee and assignee != 'N/A' and assignee != 'null' and str(assignee).lower() != 'none': - enriched += f"\n👤 事件负责人: {assignee}\n" + assignee = get_attr(alert, "assignee", None) + if assignee and str(assignee).lower() not in {"n/a", "null", "none"}: + enriched += f"\n👤 Owner: {assignee}\n" elif incident: - assignee = get_attr(incident, 'assignee', None) - if assignee and assignee != 'N/A' and assignee != 'null' and str(assignee).lower() != 'none': - enriched += f"\n👤 事件负责人: {assignee}\n" - - # 添加提示 - enriched += f"\n⚠️ 请点击上方事件详情链接查看完整信息并及时处理" - + assignee = get_attr(incident, "assignee", None) + if assignee and str(assignee).lower() not in {"n/a", "null", "none"}: + enriched += f"\n👤 Owner: {assignee}\n" + + enriched += "\n⚠️ Use the links above to review full context and take action promptly." + self.logger.info("✅ Auto-enriched ticket description with event context") return enriched - + except Exception as e: self.logger.warning(f"Failed to auto-enrich description: {e}, using original") import traceback self.logger.debug(f"Traceback: {traceback.format_exc()}") - return description if description else "无详细描述 / No description provided" + return description if description else "No detailed description provided." def _notify( self, @@ -1490,11 +1433,11 @@ def _notify( try: self.logger.info("Notifying Feishu Service Desk...") - # 从kwargs中获取其他参数 + # Extract additional parameters from kwargs description = kwargs.get("description", "") ticket_id = kwargs.get("ticket_id", None) - # 如果title在kwargs中,也支持从kwargs获取(兼容性) + # Support reading the title from kwargs for compatibility if title is None: title = kwargs.get("title", None) status = kwargs.get("status", None) @@ -1507,7 +1450,6 @@ def _notify( open_id = kwargs.get("open_id", None) auto_enrich = kwargs.get("auto_enrich", True) - # 🆕 如果提供了user_email,自动转换为open_id if user_email and not open_id: try: self.logger.info(f"🔄 Converting user email to open_id: {user_email}") @@ -1516,9 +1458,8 @@ def _notify( self.logger.info(f"✅ Converted user email to open_id: {open_id}") except Exception as e: self.logger.warning(f"Failed to convert user email to open_id: {e}") - # 继续执行,使用default_open_id或报错 - - # 🆕 如果提供了agent_email,自动转换为agent_id + # Continue with default_open_id or raise during ticket creation + if agent_email and not agent_id: try: self.logger.info(f"🔄 Converting agent email to agent_id: {agent_email}") @@ -1527,13 +1468,10 @@ def _notify( self.logger.info(f"✅ Converted agent email to agent_id: {agent_id}") except Exception as e: self.logger.warning(f"Failed to convert agent email to agent_id: {e}") - # 继续执行,不分配客服 - - # 🆕 自动enrichment:如果启用且description较短或为空,自动添加完整的事件信息 - # 只在创建工单时(有title)或更新工单时(有description)才enrich + # Continue without assigning a specific agent + if auto_enrich and title and (not description or len(description) < 300): original_desc = description - # 创建一个新的kwargs副本,移除已经提取的参数以避免冲突 enrich_kwargs = {k: v for k, v in kwargs.items() if k not in ['description', 'ticket_id', 'status', 'customized_fields', 'category_id', 'agent_id', 'priority', 'tags', @@ -1543,8 +1481,6 @@ def _notify( self.logger.info("✅ Auto-enriched description with alert/incident context") if ticket_id: - # 更新现有工单 - # 创建一个清理过的kwargs,移除已经作为显式参数传递的值 update_kwargs = {k: v for k, v in kwargs.items() if k not in ['description', 'ticket_id', 'status', 'customized_fields', 'category_id', 'agent_id', 'priority', 'tags', @@ -1557,28 +1493,23 @@ def _notify( **update_kwargs, ) - # 如果提供了评论,添加评论 if add_comment: self.add_ticket_comment(ticket_id, add_comment) result["comment_added"] = True - # 如果提供了客服 ID,分配工单 if agent_id: self.assign_ticket(ticket_id, agent_id) result["assigned_to"] = agent_id - # 获取工单详情以获取完整的 ticket_url ticket_details = self.__get_ticket(ticket_id) result["ticket_url"] = ticket_details.get("ticket_url", "") self.logger.info("Updated a Feishu Service Desk ticket: " + str(result)) return result else: - # 创建新工单 if not title: raise ProviderException("Title is required to create a ticket!") - # 创建一个清理过的kwargs,移除已经作为显式参数传递的值 create_kwargs = {k: v for k, v in kwargs.items() if k not in ['description', 'ticket_id', 'status', 'customized_fields', 'category_id', 'agent_id', 'priority', 'tags', @@ -1596,18 +1527,14 @@ def _notify( **create_kwargs, ) - # 获取创建的工单 ID 和 URL ticket_data = result.get("ticket", {}) created_ticket_id = ticket_data.get("ticket_id") if created_ticket_id: - # Note: agent_id已经在__create_ticket中通过appointed_agents参数指定 - # 不需要后续调用assign_ticket(该API返回404) if agent_id: result["assigned_to"] = agent_id self.logger.info(f"✅ Agent assigned via appointed_agents: {agent_id}") - # 获取工单详情 ticket_details = self.__get_ticket(created_ticket_id) result["ticket_url"] = ticket_details.get("ticket_url", "") @@ -1632,18 +1559,15 @@ def _query( """ try: if ticket_id: - # 查询单个工单 ticket = self.__get_ticket(ticket_id) return {"ticket": ticket} else: - # 从 kwargs 提取高级参数 status = kwargs.get("status", None) category_id = kwargs.get("category_id", None) agent_id = kwargs.get("agent_id", None) page_size = kwargs.get("page_size", 50) page_token = kwargs.get("page_token", None) - # 列出工单 self.logger.info("Listing tickets from Feishu Service Desk...") url = self.__get_url("/open-apis/helpdesk/v1/tickets") @@ -1652,7 +1576,6 @@ def _query( "page_size": page_size, } - # 添加可选的过滤参数 if page_token: params["page_token"] = page_token if status is not None: @@ -1662,7 +1585,6 @@ def _query( if agent_id: params["agent_id"] = agent_id - # 添加服务台 ID(如果已配置) if self.authentication_config.helpdesk_id: params["helpdesk_id"] = self.authentication_config.helpdesk_id @@ -1728,18 +1650,15 @@ def _query( # Example 1: Create ticket result = provider.notify( - title="测试工单", - description="这是一个测试工单", + title="Test Ticket", + description="This is a test ticket", ) print(f"Created ticket: {result}") # Example 2: Update ticket if result.get("ticket", {}).get("ticket_id"): ticket_id = result["ticket"]["ticket_id"] - update_result = provider.notify( - ticket_id=ticket_id, - status=50, # 已完成 - ) + update_result = provider.notify(ticket_id=ticket_id, status=50) print(f"Updated ticket: {update_result}") # Example 3: Query ticket From 62b707d75b1f58a06f94de89955d536092209779 Mon Sep 17 00:00:00 2001 From: nctllnty Date: Fri, 21 Nov 2025 13:48:14 +0800 Subject: [PATCH 3/4] fix: Removed unused imports and simplified exception handling in feishu_servicedesk_provider.py --- .../feishu_servicedesk_provider.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/keep/providers/feishu_servicedesk_provider/feishu_servicedesk_provider.py b/keep/providers/feishu_servicedesk_provider/feishu_servicedesk_provider.py index 9ffe8aa797..b3b1e139f0 100644 --- a/keep/providers/feishu_servicedesk_provider/feishu_servicedesk_provider.py +++ b/keep/providers/feishu_servicedesk_provider/feishu_servicedesk_provider.py @@ -6,7 +6,7 @@ import datetime import json from typing import Any, Dict, List, Optional -from urllib.parse import urljoin, urlencode +from urllib.parse import urljoin import pydantic import requests @@ -15,7 +15,6 @@ from keep.exceptions.provider_exception import ProviderException from keep.providers.base.base_provider import BaseProvider from keep.providers.models.provider_config import ProviderConfig, ProviderScope -from keep.providers.models.provider_method import ProviderMethod from keep.validation.fields import HttpsUrl @@ -187,7 +186,6 @@ def __get_access_token(self) -> str: """Retrieve the Feishu tenant access token.""" try: # Reuse the cached token if it is still valid - import datetime if self._access_token and self._token_expiry: if datetime.datetime.now() < self._token_expiry: return self._access_token @@ -250,7 +248,7 @@ def __get_headers(self, use_helpdesk_auth: bool = False): auth_string = f"{self.authentication_config.helpdesk_id}:{self.authentication_config.helpdesk_token}" encoded = base64.b64encode(auth_string.encode()).decode() headers["X-Lark-Helpdesk-Authorization"] = encoded - self.logger.info(f"Using dual authentication: Bearer token + Helpdesk auth") + self.logger.info("Using dual authentication: Bearer token + Helpdesk auth") return headers @@ -357,7 +355,7 @@ def __create_ticket( # Raise for HTTP errors try: response.raise_for_status() - except Exception as e: + except Exception: self.logger.exception( "Failed to create a ticket", extra={"result": result, "status": response.status_code} ) @@ -563,7 +561,7 @@ def __send_ticket_message(self, ticket_id: str, content: str): try: result = response.json() self.logger.info(f"Response: {result}") - except: + except Exception: result = {"text": response.text} if response.status_code == 200: @@ -631,7 +629,7 @@ def __update_ticket( # Propagate HTTP errors try: response.raise_for_status() - except Exception as e: + except Exception: self.logger.exception( "Failed to update a ticket", extra={"result": result, "status": response.status_code} @@ -797,7 +795,7 @@ def get_agents(self, helpdesk_id: Optional[str] = None) -> Dict[str, Any]: self.logger.info(f"Fetching agents for helpdesk {helpdesk_id}...") - url = self.__get_url(f"/open-apis/helpdesk/v1/agents") + url = self.__get_url("/open-apis/helpdesk/v1/agents") params = {"helpdesk_id": helpdesk_id} response = requests.get( @@ -1027,8 +1025,8 @@ def assign_ticket( """ try: self.logger.warning( - f"⚠️ Assign ticket API may not be available in Feishu Service Desk. " - f"Recommend using agent_email/agent_id in ticket creation instead." + "Assign ticket API may not be available in Feishu Service Desk. " + "Recommend using agent_email/agent_id in ticket creation instead." ) self.logger.info(f"Attempting to assign ticket {ticket_id} to agent {agent_id}...") From 1dff3c9e5c059e0e03e258323a38e4bc3a27d074 Mon Sep 17 00:00:00 2001 From: nctllnty Date: Fri, 21 Nov 2025 13:53:46 +0800 Subject: [PATCH 4/4] docs: Enhance Feishu Service Desk documentation with detailed workflow examples and new YAML configurations --- .../feishu-servicedesk-provider-en.mdx | 108 +++++++++++++++++- .../workflows/feishu_servicedesk_advanced.yml | 43 +++++++ .../feishu_servicedesk_best_practice.yml | 82 +++++++++++++ .../workflows/feishu_servicedesk_simple.yml | 27 +++++ .../feishu_servicedesk_with_email.yml | 29 +++++ 5 files changed, 284 insertions(+), 5 deletions(-) create mode 100644 examples/workflows/feishu_servicedesk_advanced.yml create mode 100644 examples/workflows/feishu_servicedesk_best_practice.yml create mode 100644 examples/workflows/feishu_servicedesk_simple.yml create mode 100644 examples/workflows/feishu_servicedesk_with_email.yml diff --git a/docs/providers/documentation/feishu-servicedesk-provider-en.mdx b/docs/providers/documentation/feishu-servicedesk-provider-en.mdx index 78733dc688..70d4fcd65c 100644 --- a/docs/providers/documentation/feishu-servicedesk-provider-en.mdx +++ b/docs/providers/documentation/feishu-servicedesk-provider-en.mdx @@ -233,10 +233,108 @@ Disable enrichment by setting `auto_enrich: false` and providing your own `descr Documentation for the Lark deployment. -## Examples +## Workflow Examples -Additional workflow samples are available in `examples/workflows/`: +Complete workflow examples are available in the `examples/workflows/` directory: -- `feishu_servicedesk_simple.yml` -- `feishu_servicedesk_best_practice.yml` -- `feishu_servicedesk_with_email.yml` +### Simple Example + +`feishu_servicedesk_simple.yml` - Minimal configuration with auto-enrichment: + +```yaml +workflow: + id: feishu-servicedesk-simple + name: Create Feishu Service Desk Ticket (Simple) + description: Minimal configuration example + triggers: + - type: alert + filters: + - key: severity + value: [critical, high] + actions: + - name: create-feishu-ticket + provider: + type: feishu_servicedesk + config: "{{ providers.feishu_servicedesk }}" + with: + title: "{{ alert.name }}" + user_email: "{{ alert.assignee }}" + agent_email: "oncall@example.com" + enrich_alert: + - key: ticket_type + value: feishu_servicedesk + - key: ticket_id + value: results.ticket_id + - key: ticket_url + value: results.ticket_url +``` + +### Email Conversion Example + +`feishu_servicedesk_with_email.yml` - Using email addresses with automatic conversion: + +```yaml +workflow: + id: feishu-servicedesk-with-email + name: Create Feishu Ticket with Email Conversion + triggers: + - type: alert + filters: + - key: severity + value: critical + actions: + - name: create-ticket-with-email + provider: + type: feishu_servicedesk + config: "{{ providers.feishu_servicedesk }}" + with: + title: "Critical Alert: {{ alert.name }}" + user_email: "reporter@example.com" + agent_email: "handler@example.com" + priority: 4 + tags: ["{{ alert.environment }}", "{{ alert.service }}"] +``` + +### Best Practices Example + +`feishu_servicedesk_best_practice.yml` - Complete workflow with ticket creation and status updates: + +- Creates tickets for high-severity alerts +- Updates ticket status when alerts are resolved +- Supports incident-triggered ticket creation + +### Advanced Configuration Example + +`feishu_servicedesk_advanced.yml` - Advanced features including custom fields and manual description: + +```yaml +workflow: + id: feishu-servicedesk-advanced + name: Feishu Service Desk Advanced Configuration + triggers: + - type: alert + filters: + - key: severity + value: critical + actions: + - name: create-advanced-ticket + provider: + type: feishu_servicedesk + config: "{{ providers.feishu_servicedesk }}" + with: + title: "Urgent Alert: {{ alert.name }}" + user_email: "user@example.com" + agent_email: "agent@example.com" + priority: 4 + tags: ["production", "database"] + category_id: "category_123" + customized_fields: + - id: "field_12345" + value: "high" + - id: "field_67890" + value: "{{ alert.service }}" + auto_enrich: false + description: "Custom description override" +``` + +All examples are available in the `examples/workflows/` directory. diff --git a/examples/workflows/feishu_servicedesk_advanced.yml b/examples/workflows/feishu_servicedesk_advanced.yml new file mode 100644 index 0000000000..9c1b9e4587 --- /dev/null +++ b/examples/workflows/feishu_servicedesk_advanced.yml @@ -0,0 +1,43 @@ +workflow: + id: feishu-servicedesk-advanced + name: Feishu Service Desk Advanced Configuration + description: Example with custom fields, categories, and manual description override + disabled: false + triggers: + - type: alert + filters: + - key: severity + value: critical + actions: + - name: create-advanced-ticket + provider: + type: feishu_servicedesk + config: "{{ providers.feishu_servicedesk }}" + with: + title: "Urgent Alert: {{ alert.name }}" + user_email: "user@example.com" + agent_email: "agent@example.com" + priority: 4 + tags: ["production", "database", "critical"] + category_id: "category_123" + description: | + Custom description for this alert. + + Additional context: + - Environment: {{ alert.environment }} + - Service: {{ alert.service }} + - Source: {{ alert.source }} + auto_enrich: false + customized_fields: + - id: "field_12345" + value: "high" + - id: "field_67890" + value: "{{ alert.service }}" + enrich_alert: + - key: ticket_type + value: feishu_servicedesk + - key: ticket_id + value: results.ticket_id + - key: ticket_url + value: results.ticket_url + diff --git a/examples/workflows/feishu_servicedesk_best_practice.yml b/examples/workflows/feishu_servicedesk_best_practice.yml new file mode 100644 index 0000000000..f6f9fc75cd --- /dev/null +++ b/examples/workflows/feishu_servicedesk_best_practice.yml @@ -0,0 +1,82 @@ +workflow: + id: feishu-servicedesk-best-practice + name: Feishu Service Desk Best Practices + description: Complete example with ticket creation, status updates, and incident support + disabled: false + triggers: + - type: alert + filters: + - key: severity + value: [critical, high] + actions: + - name: create-ticket-on-alert + if: "not '{{ alert.ticket_id }}'" + provider: + type: feishu_servicedesk + config: "{{ providers.feishu_servicedesk }}" + with: + title: "{{ alert.name }}" + user_email: "{{ alert.assignee }}" + agent_email: "oncall@example.com" + priority: 4 + tags: ["{{ alert.environment }}", "{{ alert.service }}", "auto-created"] + enrich_alert: + - key: ticket_type + value: feishu_servicedesk + - key: ticket_id + value: results.ticket_id + - key: ticket_url + value: results.ticket_url + +--- +workflow: + id: feishu-servicedesk-update-on-resolve + name: Update Feishu Ticket on Alert Resolution + description: Automatically update ticket status when alert is resolved + disabled: false + triggers: + - type: alert + filters: + - key: status + value: resolved + actions: + - name: update-ticket-status + if: "'{{ alert.ticket_id }}' and '{{ alert.ticket_type }}' == 'feishu_servicedesk'" + provider: + type: feishu_servicedesk + config: "{{ providers.feishu_servicedesk }}" + with: + ticket_id: "{{ alert.ticket_id }}" + status: 50 + add_comment: "Alert automatically resolved at {{ alert.lastReceived }}" + +--- +workflow: + id: feishu-servicedesk-from-incident + name: Create Feishu Ticket from Incident + description: Create a ticket when a high-severity incident is created + disabled: false + triggers: + - type: incident + filters: + - key: severity + value: [critical, high] + actions: + - name: create-ticket-from-incident + provider: + type: feishu_servicedesk + config: "{{ providers.feishu_servicedesk }}" + with: + title: "{{ incident.user_generated_name }}" + user_email: "{{ incident.assignee }}" + agent_email: "sre-team@example.com" + priority: 4 + tags: ["incident", "{{ incident.severity }}"] + enrich_incident: + - key: ticket_type + value: feishu_servicedesk + - key: ticket_id + value: results.ticket_id + - key: ticket_url + value: results.ticket_url + diff --git a/examples/workflows/feishu_servicedesk_simple.yml b/examples/workflows/feishu_servicedesk_simple.yml new file mode 100644 index 0000000000..e21bbd3d4c --- /dev/null +++ b/examples/workflows/feishu_servicedesk_simple.yml @@ -0,0 +1,27 @@ +workflow: + id: feishu-servicedesk-simple + name: Create Feishu Service Desk Ticket (Simple) + description: Minimal configuration example - creates a ticket with auto-enrichment enabled + disabled: false + triggers: + - type: alert + filters: + - key: severity + value: [critical, high] + actions: + - name: create-feishu-ticket + provider: + type: feishu_servicedesk + config: "{{ providers.feishu_servicedesk }}" + with: + title: "{{ alert.name }}" + user_email: "{{ alert.assignee }}" + agent_email: "oncall@example.com" + enrich_alert: + - key: ticket_type + value: feishu_servicedesk + - key: ticket_id + value: results.ticket_id + - key: ticket_url + value: results.ticket_url + diff --git a/examples/workflows/feishu_servicedesk_with_email.yml b/examples/workflows/feishu_servicedesk_with_email.yml new file mode 100644 index 0000000000..500d274532 --- /dev/null +++ b/examples/workflows/feishu_servicedesk_with_email.yml @@ -0,0 +1,29 @@ +workflow: + id: feishu-servicedesk-with-email + name: Create Feishu Ticket with Email Conversion + description: Example using email addresses that are automatically converted to Feishu User IDs + disabled: false + triggers: + - type: alert + filters: + - key: severity + value: critical + actions: + - name: create-ticket-with-email + provider: + type: feishu_servicedesk + config: "{{ providers.feishu_servicedesk }}" + with: + title: "Critical Alert: {{ alert.name }}" + user_email: "reporter@example.com" + agent_email: "handler@example.com" + priority: 4 + tags: ["{{ alert.environment }}", "{{ alert.service }}"] + enrich_alert: + - key: ticket_type + value: feishu_servicedesk + - key: ticket_id + value: results.ticket_id + - key: ticket_url + value: results.ticket_url +