redmine-dingtalk-notify
Overview
把 Redmine 7.x 实例上 issue 的创建/更新事件,转译为钉钉群机器人消息,支持按花名册 username→display_name 自动 @ 提及。本 skill 是功能契约而非具体栈实现:
- 核心逻辑由三个纯函数模块实现,框架无关 (scripts/parse_payload.py / load_users.py / send_dingtalk.py)
- 入口方式留给部署方自选:
- 想要 CLI(零 Web 框架,文件/stdin/watch 三种入口):用 scripts/cli_mode.py
- 想要 HTTP webhook 入口:挑任意 Web 框架挂上三个纯函数即可
- 想要消息队列入口:同理,只需把 payload 灌给核心函数
- 花名册数据源支持 5 种内置(xlsx / txt / inline / json / csv)+ 任意自定义;不在 skill 仓库里的源(db / ldap / api)通过替换实现接入
- 所有接入参数(实例地址、token、花名册源、钉钉 webhook、加签 secret)在首次运行时通过交互式问答补齐,后续写入 config.py(不拆 .env)
何时使用本 skill
- 用户希望把 Redmine 问题变更同步到钉钉群机器人
- 同一组"Redmine→钉钉"逻辑要复用到多个项目、多实例、多机器人
- 需要按花名册
username → display_name实现群内 @ 提及 - 旧版 Jira→钉钉 工作流改造到 Redmine→钉钉
数据流
[上游事件源]
| (webhook POST / 文件 / stdin / MQ)
v
[接收入口(部署方自由选择:任何 Web 框架 / CLI / MQ 消费者)]
|
v
parse_payload.parse(payload) ──> 标准事件 dict
|
v
load_users.load_roster_from_config(cfg) ──> {username: display_name} 字典
↓
load_users.get_display_name(roster, assignee) ──> @ 列表
|
v
send_dingtalk.render_issue_card(event, ats) ──> 卡片
|
v
send_dingtalk.send_markdown(webhook, secret, ...) ──> 钉钉 API
|
v
[日志 logs/{date}.log + 重试 / 异常分支]
运行时引导:向用户询问的关键信息
skill 触发后,模型第一步先检查 config.py 是否齐全。缺失项必须通过 AskUserQuestion 一次性向用户索齐;补齐后先回显汇总让用户确认再写入 config.py。
必填参数清单(5 项 + 1 项花名册源)
| 参数 | 说明 | 形如 |
|---|---|---|
REDMINE_BASE_URL |
Redmine 实例地址(不要带尾部斜杠) | https://redmine.example.com |
REDMINE_API_TOKEN |
Redmine 个人 REST API token(账户→个人资料→API 密钥) | 一串十六进制字符串,直接整段粘贴 |
DINGTALK_WEBHOOK_URL |
钉钉机器人 webhook,含 query 中的 access_token |
https://oapi.dingtalk.com/robot/send?access_token=XXX |
DINGTALK_WEBHOOK_SECRET |
加签密钥(机器人开启"加签"时才有,否则留空) | SEC... |
USER_ROSTER_SOURCE |
花名册数据源类型(决定下面要追问哪些子字段) | xlsx / txt / inline / json / csv |
花名册源对应要追问的子字段
| 源 | 必追问字段 | 可选字段 |
|---|---|---|
xlsx |
USER_ROSTER_XLSX(路径)、USER_ROSTER_SHEET(默认 Sheet1)、USER_ROSTER_USERNAME_COL、USER_ROSTER_DISPLAYNAME_COL |
- |
txt |
USER_ROSTER_TXT(路径) |
USER_ROSTER_TXT_DELIM、USER_ROSTER_TXT_COMMENT、USER_ROSTER_TXT_HAS_HEADER |
inline |
USER_ROSTER_INLINE(JSON/Python 字面量字典) |
- |
json |
USER_ROSTER_JSON(路径) |
- |
csv |
USER_ROSTER_CSV(路径) |
USER_ROSTER_CSV_DELIM、USER_ROSTER_HAS_HEADER |
可选参数(其它)
| 参数 | 说明 | 默认 |
|---|---|---|
REDMINE_WEBHOOK_SECRET |
webhook 签名校验密钥 | 留空不校验 |
DINGTALK_AT_TYPE |
@ 策略:all / mobiles / user_ids / assignee |
assignee |
DINGTALK_AT_MOBILES |
当 AT_TYPE=mobiles 时的逗号分隔手机号列表 | "" |
DINGTALK_AT_USER_IDS |
当 AT_TYPE=user_ids 时的逗号分隔 user_id 列表 | "" |
RETRY_TIMES |
推送失败重试次数 | 3 |
> 注:故意不包含 Web 框架/监听端口/内网穿透等"部署层"参数——这些由部署方在外部决定。
花名册数据源(5 种内置 + 自定义)
内置 source 矩阵
| 源 | 适用场景 | 字段依赖 | 第三方依赖 |
|---|---|---|---|
| xlsx | 部门/项目维护的 Excel | USER_ROSTER_XLSX / SHEET / USERNAME_COL / DISPLAYNAME_COL |
openpyxl |
| txt | 几行字、纯文本维护;轻量;可手写 | USER_ROSTER_TXT;自动嗅探 = / | / TAB / , 分隔 |
无 |
| inline | 就几个固定人员,直接写在 config 里 | USER_ROSTER_INLINE(JSON/Python 字面量字典) |
无 |
| json | 已有 JSON 数据源,如其它系统导出 | USER_ROSTER_JSON |
无(用内置 json) |
| csv | 业务侧系统已经按 CSV 维护 | USER_ROSTER_CSV / CSV_DELIM / HAS_HEADER / 列名 |
无(用内置 csv) |
自定义 source(非内置)
如果你想要 db(SQL) / ldap(公司 AD) / api(钉钉通讯录) 这些重依赖的源,有两种做法:
做法 A(替换整个模块) —— scripts/load_users.py 是个 framework-agnostic 模块,保持 load_roster_from_config(cfg) -> Dict[str, str] 签名不变,你可以直接 fork 一份改实现。skill 的其他脚本不用动。
做法 B(保留本 skill,自挂额外模块) —— 在 skill 目录外自己写 my_roster.py,在 cli_mode.py 启动前 monkey-patch load_users.load_roster_from_config 指向自己的实现。
不推荐:把 db 客户端/ldappool/requests 推到 skill 仓库里 —— 那是你的部署方环境,不该污染通用 skill。
txt 源的行格式示例
每行一条 username / display_name 记录。空行 / # 开头为注释。分隔符自动嗅探,或 USER_ROSTER_TXT_DELIM 显式指定:
# 注释行,会被忽略
alice = Alice
bob | Bob 王
carol<TAB>Carol 张
dave,Dave
eric Eric 林 # 任意空白(空格/多空格)也支持
实现与扩展点
本 skill 提供的实现
| 路径 | 作用 | 框架依赖 |
|---|---|---|
scripts/parse_payload.py |
Redmine webhook payload 标准化 | 无 |
scripts/load_users.py |
花名册读取(5 种 source 派发) + username → display_name |
仅 xlsx 模式需 openpyxl |
scripts/send_dingtalk.py |
钉钉 Markdown/Text 渲染 + 加签 + POST | requests |
scripts/cli_mode.py |
命令行入口(stdin / 文件 / watch 三种) | 无 Web 框架 |
扩展点(常见替换)
| 替换目标 | 原实现 | 替换做法 |
|---|---|---|
| HTTP 框架 | 暂时未内置(CLI 模式无 web) | 用任意框架(如 Flask、FastAPI、aiohttp、Bottle),app.post("/webhook") 中调用 parse_payload.parse → load_users.get_display_name → send_dingtalk.send_markdown |
| HTTP 客户端 | requests |
替换为 httpx / urllib / aiohttp,只需保持 send_markdown(webhook, secret, ...) 签名不变 |
| Excel 库 | openpyxl(仅 xlsx 源需要) |
替换为 pandas / pyexcel,保持 _from_xlsx 函数签名不变 |
| 花名册源类型 | xlsx / txt / inline / json / csv | 见上面"花名册数据源(5 种内置 + 自定义)"章节 |
| 消息类型 | Markdown 卡片 | 替换为 ActionCard / LinkCard / FeedCard,改 send_dingtalk.py 中的 render_issue_card,签名向后兼容 |
| 推送频率 | 实时 | 加队列(RabbitMQ / Kafka / Redis Streams),消费侧调用同一核心函数 |
| 加签算法 | 钉钉官方 HmacSHA256 + base64 | 不变(公开协议) |
三个纯函数签名(扩展点主参考)
from parse_payload import parse # payload -> event dict
from load_users import load_roster_from_config, get_display_name # cfg -> dict, lookup -> display_name
from send_dingtalk import send_markdown, render_issue_card, DingTalkError
# 任意外部驱动代码的标准流程:
roster = load_roster_from_config(cfg) # 1. 按 cfg["USER_ROSTER_SOURCE"] 派发数据源
event = parse(raw_payload) # 2. 解析
card = render_issue_card(event, at_names) # 3. 渲染卡片(at_names 由 get_display_name 拼)
resp = send_markdown(webhook, secret, card["title"], card["markdown"], ...) # 4. 推
部署步骤(不绑任何具体栈)
0. 安装依赖
| 场景 | 依赖 |
|---|---|
| 仅 CLI 模式 + xlsx 花名册 | pip install requests openpyxl |
| 仅 CLI 模式 + 其他花名册源(txt/json/csv/inline) | pip install requests |
| 还要用 HTTP 入口 | 上述 + 你选的框架(如 pip install fastapi uvicorn) |
1. 配置写入 config.py
- 模型通过
AskUserQuestion一次性问齐 5 项核心必填 +USER_ROSTER_SOURCE选择的对应子项 - 回显汇总后写入
config.py(敏感字段全部经此文件,不出现于源码)
2. 入口选择(三种)
| 入口 | 命令 / 接入方式 |
|---|---|
| CLI 一次性 | echo '{...}' \| python -m scripts.cli_mode |
| CLI 文件 | python -m scripts.cli_mode payload.json |
| CLI watch | python -m scripts.cli_mode --watch /path/to/inbox/ |
| HTTP webhook(自选框架) | 任意框架;示例骨架参见下文"5. HTTP 入口骨架" |
3. 让 Redmine 能访问到入口
- 入口必须能从公网访问,具体暴露方式由部署方决定(nat 映射 / 反向代理 / 内网穿透工具皆可;不限制具体技术)
4. 在 Redmine 项目 → 配置 → webhook
- 新建一条 webhook,URL 对应步骤 2 选择的入口,推送内容选 JSON 完整包
5. HTTP 入口骨架(任选其一作为参考)
FastAPI 例子(非 skill 内置代码,部署方自写):
from fastapi import FastAPI, Request
from scripts.parse_payload import parse
from scripts.load_users import load_roster_from_config, get_display_name
from scripts.send_dingtalk import send_markdown, render_issue_card
app = FastAPI()
CONFIG = {...} # 你的配置 dict,字段与 config.py.example 对应
@app.post("/redmine/webhook")
async def handle(req: Request):
payload = await req.json()
event = parse(payload)
roster = load_roster_from_config(CONFIG)
# ... 写发送逻辑(可复用 cli_mode 里的 handle_payload 函数)
return {"ok": True}
> 不同框架的骨架差异只在 app.post(...) 这一段;核心三步(load_roster_from_config → parse → roster lookup → send_markdown)保持不变。
失败与重试
- 网络错误 / 钉钉返回非
0错误码 → 触发重试 - 最多
RETRY_TIMES次,指数退避 1s / 2s / 4s - 仍失败 → 写入
logs/{YYYY-MM-DD}.log,本条事件结束(不影响后续事件)
安全注意
config.py内含敏感 token,不要提交到 Git;.gitignore中加**/config.py与logs/- 日志文件自动脱敏:
access_token、SEC...、API token 仅留前 4 +***+ 后 4 - 加签 secret 不进日志
字段说明
- Redmine webhook payload 结构与字段差异,见
references/redmine-payload.md - 配置项完整列表与默认值,见
config.py.example
变更与维护
- 修改本 skill 后,用
package_skill.py重新打包 - 修改脚本后,在本文件底部记录 changelog
Changelog
v0.1.2
- 修复:
scripts/cli_mode.py启动引导文本只 print 了三步补齐方式、没列出 5 项必填字段。原因是from config_example import INTERVIEW_QUESTIONS找不到文件而静默吞掉。改为用exec()加载config.py.example到临时 module,从INTERVIEW_QUESTIONS_BASE拉字段列表输出。验证:无 config.py 时 cli_mode 启动会列出 5 项基础字段 + 提示 USER_ROSTER_SOURCE 选定后追问子项
v0.1.1
- 修复:
_from_txt增加空白分隔嗅探(常见于手写 txt 文件如alice Alice) - 修复:
package_skill.py把 logs/ 误打进 zip 的问题——删除 logs/ + 新增.gitignore - 文档: SKILL.md 的 txt 示例增加
eric Eric 林行,明示支持空白分隔 - 验证: 在完全隔离的虚拟用户环境(
.workbuddy/skills-test/+ acme-corp 团队配置 + txt 花名册源)端到端跑通,卡片@Lily Chen 张命中
v0.1.0
- 初版骨架
- scripts/parse_payload.py / load_users.py / send_dingtalk.py / cli_mode.py
- 5 种花名册数据源派发:xlsx / txt / inline / json / csv
- SKILL.md frontmatter + 工作流 + 引导问答 + 实现与扩展点