Claude Code Hook实现任务完成消息通知

Published on 2026-07-14 15:09 in 分类: 随笔 with 狂盗一枝梅
分类: 随笔

Claude Code在运行长任务的时候,可以先做着别的事情,但是由于没有消息通知,需要经常去看看任务完成了没有,实际上可以借助Claude Code 的hook机制实现消息通知功能,这样就可以从主动查询变成被动通知了。

Claude Code任务完成消息通知

第一步:安装python依赖

pip install win10toast

第二步:准备python脚本

将下面的Python脚本放到硬盘上的某个目录,不要移动

# send_notification_advanced.py
import sys
import json
import os
import re


def format_duration(seconds):
    """将秒数格式化为人类可读的耗时"""
    if seconds is None:
        return ""
    seconds = int(seconds)
    if seconds < 60:
        return f"{seconds}秒"
    elif seconds < 3600:
        return f"{seconds // 60}分{seconds % 60}秒"
    else:
        return f"{seconds // 3600}时{seconds % 3600 // 60}分"


def extract_session_name(transcript_path):
    """从 JSONL transcript 中提取会话名称 customTitle > aiTitle > slug"""
    try:
        if not transcript_path or not os.path.exists(transcript_path):
            return None

        custom_title = None
        ai_title = None
        slug = None

        with open(transcript_path, 'r', encoding='utf-8-sig') as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                try:
                    obj = json.loads(line)
                except json.JSONDecodeError:
                    continue

                t = obj.get('type', '')

                if t == 'custom-title':
                    val = obj.get('customTitle', '')
                    if val and val.strip():
                        custom_title = val.strip()

                elif t == 'ai-title':
                    val = obj.get('aiTitle', '')
                    if val and val.strip():
                        ai_title = val.strip()

                s = obj.get('slug', '')
                if s and s.strip():
                    slug = s.strip()

        return custom_title or ai_title or slug
    except Exception:
        return None


def extract_human_summary(transcript_path):
    """从 JSONL transcript 中提取最后一条人类可读的 assistant/user 消息文本"""
    try:
        if not transcript_path or not os.path.exists(transcript_path):
            return None

        with open(transcript_path, 'r', encoding='utf-8-sig') as f:
            lines = f.readlines()

        for line in reversed(lines):
            line = line.strip()
            if not line:
                continue
            try:
                obj = json.loads(line)
            except json.JSONDecodeError:
                continue

            if obj.get('type') not in ('assistant', 'user'):
                continue

            msg = obj.get('message')
            if not isinstance(msg, dict):
                continue
            content = msg.get('content', [])
            if not isinstance(content, list):
                continue
            for block in content:
                if not isinstance(block, dict):
                    continue
                text = block.get('text', '')
                if text and isinstance(text, str) and len(text.strip()) > 10:
                    clean = text.strip().replace('\r\n', '\n').replace('\r', '\n')
                    return clean
        return None
    except Exception:
        return None


def render_markdown_to_plain(text):
    """将 Markdown 渲染为纯文本,去除格式符号保留内容"""
    if not text:
        return text

    # 代码块 ```...``` 整体替换为首行摘要
    text = re.sub(r'```[\s\S]*?```', '[代码块]', text)

    # 行内代码 `code`
    text = re.sub(r'`([^`]+)`', r'\1', text)

    # 图片 ![alt](url) → alt
    text = re.sub(r'!\[([^\]]*)\]\([^)]+\)', r'\1', text)

    # 链接 [text](url) → text
    text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text)

    # **加粗** → 加粗
    text = re.sub(r'\*\*([^*]+)\*\*', r'\1', text)

    # *斜体* → 斜体
    text = re.sub(r'\*([^*]+)\*', r'\1', text)

    # ~~删除线~~
    text = re.sub(r'~~([^~]+)~~', r'\1', text)

    # 标题行 ## Title → Title
    text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)

    # 无序列表 - item → • item
    text = re.sub(r'^(\s*)[-*+]\s+', r'\1• ', text, flags=re.MULTILINE)

    # 有序列表 1. item → 保持
    text = re.sub(r'^(\s*)\d+\.\s+', r'\1  ', text, flags=re.MULTILINE)

    # 引用 > text → text
    text = re.sub(r'^>\s?', '', text, flags=re.MULTILINE)

    # 分割线 --- → ─────
    text = re.sub(r'^[-*_]{3,}\s*$', '─────', text, flags=re.MULTILINE)

    # 多余空行压缩
    text = re.sub(r'\n{3,}', '\n\n', text)

    return text.strip()


def show_notification(title, message):
    """使用 Windows 原生 Toast 通知"""
    try:
        from win10toast import ToastNotifier
        toaster = ToastNotifier()
        toaster.show_toast(title, message, duration=5, threaded=True)
        import time
        time.sleep(5)
    except Exception as e:
        print(f"[{title}] {message}", file=sys.stderr)


if __name__ == "__main__":
    title = "Claude Code"
    message = "任务已完成"

    if not sys.stdin.isatty():
        try:
            raw_input = sys.stdin.read()
            if raw_input and raw_input.strip():
                data = json.loads(raw_input)

                transcript_path = data.get('transcript_path')
                session_name = extract_session_name(transcript_path)
                if session_name:
                    title = f"{session_name} 任务完成"
                else:
                    session_id = data.get('session_id')
                    if session_id:
                        title = f"Claude Code ({session_id[:8]})"

                summary = extract_human_summary(transcript_path)

                if summary:
                    message = render_markdown_to_plain(summary)
                elif data.get('exit_code') is not None:
                    exit_code = data['exit_code']
                    if exit_code == 0:
                        message = "✅ 任务执行成功"
                    else:
                        message = f"❌ 任务执行失败 (exit={exit_code})"

                duration_raw = data.get('duration_ms') or data.get('duration', '')
                if duration_raw:
                    try:
                        sec = float(str(duration_raw).replace('ms', '')) / 1000
                        duration_text = format_duration(sec)
                        if summary:
                            message = f"⏱ {duration_text}\n{message}"
                        elif not data.get('exit_code'):
                            message = f"⏱ {duration_text}"
                    except (ValueError, TypeError):
                        pass

        except json.JSONDecodeError:
            if raw_input and raw_input.strip():
                message = raw_input.strip()[:200]
        except Exception:
            pass

    show_notification(title, message)

第三步:修改Claude Code配置文件

打开C:\Users\{用户名}\.claude目录,修改settings.json文件,新增或者修改hooks配置项:

  "hooks": {
	"Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "python D:/plugin/hook/task_complete_notification/script.py"
          }
        ]
      }
    ]
  }

注意路径不需要使用反斜杠,使用反斜杠会报错。


#Claude Code
目录
复制 复制成功