Skip to content

Commit 5b45666

Browse files
committed
dingtalk reply with markdown as default
1 parent 7fe35a1 commit 5b45666

File tree

4 files changed

+99
-20
lines changed

4 files changed

+99
-20
lines changed

backend/cmd/sop-chat-server/main.go

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ func main() {
4545
return
4646
}
4747

48+
// start 子命令:效果等同于直接运行(移除子命令参数后继续正常启动)
49+
if len(os.Args) >= 2 && os.Args[1] == "start" {
50+
os.Args = append(os.Args[:1], os.Args[2:]...)
51+
}
52+
4853
// 解析命令行参数
4954
var configPath string
5055
var showHelp bool
@@ -58,6 +63,7 @@ func main() {
5863
fmt.Fprintf(os.Stderr, "SOP Chat API Server\n\n")
5964
fmt.Fprintf(os.Stderr, "用法: %s [子命令] [选项]\n\n", os.Args[0])
6065
fmt.Fprintf(os.Stderr, "子命令:\n")
66+
fmt.Fprintf(os.Stderr, " start 启动服务(效果同直接运行,可附带选项)\n")
6167
fmt.Fprintf(os.Stderr, " stop 停止后台守护进程\n")
6268
fmt.Fprintf(os.Stderr, " adminurl 打印当前运行实例的配置管理 UI 地址\n\n")
6369
fmt.Fprintf(os.Stderr, "选项:\n")
@@ -121,10 +127,11 @@ func main() {
121127
if data, err := os.ReadFile(pidPath); err == nil {
122128
if pid, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil {
123129
if proc, err := os.FindProcess(pid); err == nil {
124-
if proc.Signal(syscall.Signal(0)) == nil {
125-
fmt.Fprintf(os.Stderr, "sop-chat-server 已在运行(PID=%d),\n如需重启请先执行: ./sop-chat-server stop\n如需查看管理地址: ./sop-chat-server adminurl\n", pid)
126-
os.Exit(1)
127-
}
130+
if proc.Signal(syscall.Signal(0)) == nil {
131+
fmt.Fprintf(os.Stderr, "sop-chat-server 已在运行(PID=%d),\n如需重启请先执行: ./sop-chat-server stop\n如需查看管理地址: ./sop-chat-server adminurl\n", pid)
132+
fmt.Fprintln(os.Stderr, "❌ 启动失败")
133+
os.Exit(1)
134+
}
128135
}
129136
}
130137
// PID 文件存在但进程已不在,清理残留文件
@@ -212,9 +219,39 @@ func main() {
212219
logFile.Close() // 父进程不再需要,关闭自己持有的 fd
213220

214221
pidPath := filepath.Join(logsDir, adminPIDName)
215-
fmt.Printf("守护进程已启动 PID=%d,端口绑定成功后 PID 将写入 %s\n", cmd.Process.Pid, pidPath)
222+
223+
// 等待子进程启动结果:轮询 PID 文件(子进程绑端口成功后写入),最多等待 10 秒。
224+
// 若子进程提前退出则判定为启动失败。
225+
started := false
226+
deadline := time.Now().Add(10 * time.Second)
227+
for time.Now().Before(deadline) {
228+
// 检查子进程是否已退出(启动失败)
229+
if err := cmd.Process.Signal(syscall.Signal(0)); err != nil {
230+
break
231+
}
232+
// PID 文件出现即表示端口绑定成功
233+
if data, err := os.ReadFile(pidPath); err == nil {
234+
if pid, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil && pid == cmd.Process.Pid {
235+
started = true
236+
break
237+
}
238+
}
239+
time.Sleep(200 * time.Millisecond)
240+
}
241+
242+
if !started {
243+
// 子进程已退出,从日志文件尾部取最后几行作为错误提示
244+
fmt.Printf("❌ 启动失败(PID=%d 已退出),错误详情:\n", cmd.Process.Pid)
245+
if tail := tailFile(logPath, 10); tail != "" {
246+
fmt.Print(tail)
247+
} else {
248+
fmt.Printf(" (请查看日志: %s)\n", logPath)
249+
}
250+
os.Exit(1)
251+
}
216252
fmt.Printf("停止服务: ./sop-chat-server stop\n")
217253
fmt.Printf("查看管理地址: ./sop-chat-server adminurl\n")
254+
fmt.Printf("✅ 启动成功 PID=%d\n", cmd.Process.Pid)
218255

219256
return
220257
}
@@ -440,6 +477,25 @@ func isPortAvailable(port int) bool {
440477
return true
441478
}
442479

480+
// tailFile 读取文件末尾最多 n 行并返回(用于启动失败时展示日志片段)。
481+
func tailFile(path string, n int) string {
482+
data, err := os.ReadFile(path)
483+
if err != nil {
484+
return ""
485+
}
486+
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
487+
if len(lines) > n {
488+
lines = lines[len(lines)-n:]
489+
}
490+
var sb strings.Builder
491+
for _, l := range lines {
492+
sb.WriteString(" ")
493+
sb.WriteString(l)
494+
sb.WriteByte('\n')
495+
}
496+
return sb.String()
497+
}
498+
443499
// localAddresses 返回本机所有真实 IPv4 单播地址,过滤掉常见虚拟网桥接口。
444500
func localAddresses() []string {
445501
skipPrefixes := []string{"docker", "br-", "veth", "virbr", "vmnet", "vboxnet", "tun", "tap"}

backend/internal/api/server.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,17 @@ func NewServer(cfg *client.Config, globalConfig *config.Config, configPath strin
114114
// 注册路由
115115
server.setupRoutes()
116116

117-
if globalConfig != nil && globalConfig.Channels != nil {
117+
if globalConfig != nil {
118+
// 无论 CMS 凭据是否完整,都尝试启动 IM 渠道机器人。
119+
// 机器人启动只需要各平台自身的凭据(如钉钉 clientId/clientSecret),
120+
// CMS 凭据仅在响应消息时才用到;凭据缺失时机器人仍可连接,查询时再报错。
121+
// 这与 reloadConfig 的行为保持一致。
118122
cmsClientCfg, cmsErr := globalConfig.ToClientConfig()
119123
if cmsErr != nil {
120-
log.Printf("警告: 无法获取 CMS 配置,消息渠道机器人未启动: %v", cmsErr)
121-
} else {
124+
log.Printf("提示: CMS 凭据未配置或解析失败,消息渠道机器人将以空凭据启动(%v)", cmsErr)
125+
cmsClientCfg = &config.ClientConfig{}
126+
}
127+
if globalConfig.Channels != nil {
122128
// 启动钉钉机器人
123129
if len(globalConfig.Channels.DingTalk) > 0 {
124130
server.syncDingTalkBots(globalConfig.Channels.DingTalk, cmsClientCfg)

backend/internal/dingtalk/bot.go

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -157,14 +157,16 @@ func errorMessage(err error) string {
157157
return err.Error()
158158
}
159159

160-
// replyAtText 向钉钉回复文本,并 @ 提问者。
160+
// replyAtMarkdown 向钉钉发送 Markdown 消息,并 @ 提问者。
161+
// title 作为钉钉 markdown 消息的标题字段(不显示在正文中,但出现在通知预览)。
161162
// atDingtalkIds 负责触发客户端通知和渲染高亮 @,content 本身不再重复拼 @前缀,
162163
// 避免钉钉客户端自动显示的 @ 头与手动拼接的前缀重叠造成双 @。
163-
func replyAtText(ctx context.Context, webhook, senderId, _, content string) error {
164+
func replyAtMarkdown(ctx context.Context, webhook, senderId, title, content string) error {
164165
body := map[string]interface{}{
165-
"msgtype": "text",
166-
"text": map[string]interface{}{
167-
"content": content,
166+
"msgtype": "markdown",
167+
"markdown": map[string]interface{}{
168+
"title": title,
169+
"text": content,
168170
},
169171
"at": map[string]interface{}{
170172
"atDingtalkIds": []string{senderId},
@@ -253,13 +255,20 @@ func (b *Bot) onMessage(ctx context.Context, data *chatbot.BotCallbackDataModel)
253255
}
254256

255257
log.Printf("[DingTalk] 正在回复钉钉消息,sessionWebhook=%s", webhook)
258+
256259
var replyErr error
257260
if conversationType == "2" {
258261
// 群聊:@ 提问者,触发客户端通知和高亮
259-
replyErr = replyAtText(asyncCtx, webhook, senderId, senderNick, replyText)
262+
replyErr = replyAtMarkdown(asyncCtx, webhook, senderId, "回复", replyText)
260263
} else {
261264
// 单聊:直接回复,无需 @
262-
replyErr = chatbot.NewChatbotReplier().SimpleReplyText(asyncCtx, webhook, []byte(replyText))
265+
replyErr = chatbot.NewChatbotReplier().ReplyMessage(asyncCtx, webhook, map[string]interface{}{
266+
"msgtype": "markdown",
267+
"markdown": map[string]interface{}{
268+
"title": "回复",
269+
"text": replyText,
270+
},
271+
})
263272
}
264273
if replyErr != nil {
265274
log.Printf("[DingTalk] 回复消息失败: %v", replyErr)
@@ -483,7 +492,7 @@ func (b *Bot) getOrCreateThreadId(conversationId, senderNick, employeeName strin
483492
}
484493

485494
// conciseInstruction 是开启简洁模式时附加到用户消息末尾的指令
486-
const conciseInstruction = "\n\n请用简洁的纯文本回答,避免复杂排版,适合在 IM 中直接阅读,控制在几句话以内。 尽量拟人的语气,少用markdown)"
495+
const conciseInstruction = "\n\n请用简洁的回答,控制在几句话以内,尽量拟人的语气。)"
487496

488497
// queryEmployee 向 CMS 数字员工发送消息,返回收集到的回复文本和线程 ID。
489498
// employeeName 为路由解析后的目标员工(可能与 cfg.EmployeeName 不同)。

frontend/public/config.html

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1596,7 +1596,7 @@
15961596
).join('');
15971597
const whType = task.webhook ? (task.webhook.type || '') : '';
15981598
const whUrl = task.webhook ? (task.webhook.url || '') : '';
1599-
const whMsg = task.webhook ? (task.webhook.msgType || 'text') : 'text';
1599+
const whMsg = task.webhook ? (task.webhook.msgType || (task.webhook.type === 'dingtalk' ? 'markdown' : 'text')) : 'text';
16001600
const whTitle = task.webhook ? (task.webhook.title || '') : '';
16011601
const titleField = whMsg === 'text' ? 'style="display:none"' : '';
16021602

@@ -1692,7 +1692,7 @@
16921692
cron: '0 9 * * 1-5',
16931693
employeeName: 'apsara-ops',
16941694
conciseReply: true,
1695-
webhook: { type: 'dingtalk', msgType: 'text' },
1695+
webhook: { type: 'dingtalk', msgType: 'markdown', title: '定时任务通知' },
16961696
}, container.children.length, true));
16971697
}
16981698

@@ -1761,8 +1761,16 @@
17611761
const card = sel.closest('.task-card');
17621762
const msgSel = card.querySelector('[data-field=webhookMsgType]');
17631763
if (!msgSel) return;
1764-
// 重建 options,保留当前选中值(如果新平台不支持则回退到第一项)
1765-
msgSel.innerHTML = buildMsgTypeOptions(sel.value, msgSel.value);
1764+
// 钉钉默认使用 markdown,其他平台保留当前选中值(不支持则回退到第一项)
1765+
const preferredMsg = sel.value === 'dingtalk' ? 'markdown' : msgSel.value;
1766+
msgSel.innerHTML = buildMsgTypeOptions(sel.value, preferredMsg);
1767+
// 切换到钉钉且标题为空时,补上默认标题
1768+
if (sel.value === 'dingtalk') {
1769+
const titleInput = card.querySelector('[data-field=webhookTitle]');
1770+
if (titleInput && !titleInput.value.trim()) {
1771+
titleInput.value = '定时任务通知';
1772+
}
1773+
}
17661774
onMsgTypeChange(msgSel);
17671775
}
17681776

0 commit comments

Comments
 (0)