ReMe/skills/dingtalk-message/scripts/dingtalk.py
jinliyl 46adb5ae1e
Some checks failed
Pre-commit / run (ubuntu-latest) (push) Has been cancelled
Tests ReMe / Unit Tests - py3.11 (push) Has been cancelled
Tests ReMe / Unit Tests - py3.12 (push) Has been cancelled
Tests ReMe / Unit Tests - py3.13 (push) Has been cancelled
Windows Smoke / CLI smoke - py3.11 (push) Has been cancelled
feat: add daily paper cookbook and DingTalk agent integration (#385)
* feat(daily-paper): add daily paper cookbook workflow with schema and tests

- Introduce daily paper schema types (DailyBriefOutput, PaperInfo, PaperNoteOutput, etc.)
- Create daily paper cookbook module with analyze, collect, digest, rank, and select steps
- Add cookbook entry point and integrate into main steps module
- Replace job config export with daily brief output in schema exports
- Add comprehensive unit tests covering pipeline, filtering, and output generation
- Update dependencies including openai-codex and pypdf packages
- Configure standalone daily paper cron job with proper scheduling and routing

* test(daily_paper): update tests to use Claude Code wrapper exclusively

- Add test to verify web search is disallowed by default in Claude Code
- Update imports to include DailyBriefOutput, PaperNoteOutput, and PaperSelection schemas
- Change test name from standalone_config_has_backend_split to reflect Claude Code only usage
- Remove default agent wrapper and configure all steps to use Claude Code wrapper
- Rename select_wrapper to cc_wrapper for clarity and consistency
- Remove duplicate Claude Code wrapper initialization
- Update test assertions to verify output schema usage matches expected sequence
- Remove unused as_llm component from standalone configuration test

* refactor(agent-wrapper): simplify skill resolution logic across all wrappers

- Replace duplicate skill resolution code with centralized _resolve_project_skills method
- Add project_path property with configurable relative path resolution
- Introduce proper validation for skill names and directory existence
- Change Codex wrapper to use project_path instead of workspace_path for skills
- Add SKILL.md requirement validation for project skills
- Remove redundant skill processing logic from individual wrappers

* feat(daily_paper): add daily paper workflow with PDF analysis and brief generation

- Implement shared state management and file helpers for daily-paper steps
- Add PDF download and text extraction capabilities with arXiv integration
- Create paper collection step with Hugging Face weekly/monthly rankings
- Build ranking system using reciprocal-rank fusion with memory keyword scoring
- Add Claude Code integration for paper analysis and detailed note generation
- Implement digest step to create final five-minute brief from detailed notes
- Add configuration for standalone daily cookbook application with cron scheduling
- Create typed schema for paper information, selection, and output formats
- Add atomic file writing with temporary file safety mechanisms
- Implement exclusion logic for previously recommended papers and daily filters

* feat(daily_paper): add DingTalk notification integration and enhance logging

- Integrate DingTalk markdown send step to notify groups about daily paper briefs
- Add comprehensive logging throughout daily paper workflow including start/finish events
- Update daily paper analysis prompt to include code repository context requirement
- Configure DingTalk notification in daily_cookbook.yaml with app credentials
- Add dingtalk-stream dependency for proactive message API integration
- Enhance daily paper README with DingTalk notification section and updated flow chart
- Implement detailed logging for each step including paper processing and agent calls
- Add test coverage for DingTalk markdown sending functionality and configuration
- Update pre-commit config to exclude skills directory from checks
- Add .claude/skills to gitignore for local development environment

* refactor(dingtalk): move dingtalk_stream import to local scope and improve code safety

- Moved global dingtalk_stream import to local scope in send.py to avoid eager loading
- Added dynamic import with error handling for optional dependency cases
- Updated test suite to verify lazy loading behavior works correctly
- Fixed markdown title generation by using safe variable naming in wait.py
- Enhanced test coverage for arxiv PDF download caching functionality
- Updated application context initialization with proper resource directory configuration
- Modified paper metadata to include source PDF path reference in output files

* refactor(daily_paper): remove manifest system and store selection metadata in digest files

- Remove JSON manifest creation and storage functionality
- Store selection data directly in digest file frontmatter instead of separate manifest files
- Add load_saved_selection method to rebuild selection from digest and paper-note metadata
- Update README documentation to reflect new cookbook workflow architecture
- Modify test cases to verify selection metadata in digest files instead of manifest JSON
- Remove unused json import from multiple daily paper modules
- Integrate PaperSelection schema for proper data validation in stored metadata

* docs(daily_paper): add bilingual cookbook guides
2026-07-22 19:17:01 +08:00

1695 lines
56 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
钉钉消息发送工具
支持多机器人管理、企业内部机器人和 Webhook 自定义机器人
支持机器人状态追踪和最近使用记录
"""
import argparse
import hashlib
import hmac
import base64
import json
import os
import sys
import time
import urllib.parse
from datetime import datetime
from pathlib import Path
from dataclasses import dataclass
from typing import List, Dict, Optional, Union
# ==================== 路径常量 ====================
def _get_config_dir() -> Path:
"""获取跨平台的配置目录
- Windows: %APPDATA%/dingtalk/
- macOS/Linux: ~/.config/dingtalk/
"""
if sys.platform == "win32":
base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
else:
base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
return base / "dingtalk"
CONFIG_DIR = _get_config_dir()
CONFIG_PATH = CONFIG_DIR / "config.json"
STATE_PATH = CONFIG_DIR / "state.json"
# ==================== 配置与状态管理 ====================
def _load_json(path: Path) -> dict:
"""从文件加载 JSON不存在则返回空 dict"""
if not path.exists():
return {}
with open(path, "r") as f:
return json.load(f)
def _save_json(path: Path, data: dict):
"""保存 JSON 到文件,自动创建父目录"""
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as f:
json.dump(data, f, ensure_ascii=False, indent=4)
def load_config() -> dict:
"""加载配置文件"""
return _load_json(CONFIG_PATH)
def save_config(config: dict):
"""保存配置文件"""
_save_json(CONFIG_PATH, config)
def load_state() -> dict:
"""加载状态文件"""
if not STATE_PATH.exists():
return {"robots": {}}
with open(STATE_PATH, "r") as f:
return json.load(f)
def save_state(state: dict):
"""保存状态文件"""
_save_json(STATE_PATH, state)
def ensure_config() -> dict:
"""加载配置"""
config = load_config()
if not config:
return {"default_robot": None, "robots": []}
return config
def find_robot(
config: dict, name: Optional[str] = None, robot_type: Optional[str] = None
) -> Optional[dict]:
"""
查找机器人,优先级:
1. 指定名称
2. default_robot
3. last_used 最近的(匹配类型)
4. 第一个匹配类型的
"""
robots = config.get("robots", [])
if not robots:
return None
state = load_state()
robot_states = state.get("robots", {})
# 1. 按名称查找
if name:
for r in robots:
if r["name"] == name:
if robot_type and r["type"] != robot_type:
print(
f"警告:机器人 '{name}' 类型为 {r['type']},但当前命令需要 {robot_type} 类型"
)
return r
print(f"错误:找不到名为 '{name}' 的机器人")
print(f"可用机器人:{', '.join(r['name'] for r in robots)}")
sys.exit(1)
# 按类型过滤(只保留启用的)
candidates = []
for r in robots:
if robot_type and r["type"] != robot_type:
continue
rs = robot_states.get(r["name"], {})
if rs.get("enabled", True):
candidates.append(r)
if not candidates:
return None
# 2. default_robot
default_name = config.get("default_robot")
if default_name:
for r in candidates:
if r["name"] == default_name:
return r
# 3. last_used 最近的
def get_last_used(r):
rs = robot_states.get(r["name"], {})
return rs.get("last_used", "")
candidates.sort(key=get_last_used, reverse=True)
if get_last_used(candidates[0]):
return candidates[0]
# 4. 第一个
return candidates[0]
def update_robot_state(
name: str, success: bool = True, msg_summary: Optional[str] = None
):
"""更新机器人使用状态,记录消息摘要"""
state = load_state()
if "robots" not in state:
state["robots"] = {}
if name not in state["robots"]:
state["robots"][name] = {
"enabled": True,
"last_used": None,
"use_count": 0,
"last_status": None,
"recent_messages": [],
}
rs = state["robots"][name]
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
rs["last_used"] = now
rs["use_count"] = rs.get("use_count", 0) + 1
rs["last_status"] = "success" if success else "failed"
# 记录最近消息摘要保留最近10条
if msg_summary:
recent = rs.get("recent_messages", [])
recent.append(
{
"time": now,
"summary": msg_summary[:80],
"status": "ok" if success else "fail",
}
)
rs["recent_messages"] = recent[-10:]
save_state(state)
# ==================== 数据类 ====================
@dataclass
class DingTalkConfig:
"""钉钉企业内部机器人配置"""
app_key: str
app_secret: str
robot_code: Optional[str] = None
agent_id: Optional[str] = None
BASE_URL = "https://api.dingtalk.com"
OLD_BASE_URL = "https://oapi.dingtalk.com"
def __post_init__(self):
if not self.app_key or not self.app_secret:
raise ValueError("app_key 和 app_secret 不能为空")
@dataclass
class DingTalkWebhookConfig:
"""钉钉 Webhook 自定义机器人配置"""
webhook_token: str
secret: Optional[str] = None
WEBHOOK_BASE = "https://oapi.dingtalk.com/robot/send"
def __post_init__(self):
if not self.webhook_token:
raise ValueError("webhook_token 不能为空")
@property
def webhook_url(self) -> str:
"""根据 token 生成完整 URL"""
if self.webhook_token.startswith("http"):
return self.webhook_token
return f"{self.WEBHOOK_BASE}?access_token={self.webhook_token}"
# ==================== Webhook 发送器 ====================
class DingTalkWebhookSender:
"""钉钉 Webhook 自定义机器人消息发送器"""
def __init__(self, config: Union[DingTalkWebhookConfig, Dict]):
if isinstance(config, dict):
self.config = DingTalkWebhookConfig(**config)
else:
self.config = config
try:
import requests
self.session = requests.Session()
except ImportError:
raise ImportError("请安装 requests: pip install requests")
def _build_url(self) -> str:
url = self.config.webhook_url
if self.config.secret:
timestamp = str(round(time.time() * 1000))
string_to_sign = f"{timestamp}\n{self.config.secret}"
hmac_code = hmac.new(
self.config.secret.encode("utf-8"),
string_to_sign.encode("utf-8"),
digestmod=hashlib.sha256,
).digest()
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code))
separator = "&" if "?" in url else "?"
url = f"{url}{separator}timestamp={timestamp}&sign={sign}"
return url
def _send(self, payload: Dict) -> Dict:
url = self._build_url()
headers = {"Content-Type": "application/json; charset=utf-8"}
try:
response = self.session.post(url, headers=headers, json=payload)
if not response.ok:
try:
detail = response.json()
except Exception:
detail = response.text
raise Exception(f"Webhook HTTP {response.status_code}: {detail}")
return response.json()
except Exception as e:
if "HTTP " in str(e):
raise
raise Exception(f"Webhook 请求失败: {str(e)}")
@staticmethod
def _process_markdown_text(text: str) -> str:
return DingTalkMessenger._process_markdown_text(text)
def send_text(
self,
content: str,
at_mobiles: Optional[List[str]] = None,
at_user_ids: Optional[List[str]] = None,
is_at_all: bool = False,
) -> Dict:
content = content.replace("\\n", "\n")
payload = {
"msgtype": "text",
"text": {"content": content},
"at": {"isAtAll": is_at_all},
}
if at_mobiles:
payload["at"]["atMobiles"] = at_mobiles
if at_user_ids:
payload["at"]["atUserIds"] = at_user_ids
return self._send(payload)
def send_markdown(
self,
title: str,
text: str,
at_mobiles: Optional[List[str]] = None,
at_user_ids: Optional[List[str]] = None,
is_at_all: bool = False,
) -> Dict:
processed_text = self._process_markdown_text(text)
payload = {
"msgtype": "markdown",
"markdown": {"title": title, "text": processed_text},
"at": {"isAtAll": is_at_all},
}
if at_mobiles:
payload["at"]["atMobiles"] = at_mobiles
if at_user_ids:
payload["at"]["atUserIds"] = at_user_ids
return self._send(payload)
def send_link(
self, title: str, text: str, message_url: str, pic_url: Optional[str] = None
) -> Dict:
text = text.replace("\\n", "\n")
payload = {
"msgtype": "link",
"link": {"title": title, "text": text, "messageUrl": message_url},
}
if pic_url:
payload["link"]["picUrl"] = pic_url
return self._send(payload)
def send_action_card(
self,
title: str,
text: str,
single_title: str,
single_url: str,
btn_orientation: str = "0",
) -> Dict:
text = self._process_markdown_text(text)
payload = {
"msgtype": "actionCard",
"actionCard": {
"title": title,
"text": text,
"btnOrientation": btn_orientation,
"singleTitle": single_title,
"singleURL": single_url,
},
}
return self._send(payload)
def send_action_card_multi(
self, title: str, text: str, buttons: List[Dict], btn_orientation: str = "0"
) -> Dict:
text = self._process_markdown_text(text)
payload = {
"msgtype": "actionCard",
"actionCard": {
"title": title,
"text": text,
"btnOrientation": btn_orientation,
"btns": buttons,
},
}
return self._send(payload)
def send_feed_card(self, links: List[Dict]) -> Dict:
payload = {"msgtype": "feedCard", "feedCard": {"links": links}}
return self._send(payload)
# ==================== 企业内部机器人发送器 ====================
class DingTalkMessenger:
"""钉钉企业内部机器人消息发送器"""
MSG_TYPES = {
"text": "sampleText",
"image": "sampleImage",
"voice": "sampleVoice",
"file": "sampleFile",
"link": "sampleLink",
"markdown": "sampleMarkdown",
"action_card": "sampleActionCard",
"action_card_multi": "sampleMultiActionCard",
"group_text": "text",
"group_image": "image",
"group_link": "link",
"group_markdown": "markdown",
"group_action_card": "actionCard",
"group_feed_card": "feedCard",
}
def __init__(self, config: Union[DingTalkConfig, Dict]):
if isinstance(config, dict):
self.config = DingTalkConfig(**config)
else:
self.config = config
self._access_token = None
self._token_expire_time = 0
try:
import requests
self.session = requests.Session()
except ImportError:
raise ImportError("请安装 requests: pip install requests")
def _get_access_token(self) -> str:
current_time = time.time()
if self._access_token and current_time < self._token_expire_time - 300:
return self._access_token
url = f"{self.config.OLD_BASE_URL}/gettoken"
params = {"appkey": self.config.app_key, "appsecret": self.config.app_secret}
try:
response = self.session.get(url, params=params)
if not response.ok:
try:
detail = response.json()
except Exception:
detail = response.text
raise Exception(
f"获取access_token失败 HTTP {response.status_code}: {detail}"
)
data = response.json()
if data.get("errcode") != 0:
raise Exception(f"获取access_token失败: {data}")
self._access_token = data["access_token"]
self._token_expire_time = current_time + data.get("expires_in", 7200)
return self._access_token
except Exception as e:
if "access_token" in str(e):
raise
raise Exception(f"请求access_token失败: {str(e)}")
def _upload_media(self, file_path: str, media_type: str = "file") -> str:
url = f"{self.config.OLD_BASE_URL}/media/upload"
params = {"access_token": self._get_access_token()}
with open(file_path, "rb") as f:
files = {"media": f}
data = {"type": media_type}
try:
response = self.session.post(url, params=params, files=files, data=data)
if not response.ok:
try:
detail = response.json()
except Exception:
detail = response.text
raise Exception(
f"上传文件失败 HTTP {response.status_code}: {detail}"
)
result = response.json()
if result.get("errcode") != 0:
raise Exception(f"上传文件失败: {result}")
return result["media_id"]
except Exception as e:
if "上传文件" in str(e):
raise
raise Exception(f"上传文件请求失败: {str(e)}")
def _make_request(self, method: str, url: str, **kwargs) -> Dict:
try:
response = self.session.request(method, url, **kwargs)
if not response.ok:
try:
detail = response.json()
except Exception:
detail = response.text
raise Exception(f"HTTP {response.status_code}: {detail}")
return response.json()
except Exception as e:
if "HTTP " in str(e):
raise
raise Exception(f"HTTP请求失败: {str(e)}")
# ---- 单聊 ----
def send_o2o_message(
self, user_ids: List[str], msg_key: str, msg_param: Dict
) -> Dict:
if not user_ids:
raise ValueError("user_ids不能为空")
if len(user_ids) > 100:
raise ValueError("单次最多发送给100个用户")
if not self.config.robot_code:
raise ValueError("发送单聊消息需要配置robot_code")
url = f"{self.config.BASE_URL}/v1.0/robot/oToMessages/batchSend"
headers = {
"x-acs-dingtalk-access-token": self._get_access_token(),
"Content-Type": "application/json",
}
payload = {
"robotCode": self.config.robot_code,
"userIds": user_ids,
"msgKey": msg_key,
"msgParam": json.dumps(msg_param, ensure_ascii=False),
}
return self._make_request("POST", url, headers=headers, json=payload)
def send_o2o_text(
self, user_ids: List[str], content: str, at_users: Optional[List[str]] = None
) -> Dict:
content = content.replace("\\n", "\n")
msg_param = {"content": content}
if at_users:
msg_param["atUserIds"] = at_users
return self.send_o2o_message(user_ids, self.MSG_TYPES["text"], msg_param)
@staticmethod
def _process_markdown_text(text: str) -> str:
import re
processed = text.replace("\\n", "\n")
processed = re.sub(r"^\s*[-*_]{3,}\s*$", "", processed, flags=re.MULTILINE)
processed = re.sub(r"\n{3,}", "\n\n", processed)
return processed.strip()
def send_o2o_markdown(self, user_ids: List[str], title: str, text: str) -> Dict:
processed_text = self._process_markdown_text(text)
msg_param = {"title": title, "text": processed_text}
return self.send_o2o_message(user_ids, self.MSG_TYPES["markdown"], msg_param)
def send_o2o_link(
self,
user_ids: List[str],
title: str,
text: str,
message_url: str,
pic_url: Optional[str] = None,
) -> Dict:
msg_param = {"title": title, "text": text, "messageUrl": message_url}
if pic_url:
msg_param["picUrl"] = pic_url
return self.send_o2o_message(user_ids, self.MSG_TYPES["link"], msg_param)
def send_o2o_image(
self, user_ids: List[str], media_id: str, caption: Optional[str] = None
) -> Dict:
msg_param = {"mediaId": media_id}
if caption:
msg_param["caption"] = caption
return self.send_o2o_message(user_ids, self.MSG_TYPES["image"], msg_param)
def send_o2o_file(
self,
user_ids: List[str],
media_id: str,
file_name: str,
file_size: Optional[str] = None,
file_type: Optional[str] = None,
) -> Dict:
msg_param = {"mediaId": media_id, "fileName": file_name}
if file_size:
msg_param["fileSize"] = file_size
if file_type:
msg_param["fileType"] = file_type
return self.send_o2o_message(user_ids, self.MSG_TYPES["file"], msg_param)
def send_o2o_voice(
self,
user_ids: List[str],
media_id: str,
duration: str,
file_size: Optional[str] = None,
) -> Dict:
msg_param = {"mediaId": media_id, "duration": duration}
if file_size:
msg_param["fileSize"] = file_size
return self.send_o2o_message(user_ids, self.MSG_TYPES["voice"], msg_param)
def send_o2o_action_card(
self,
user_ids: List[str],
title: str,
markdown: str,
single_title: str,
single_url: str,
) -> Dict:
msg_param = {
"title": title,
"markdown": markdown,
"singleTitle": single_title,
"singleUrl": single_url,
}
return self.send_o2o_message(user_ids, self.MSG_TYPES["action_card"], msg_param)
def send_o2o_action_card_multi(
self,
user_ids: List[str],
title: str,
markdown: str,
buttons: List[Dict],
btn_orientation: str = "0",
) -> Dict:
msg_param = {
"title": title,
"markdown": markdown,
"btnOrientation": btn_orientation,
"btns": buttons,
}
return self.send_o2o_message(
user_ids, self.MSG_TYPES["action_card_multi"], msg_param
)
# ---- 群聊 ----
def send_group_message(
self, open_conversation_id: str, msg_type: str, content: Dict
) -> Dict:
if not open_conversation_id:
raise ValueError("open_conversation_id不能为空")
if not self.config.robot_code:
raise ValueError("发送群聊消息需要配置robot_code")
url = f"{self.config.BASE_URL}/v1.0/robot/groupMessages/send"
headers = {
"x-acs-dingtalk-access-token": self._get_access_token(),
"Content-Type": "application/json",
}
payload = {
"robotCode": self.config.robot_code,
"openConversationId": open_conversation_id,
"msgKey": msg_type,
"msgParam": json.dumps(content, ensure_ascii=False),
}
return self._make_request("POST", url, headers=headers, json=payload)
def send_group_text(
self,
open_conversation_id: str,
content: str,
at_mobiles: Optional[List[str]] = None,
at_user_ids: Optional[List[str]] = None,
is_at_all: bool = False,
) -> Dict:
msg_param = {"content": content, "at": {"isAtAll": is_at_all}}
if at_mobiles:
msg_param["at"]["atMobiles"] = at_mobiles
if at_user_ids:
msg_param["at"]["atUserIds"] = at_user_ids
return self.send_group_message(open_conversation_id, "sampleText", msg_param)
def send_group_markdown(
self,
open_conversation_id: str,
title: str,
text: str,
at_mobiles: Optional[List[str]] = None,
at_user_ids: Optional[List[str]] = None,
is_at_all: bool = False,
) -> Dict:
msg_param = {"title": title, "text": self._process_markdown_text(text)}
if at_mobiles or at_user_ids or is_at_all:
msg_param["at"] = {"isAtAll": is_at_all}
if at_mobiles:
msg_param["at"]["atMobiles"] = at_mobiles
if at_user_ids:
msg_param["at"]["atUserIds"] = at_user_ids
return self.send_group_message(
open_conversation_id, "sampleMarkdown", msg_param
)
def send_group_link(
self,
open_conversation_id: str,
title: str,
text: str,
message_url: str,
pic_url: Optional[str] = None,
) -> Dict:
msg_param = {"title": title, "text": text, "messageUrl": message_url}
if pic_url:
msg_param["picUrl"] = pic_url
return self.send_group_message(open_conversation_id, "sampleLink", msg_param)
def send_group_action_card(
self,
open_conversation_id: str,
title: str,
markdown: str,
single_title: str,
single_url: str,
) -> Dict:
msg_param = {
"title": title,
"markdown": markdown,
"singleTitle": single_title,
"singleUrl": single_url,
}
return self.send_group_message(
open_conversation_id, "sampleActionCard", msg_param
)
def send_group_action_card_multi(
self,
open_conversation_id: str,
title: str,
markdown: str,
buttons: List[Dict],
btn_orientation: str = "0",
) -> Dict:
msg_param = {
"title": title,
"markdown": markdown,
"btnOrientation": btn_orientation,
"btns": buttons,
}
return self.send_group_message(
open_conversation_id, "sampleMultiActionCard", msg_param
)
def send_group_feed_card(
self, open_conversation_id: str, links: List[Dict]
) -> Dict:
return self.send_group_message(
open_conversation_id, "feedCard", {"links": links}
)
# ---- 文件上传 ----
def upload_and_send_o2o_file(
self, user_ids: List[str], file_path: str, file_name: Optional[str] = None
) -> Dict:
import os
media_id = self._upload_media(file_path, "file")
if not file_name:
file_name = os.path.basename(file_path)
file_size = str(os.path.getsize(file_path))
return self.send_o2o_file(user_ids, media_id, file_name, file_size)
def upload_and_send_o2o_image(
self, user_ids: List[str], file_path: str, caption: Optional[str] = None
) -> Dict:
media_id = self._upload_media(file_path, "image")
return self.send_o2o_image(user_ids, media_id, caption)
# ==================== 机器人解析与创建 ====================
def resolve_robot(args, robot_type: str) -> dict:
"""
根据 --robot 参数和配置文件解析出目标机器人配置。
当无任何机器人可用时,提示用户配置。
Args:
args: CLI 参数
robot_type: "app""webhook"
Returns:
机器人配置 dict
"""
config = ensure_config()
robot_name = getattr(args, "robot", None)
# 如果通过命令行传入了完整凭证,直接使用(不走机器人管理)
if robot_type == "webhook":
token = getattr(args, "webhook_token", None)
if token:
return {
"name": "__cli__",
"type": "webhook",
"webhook_token": token,
"webhook_secret": getattr(args, "webhook_secret", None) or "",
}
elif robot_type == "app":
ak = getattr(args, "app_key", None)
if ak:
return {
"name": "__cli__",
"type": "app",
"app_key": ak,
"app_secret": getattr(args, "app_secret", None) or "",
"robot_code": getattr(args, "robot_code", None) or "",
"agent_id": getattr(args, "agent_id", None) or "",
}
robot = find_robot(config, name=robot_name, robot_type=robot_type)
if not robot:
if robot_type == "webhook":
print("错误:未配置 Webhook")
print(
'快速接入python scripts/dingtalk.py webhook-text --webhook-token <token> "消息内容"'
)
print(
"保存配置python scripts/dingtalk.py config --set webhook_token=<token>"
)
else:
print("错误:未配置企业内部机器人")
print(
"添加机器人python scripts/dingtalk.py robot-add --name <名称> --type app --app-key <KEY> --app-secret <SECRET>"
)
sys.exit(1)
return robot
def create_messenger_from_robot(robot: dict) -> DingTalkMessenger:
"""从机器人配置创建企业内部机器人发送器"""
config = DingTalkConfig(
app_key=robot["app_key"],
app_secret=robot["app_secret"],
robot_code=robot.get("robot_code", ""),
agent_id=robot.get("agent_id", ""),
)
return DingTalkMessenger(config)
def create_webhook_from_robot(robot: dict) -> DingTalkWebhookSender:
"""从机器人配置创建 Webhook 发送器"""
secret = robot.get("webhook_secret", "")
if secret and secret.startswith("YOUR_"):
secret = None
config = DingTalkWebhookConfig(
webhook_token=robot["webhook_token"],
secret=secret or None,
)
return DingTalkWebhookSender(config)
def send_and_track(robot: dict, send_fn, msg_summary: Optional[str] = None):
"""执行发送并追踪状态,记录消息摘要"""
name = robot.get("name", "__cli__")
try:
result = send_fn()
success = result.get("errcode", -1) == 0 or "processQueryKey" in result
if name != "__cli__":
update_robot_state(name, success=success, msg_summary=msg_summary)
print(json.dumps(result, ensure_ascii=False, indent=2))
except Exception as e:
if name != "__cli__":
update_robot_state(name, success=False, msg_summary=msg_summary)
print(f"发送失败: {e}", file=sys.stderr)
sys.exit(1)
# ==================== 消息命令 ====================
def _make_summary(msg_type: str, content: str = "", title: str = "") -> str:
"""生成消息摘要,用于状态追踪"""
prefix = f"[{msg_type}]"
text = title or content or ""
text = text.replace("\\n", " ").replace("\n", " ").strip()
if len(text) > 60:
text = text[:60] + "..."
return f"{prefix} {text}" if text else prefix
def cmd_send_text(args):
robot = resolve_robot(args, "app")
messenger = create_messenger_from_robot(robot)
user_ids = args.users.split(",") if args.users else None
summary = _make_summary("text", args.content)
if args.mode == "o2o":
if not user_ids:
print("错误单聊模式需要提供用户ID (--users)")
sys.exit(1)
send_and_track(
robot,
lambda: messenger.send_o2o_text(
user_ids=user_ids,
content=args.content,
at_users=args.at_users.split(",") if args.at_users else None,
),
summary,
)
else:
if not args.conversation_id:
print("错误群聊模式需要提供会话ID (--conversation-id)")
sys.exit(1)
send_and_track(
robot,
lambda: messenger.send_group_text(
open_conversation_id=args.conversation_id,
content=args.content,
at_mobiles=args.at_mobiles.split(",") if args.at_mobiles else None,
at_user_ids=args.at_users.split(",") if args.at_users else None,
is_at_all=args.at_all,
),
summary,
)
def cmd_send_markdown(args):
robot = resolve_robot(args, "app")
messenger = create_messenger_from_robot(robot)
user_ids = args.users.split(",") if args.users else None
summary = _make_summary("markdown", args.content, args.title)
if args.mode == "o2o":
if not user_ids:
print("错误单聊模式需要提供用户ID (--users)")
sys.exit(1)
send_and_track(
robot,
lambda: messenger.send_o2o_markdown(
user_ids=user_ids, title=args.title, text=args.content
),
summary,
)
else:
if not args.conversation_id:
print("错误群聊模式需要提供会话ID (--conversation-id)")
sys.exit(1)
send_and_track(
robot,
lambda: messenger.send_group_markdown(
open_conversation_id=args.conversation_id,
title=args.title,
text=args.content,
at_mobiles=args.at_mobiles.split(",") if args.at_mobiles else None,
at_user_ids=args.at_users.split(",") if args.at_users else None,
is_at_all=args.at_all,
),
summary,
)
def cmd_send_link(args):
robot = resolve_robot(args, "app")
messenger = create_messenger_from_robot(robot)
user_ids = args.users.split(",") if args.users else None
summary = _make_summary("link", args.content, args.title)
if args.mode == "o2o":
if not user_ids:
print("错误单聊模式需要提供用户ID (--users)")
sys.exit(1)
send_and_track(
robot,
lambda: messenger.send_o2o_link(
user_ids=user_ids,
title=args.title,
text=args.content,
message_url=args.url,
pic_url=args.pic_url,
),
summary,
)
else:
if not args.conversation_id:
print("错误群聊模式需要提供会话ID (--conversation-id)")
sys.exit(1)
send_and_track(
robot,
lambda: messenger.send_group_link(
open_conversation_id=args.conversation_id,
title=args.title,
text=args.content,
message_url=args.url,
pic_url=args.pic_url,
),
summary,
)
def cmd_send_action_card(args):
robot = resolve_robot(args, "app")
messenger = create_messenger_from_robot(robot)
user_ids = args.users.split(",") if args.users else None
summary = _make_summary("action_card", args.content, args.title)
if args.buttons:
buttons = []
for btn in args.buttons.split(";"):
parts = btn.split(",")
if len(parts) == 2:
buttons.append({"title": parts[0], "url": parts[1]})
if args.mode == "o2o":
if not user_ids:
print("错误单聊模式需要提供用户ID (--users)")
sys.exit(1)
send_and_track(
robot,
lambda: messenger.send_o2o_action_card_multi(
user_ids=user_ids,
title=args.title,
markdown=args.content,
buttons=buttons,
btn_orientation=args.btn_orientation,
),
summary,
)
else:
if not args.conversation_id:
print("错误群聊模式需要提供会话ID (--conversation-id)")
sys.exit(1)
send_and_track(
robot,
lambda: messenger.send_group_action_card_multi(
open_conversation_id=args.conversation_id,
title=args.title,
markdown=args.content,
buttons=buttons,
btn_orientation=args.btn_orientation,
),
summary,
)
else:
if args.mode == "o2o":
if not user_ids:
print("错误单聊模式需要提供用户ID (--users)")
sys.exit(1)
send_and_track(
robot,
lambda: messenger.send_o2o_action_card(
user_ids=user_ids,
title=args.title,
markdown=args.content,
single_title=args.single_title,
single_url=args.url,
),
summary,
)
else:
if not args.conversation_id:
print("错误群聊模式需要提供会话ID (--conversation-id)")
sys.exit(1)
send_and_track(
robot,
lambda: messenger.send_group_action_card(
open_conversation_id=args.conversation_id,
title=args.title,
markdown=args.content,
single_title=args.single_title,
single_url=args.url,
),
summary,
)
def cmd_send_file(args):
robot = resolve_robot(args, "app")
messenger = create_messenger_from_robot(robot)
user_ids = args.users.split(",") if args.users else None
if not user_ids:
print("错误需要提供用户ID (--users)")
sys.exit(1)
summary = _make_summary("file", args.file_name or args.file)
send_and_track(
robot,
lambda: messenger.upload_and_send_o2o_file(
user_ids=user_ids, file_path=args.file, file_name=args.file_name
),
summary,
)
def cmd_webhook_text(args):
robot = resolve_robot(args, "webhook")
sender = create_webhook_from_robot(robot)
summary = _make_summary("webhook-text", args.content)
send_and_track(
robot,
lambda: sender.send_text(
content=args.content,
at_mobiles=args.at_mobiles.split(",") if args.at_mobiles else None,
at_user_ids=args.at_users.split(",") if args.at_users else None,
is_at_all=args.at_all,
),
summary,
)
def cmd_webhook_markdown(args):
robot = resolve_robot(args, "webhook")
sender = create_webhook_from_robot(robot)
summary = _make_summary("webhook-md", args.content, args.title)
send_and_track(
robot,
lambda: sender.send_markdown(
title=args.title,
text=args.content,
at_mobiles=args.at_mobiles.split(",") if args.at_mobiles else None,
at_user_ids=args.at_users.split(",") if args.at_users else None,
is_at_all=args.at_all,
),
summary,
)
def cmd_webhook_link(args):
robot = resolve_robot(args, "webhook")
sender = create_webhook_from_robot(robot)
summary = _make_summary("webhook-link", args.content, args.title)
send_and_track(
robot,
lambda: sender.send_link(
title=args.title,
text=args.content,
message_url=args.url,
pic_url=args.pic_url,
),
summary,
)
def cmd_webhook_action_card(args):
robot = resolve_robot(args, "webhook")
sender = create_webhook_from_robot(robot)
summary = _make_summary("webhook-card", args.content, args.title)
if args.buttons:
buttons = []
for btn in args.buttons.split(";"):
parts = btn.split(",")
if len(parts) == 2:
buttons.append({"title": parts[0], "actionURL": parts[1]})
send_and_track(
robot,
lambda: sender.send_action_card_multi(
title=args.title,
text=args.content,
buttons=buttons,
btn_orientation=args.btn_orientation,
),
summary,
)
else:
send_and_track(
robot,
lambda: sender.send_action_card(
title=args.title,
text=args.content,
single_title=args.single_title,
single_url=args.url,
btn_orientation=args.btn_orientation,
),
summary,
)
def cmd_webhook_feed_card(args):
robot = resolve_robot(args, "webhook")
sender = create_webhook_from_robot(robot)
links = []
for link_str in args.links.split(";"):
parts = link_str.split(",")
if len(parts) >= 2:
link_item = {"title": parts[0], "messageURL": parts[1]}
if len(parts) >= 3:
link_item["picURL"] = parts[2]
links.append(link_item)
titles = [l.get("title", "") for l in links[:3]]
summary = _make_summary("webhook-feed", ", ".join(titles))
send_and_track(robot, lambda: sender.send_feed_card(links=links), summary)
# ==================== 机器人管理命令 ====================
def cmd_robot_add(args):
"""添加机器人"""
config = ensure_config()
robots = config.get("robots", [])
# 检查名称重复
for r in robots:
if r["name"] == args.name:
print(f"错误:机器人 '{args.name}' 已存在,请使用其他名称或先删除")
sys.exit(1)
robot = {"name": args.name, "type": args.type}
desc = getattr(args, "desc", None) or ""
if desc:
robot["description"] = desc
if args.type == "webhook":
token = getattr(args, "webhook_token", None)
if not token:
print("错误Webhook 机器人需要提供 --webhook-token")
sys.exit(1)
robot["webhook_token"] = token
robot["webhook_secret"] = getattr(args, "webhook_secret", None) or ""
elif args.type == "app":
ak = getattr(args, "app_key", None)
aks = getattr(args, "app_secret", None)
if not ak or not aks:
print("错误:企业内部机器人需要提供 --app-key 和 --app-secret")
sys.exit(1)
robot["app_key"] = ak
robot["app_secret"] = aks
robot["robot_code"] = getattr(args, "robot_code", None) or ""
robot["agent_id"] = getattr(args, "agent_id", None) or ""
robots.append(robot)
config["robots"] = robots
# 如果是第一个,设为默认
if len(robots) == 1 or not config.get("default_robot"):
config["default_robot"] = args.name
save_config(config)
# 初始化状态
state = load_state()
if "robots" not in state:
state["robots"] = {}
state["robots"][args.name] = {
"enabled": True,
"last_used": None,
"use_count": 0,
"last_status": None,
"recent_messages": [],
}
save_state(state)
is_default = config["default_robot"] == args.name
desc_str = f" - {desc}" if desc else ""
print(
f"已添加机器人: {args.name} (类型: {args.type}){desc_str}{' [默认]' if is_default else ''}"
)
print(f" 配置文件: {CONFIG_PATH}")
def cmd_robot_list(args):
"""列出所有机器人及状态"""
config = ensure_config()
robots = config.get("robots", [])
state = load_state()
robot_states = state.get("robots", {})
default_name = config.get("default_robot")
if not robots:
print("暂无机器人配置")
print(
"添加机器人python scripts/dingtalk.py robot-add --name <名称> --type webhook --webhook-token <token>"
)
return
print(f"配置文件: {CONFIG_PATH}")
print()
print(f"{len(robots)} 个机器人:")
print()
for r in robots:
name = r["name"]
rtype = r["type"]
rs = robot_states.get(name, {})
enabled = rs.get("enabled", True)
last_used = rs.get("last_used") or "-"
use_count = rs.get("use_count", 0)
last_status = rs.get("last_status") or "-"
is_default = name == default_name
markers = []
if is_default:
markers.append("默认")
if not enabled:
markers.append("已禁用")
marker_str = f" [{', '.join(markers)}]" if markers else ""
desc = r.get("description", "")
desc_str = f" - {desc}" if desc else ""
print(f" {name}{marker_str}{desc_str}")
print(f" 类型: {rtype}")
if rtype == "webhook":
token = r.get("webhook_token", "")
if token:
print(f" Token: {token[:8]}***")
has_sign = bool(r.get("webhook_secret"))
print(f" 加签: {'' if has_sign else ''}")
else:
print(f" AppKey: {r.get('app_key', '')[:8]}***")
print(f" RobotCode: {r.get('robot_code', '-')}")
print(f" 使用次数: {use_count}")
print(f" 最近使用: {last_used}")
print(f" 最近状态: {last_status}")
# 显示最近消息摘要
recent = rs.get("recent_messages", [])
if recent:
print(f" 最近消息:")
for msg in recent[-3:]:
ts = msg.get("time", "")
summary = msg.get("summary", "")
print(f" [{ts}] {summary}")
print()
def cmd_robot_remove(args):
"""删除机器人"""
config = ensure_config()
robots = config.get("robots", [])
found = any(r["name"] == args.name for r in robots)
if not found:
print(f"错误:找不到机器人 '{args.name}'")
sys.exit(1)
config["robots"] = [r for r in robots if r["name"] != args.name]
if config.get("default_robot") == args.name:
config["default_robot"] = (
config["robots"][0]["name"] if config["robots"] else None
)
save_config(config)
# 清理状态
state = load_state()
if args.name in state.get("robots", {}):
del state["robots"][args.name]
save_state(state)
print(f"已删除机器人: {args.name}")
if config.get("default_robot"):
print(f"当前默认机器人: {config['default_robot']}")
def cmd_robot_default(args):
"""设置默认机器人"""
config = ensure_config()
robots = config.get("robots", [])
found = False
for r in robots:
if r["name"] == args.name:
found = True
break
if not found:
print(f"错误:找不到机器人 '{args.name}'")
print(f"可用机器人:{', '.join(r['name'] for r in robots)}")
sys.exit(1)
config["default_robot"] = args.name
save_config(config)
print(f"已将默认机器人设为: {args.name}")
def cmd_robot_enable(args):
"""启用/禁用机器人"""
config = ensure_config()
robots = config.get("robots", [])
found = False
for r in robots:
if r["name"] == args.name:
found = True
break
if not found:
print(f"错误:找不到机器人 '{args.name}'")
sys.exit(1)
state = load_state()
if "robots" not in state:
state["robots"] = {}
if args.name not in state["robots"]:
state["robots"][args.name] = {
"enabled": True,
"last_used": None,
"use_count": 0,
"last_status": None,
}
enabled = not args.disable
state["robots"][args.name]["enabled"] = enabled
save_state(state)
status = "启用" if enabled else "禁用"
print(f"{status}机器人: {args.name}")
def cmd_robot_update(args):
"""更新机器人信息(描述等)"""
config = ensure_config()
robots = config.get("robots", [])
target = None
for r in robots:
if r["name"] == args.name:
target = r
break
if not target:
print(f"错误:找不到机器人 '{args.name}'")
sys.exit(1)
updated = []
if args.desc is not None:
target["description"] = args.desc
updated.append(f"描述: {args.desc}" if args.desc else "描述: (已清除)")
if args.rename:
# 检查新名称是否重复
for r in robots:
if r["name"] == args.rename and r is not target:
print(f"错误:名称 '{args.rename}' 已被使用")
sys.exit(1)
old_name = target["name"]
target["name"] = args.rename
# 同步更新 default_robot
if config.get("default_robot") == old_name:
config["default_robot"] = args.rename
# 同步更新状态文件中的名称
state = load_state()
if old_name in state.get("robots", {}):
state["robots"][args.rename] = state["robots"].pop(old_name)
save_state(state)
updated.append(f"名称: {old_name} -> {args.rename}")
if not updated:
print("未指定更新内容,可用参数:--desc, --rename")
return
save_config(config)
print(f"已更新机器人 {args.name}:")
for u in updated:
print(f" {u}")
# ==================== 配置管理命令 ====================
def cmd_config(args):
"""管理配置"""
sensitive_keys = ("app_secret", "webhook_secret", "webhook_token")
def mask_value(val):
"""对敏感字段做截断隐藏"""
if not val or str(val).startswith("YOUR_"):
return val
return val[:8] + "***" if len(val) > 8 else "***"
def mask_config(cfg: dict) -> dict:
"""隐藏配置中的敏感信息"""
display = json.loads(json.dumps(cfg))
for key in sensitive_keys:
if key in display:
display[key] = mask_value(display[key])
if "robots" in display:
for r in display["robots"]:
for key in sensitive_keys:
if key in r:
r[key] = mask_value(r[key])
return display
if args.show:
config = load_config()
if not config:
print(f"配置文件不存在: {CONFIG_PATH}")
print()
print(
"添加机器人robot-add --name <名称> --type webhook --webhook-token <token>"
)
return
print(f"配置文件: {CONFIG_PATH}")
print(json.dumps(mask_config(config), ensure_ascii=False, indent=2))
elif args.set:
config = load_config()
if not config:
config = {"default_robot": None, "robots": []}
key, value = args.set.split("=", 1)
config[key] = value
save_config(config)
print(f"已设置 {key},配置文件: {CONFIG_PATH}")
elif args.init:
if CONFIG_PATH.exists() and not args.force:
print(f"配置文件已存在: {CONFIG_PATH}")
print("使用 --force 覆盖")
return
default_config = {"default_robot": None, "robots": []}
save_config(default_config)
print(f"已创建配置文件: {CONFIG_PATH}")
print(
"添加机器人robot-add --name <名称> --type webhook --webhook-token <token>"
)
# ==================== CLI 参数定义 ====================
def add_robot_arg(parser):
"""添加 --robot 参数"""
parser.add_argument("--robot", help="指定机器人名称(不指定则使用默认/最近使用的)")
def add_common_args(parser):
"""添加企业内部机器人参数"""
add_robot_arg(parser)
parser.add_argument("--app-key", help="应用AppKey覆盖配置")
parser.add_argument("--app-secret", help="应用AppSecret覆盖配置")
parser.add_argument("--robot-code", help="机器人编号(覆盖配置)")
parser.add_argument("--agent-id", help="应用AgentID覆盖配置")
def add_webhook_args(parser):
"""添加 Webhook 参数"""
add_robot_arg(parser)
parser.add_argument("--webhook-token", help="Webhook access_token覆盖配置")
parser.add_argument("--webhook-secret", help="加签密钥(覆盖配置)")
def add_webhook_at_args(parser):
parser.add_argument("--at-users", help="@的用户ID列表逗号分隔")
parser.add_argument("--at-mobiles", help="@的手机号列表,逗号分隔")
parser.add_argument("--at-all", action="store_true", help="@所有人")
def add_message_args(parser):
parser.add_argument(
"--mode",
choices=["o2o", "group"],
default="o2o",
help="发送模式o2o=单聊group=群聊",
)
parser.add_argument("--users", help="用户ID列表逗号分隔")
parser.add_argument("--conversation-id", help="群聊会话ID")
parser.add_argument("--at-users", help="@的用户ID列表逗号分隔")
parser.add_argument("--at-mobiles", help="@的手机号列表,逗号分隔")
parser.add_argument("--at-all", action="store_true", help="@所有人")
def add_robot_credential_args(parser):
"""robot-add 命令的凭证参数"""
parser.add_argument("--webhook-token", help="Webhook access_token")
parser.add_argument("--webhook-secret", help="加签密钥")
parser.add_argument("--app-key", help="应用AppKey")
parser.add_argument("--app-secret", help="应用AppSecret")
parser.add_argument("--robot-code", help="机器人编号")
parser.add_argument("--agent-id", help="应用AgentID")
# ==================== main ====================
def main():
parser = argparse.ArgumentParser(description="钉钉消息发送工具(支持多机器人管理)")
subparsers = parser.add_subparsers(dest="command", help="子命令")
# ---- 企业内部机器人消息 ----
text_p = subparsers.add_parser("text", help="发送文本消息(企业内部机器人)")
add_common_args(text_p)
add_message_args(text_p)
text_p.add_argument("content", help="消息内容")
md_p = subparsers.add_parser("markdown", help="发送Markdown消息企业内部机器人")
add_common_args(md_p)
add_message_args(md_p)
md_p.add_argument("--title", required=True, help="消息标题")
md_p.add_argument("content", help="Markdown内容")
link_p = subparsers.add_parser("link", help="发送链接消息(企业内部机器人)")
add_common_args(link_p)
add_message_args(link_p)
link_p.add_argument("--title", required=True, help="链接标题")
link_p.add_argument("content", help="链接描述")
link_p.add_argument("--url", required=True, help="链接地址")
link_p.add_argument("--pic-url", help="缩略图URL")
ac_p = subparsers.add_parser(
"action-card", help="发送ActionCard消息企业内部机器人"
)
add_common_args(ac_p)
add_message_args(ac_p)
ac_p.add_argument("--title", required=True, help="卡片标题")
ac_p.add_argument("content", help="卡片内容(Markdown)")
ac_p.add_argument("--single-title", help="单按钮标题")
ac_p.add_argument("--url", help="单按钮链接")
ac_p.add_argument("--buttons", help="多按钮格式标题1,链接1;标题2,链接2")
ac_p.add_argument("--btn-orientation", default="0", help="按钮排列0=竖排1=横排")
file_p = subparsers.add_parser("file", help="发送文件(企业内部机器人)")
add_common_args(file_p)
file_p.add_argument("--users", required=True, help="用户ID列表逗号分隔")
file_p.add_argument("--file", required=True, help="文件路径")
file_p.add_argument("--file-name", help="自定义文件名")
# ---- Webhook 消息 ----
wt_p = subparsers.add_parser("webhook-text", help="通过 Webhook 发送文本消息")
add_webhook_args(wt_p)
add_webhook_at_args(wt_p)
wt_p.add_argument("content", help="消息内容")
wm_p = subparsers.add_parser(
"webhook-markdown", help="通过 Webhook 发送 Markdown 消息"
)
add_webhook_args(wm_p)
add_webhook_at_args(wm_p)
wm_p.add_argument("--title", required=True, help="消息标题")
wm_p.add_argument("content", help="Markdown 内容")
wl_p = subparsers.add_parser("webhook-link", help="通过 Webhook 发送链接消息")
add_webhook_args(wl_p)
wl_p.add_argument("--title", required=True, help="链接标题")
wl_p.add_argument("content", help="链接描述")
wl_p.add_argument("--url", required=True, help="链接地址")
wl_p.add_argument("--pic-url", help="缩略图URL")
wac_p = subparsers.add_parser(
"webhook-action-card", help="通过 Webhook 发送 ActionCard"
)
add_webhook_args(wac_p)
wac_p.add_argument("--title", required=True, help="卡片标题")
wac_p.add_argument("content", help="卡片内容(Markdown)")
wac_p.add_argument("--single-title", help="单按钮标题")
wac_p.add_argument("--url", help="单按钮链接")
wac_p.add_argument("--buttons", help="多按钮格式标题1,链接1;标题2,链接2")
wac_p.add_argument("--btn-orientation", default="0", help="按钮排列")
wfc_p = subparsers.add_parser(
"webhook-feed-card", help="通过 Webhook 发送 FeedCard"
)
add_webhook_args(wfc_p)
wfc_p.add_argument(
"--links",
required=True,
help="链接列表格式标题1,链接1,图片1;标题2,链接2,图片2",
)
# ---- 机器人管理 ----
ra_p = subparsers.add_parser("robot-add", help="添加机器人")
ra_p.add_argument(
"--name", required=True, help="机器人名称(唯一标识,如:技术告警群)"
)
ra_p.add_argument("--type", required=True, choices=["app", "webhook"], help="类型")
ra_p.add_argument("--desc", help="机器人描述(用途说明,如:发送告警到技术群)")
add_robot_credential_args(ra_p)
rl_p = subparsers.add_parser("robot-list", help="列出所有机器人及状态")
rr_p = subparsers.add_parser("robot-remove", help="删除机器人")
rr_p.add_argument("--name", required=True, help="机器人名称")
ru_p = subparsers.add_parser("robot-update", help="更新机器人信息")
ru_p.add_argument("--name", required=True, help="机器人名称")
ru_p.add_argument("--desc", help="更新描述")
ru_p.add_argument("--rename", help="重命名")
rd_p = subparsers.add_parser("robot-default", help="设置默认机器人")
rd_p.add_argument("--name", required=True, help="机器人名称")
re_p = subparsers.add_parser("robot-enable", help="启用/禁用机器人")
re_p.add_argument("--name", required=True, help="机器人名称")
re_p.add_argument("--disable", action="store_true", help="禁用(不加则启用)")
# ---- 配置管理 ----
cfg_p = subparsers.add_parser("config", help="管理配置文件")
cfg_p.add_argument("--show", action="store_true", help="显示当前配置")
cfg_p.add_argument("--set", help="设置配置项格式key=value")
cfg_p.add_argument("--init", action="store_true", help="初始化配置文件")
cfg_p.add_argument("--force", action="store_true", help="强制覆盖")
# ---- 解析 ----
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
CMD_MAP = {
"text": cmd_send_text,
"markdown": cmd_send_markdown,
"link": cmd_send_link,
"action-card": cmd_send_action_card,
"file": cmd_send_file,
"webhook-text": cmd_webhook_text,
"webhook-markdown": cmd_webhook_markdown,
"webhook-link": cmd_webhook_link,
"webhook-action-card": cmd_webhook_action_card,
"webhook-feed-card": cmd_webhook_feed_card,
"robot-add": cmd_robot_add,
"robot-list": cmd_robot_list,
"robot-remove": cmd_robot_remove,
"robot-update": cmd_robot_update,
"robot-default": cmd_robot_default,
"robot-enable": cmd_robot_enable,
"config": cmd_config,
}
handler = CMD_MAP.get(args.command)
if handler:
handler(args)
else:
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()