diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6bbaa06 --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# TaskPing Email Relay Configuration + +# 邮件接收配置 +IMAP_HOST=imap.gmail.com +IMAP_PORT=993 +IMAP_SECURE=true +IMAP_USER=your-email@gmail.com +IMAP_PASS=your-app-password + +# 邮件安全设置 +# 允许的发件人列表(逗号分隔) +ALLOWED_SENDERS=jessy@example.com,trusted@company.com + +# 路由与会话存储 +SESSION_MAP_PATH=/Users/jessytsui/dev/TaskPing/src/data/session-map.json + +# 模式选择:pty 或 tmux +INJECTION_MODE=pty + +# Claude CLI 路径(可选,默认使用系统PATH中的claude) +CLAUDE_CLI_PATH=claude + +# 日志级别:debug, info, warn, error +LOG_LEVEL=info + +# 是否记录PTY输出(调试用) +PTY_OUTPUT_LOG=false + +# tmux 配置(如果使用tmux模式) +TMUX_SESSION_PREFIX=taskping \ No newline at end of file diff --git a/docs/EMAIL_REPLY_GUIDE.md b/docs/EMAIL_REPLY_GUIDE.md new file mode 100644 index 0000000..3d5a5ca --- /dev/null +++ b/docs/EMAIL_REPLY_GUIDE.md @@ -0,0 +1,274 @@ +# TaskPing 邮件回复功能使用指南 + +## 概述 + +TaskPing 的邮件回复功能允许您通过回复邮件的方式向 Claude Code CLI 发送命令。当您在移动设备或其他电脑上收到 TaskPing 的任务提醒邮件时,可以直接回复邮件来控制 Claude Code 继续执行任务。 + +## 工作原理 + +1. **发送提醒**: Claude Code 在任务暂停时发送包含会话 Token 的提醒邮件 +2. **邮件监听**: PTY Relay 服务持续监听您的收件箱 +3. **命令提取**: 从回复邮件中提取命令内容 +4. **命令注入**: 通过 node-pty 将命令注入到对应的 Claude Code 会话 + +## 快速开始 + +### 1. 配置邮件账号 + +复制环境配置文件: +```bash +cp .env.example .env +``` + +编辑 `.env` 文件,填入您的邮件配置: +```env +# Gmail 示例 +IMAP_HOST=imap.gmail.com +IMAP_PORT=993 +IMAP_SECURE=true +IMAP_USER=your-email@gmail.com +IMAP_PASS=your-app-password # 使用应用专用密码 + +# 安全设置 +ALLOWED_SENDERS=your-email@gmail.com,trusted@company.com +``` + +### 2. 启动 PTY Relay 服务 + +```bash +npm run relay:pty +# 或者 +./start-relay-pty.js +``` + +### 3. 测试邮件解析 + +运行测试工具验证配置: +```bash +npm run relay:test +``` + +## 使用方法 + +### 邮件格式要求 + +#### 主题格式 +邮件主题必须包含 TaskPing Token,支持以下格式: +- `[TaskPing #TOKEN]` - 推荐格式 +- `[TaskPing TOKEN]` +- `TaskPing: TOKEN` + +例如: +- `Re: [TaskPing #ABC123] 任务等待您的指示` +- `回复: TaskPing: XYZ789` + +#### 命令格式 +支持三种命令输入方式: + +1. **直接输入**(最简单) + ``` + 继续执行 + ``` + +2. **CMD 前缀**(明确标识) + ``` + CMD: npm run build + ``` + +3. **代码块**(复杂命令) + ```` + ``` + git add . + git commit -m "Update features" + git push + ``` + ```` + +### 示例场景 + +#### 场景 1: 简单确认 +收到邮件: +``` +主题: [TaskPing #TASK001] 是否继续部署到生产环境? +``` + +回复: +``` +yes +``` + +#### 场景 2: 执行具体命令 +收到邮件: +``` +主题: [TaskPing #BUILD123] 构建失败,请输入修复命令 +``` + +回复: +``` +CMD: npm install missing-package +``` + +#### 场景 3: 多行命令 +收到邮件: +``` +主题: [TaskPing #DEPLOY456] 准备部署,请确认步骤 +``` + +回复: +```` +执行以下命令: + +``` +npm run test +npm run build +npm run deploy +``` +```` + +## 高级配置 + +### 会话管理 + +会话映射文件 `session-map.json` 结构: +```json +{ + "TOKEN123": { + "type": "pty", + "createdAt": 1234567890, + "expiresAt": 1234654290, + "cwd": "/path/to/project", + "description": "构建项目 X" + } +} +``` + +### 环境变量说明 + +| 变量名 | 说明 | 默认值 | +|--------|------|--------| +| `IMAP_HOST` | IMAP 服务器地址 | 必需 | +| `IMAP_PORT` | IMAP 端口 | 993 | +| `IMAP_SECURE` | 使用 SSL/TLS | true | +| `IMAP_USER` | 邮箱账号 | 必需 | +| `IMAP_PASS` | 邮箱密码 | 必需 | +| `ALLOWED_SENDERS` | 允许的发件人列表 | 空(接受所有) | +| `SESSION_MAP_PATH` | 会话映射文件路径 | ./src/data/session-map.json | +| `CLAUDE_CLI_PATH` | Claude CLI 路径 | claude | +| `LOG_LEVEL` | 日志级别 | info | +| `PTY_OUTPUT_LOG` | 记录 PTY 输出 | false | + +### 邮件服务器配置示例 + +#### Gmail +1. 启用 IMAP: 设置 → 转发和 POP/IMAP → 启用 IMAP +2. 生成应用专用密码: 账号设置 → 安全 → 应用专用密码 + +```env +IMAP_HOST=imap.gmail.com +IMAP_PORT=993 +IMAP_SECURE=true +``` + +#### Outlook/Office 365 +```env +IMAP_HOST=outlook.office365.com +IMAP_PORT=993 +IMAP_SECURE=true +``` + +#### QQ 邮箱 +```env +IMAP_HOST=imap.qq.com +IMAP_PORT=993 +IMAP_SECURE=true +``` + +## 安全注意事项 + +1. **发件人验证**: 始终配置 `ALLOWED_SENDERS` 以限制谁可以发送命令 +2. **命令过滤**: 系统会自动过滤危险命令(如 `rm -rf`) +3. **会话过期**: 会话有过期时间,过期后无法接受命令 +4. **邮件加密**: 使用 SSL/TLS 加密的 IMAP 连接 +5. **密码安全**: 使用应用专用密码而非主密码 + +## 故障排查 + +### 常见问题 + +1. **无法连接到邮件服务器** + - 检查 IMAP 是否已启用 + - 验证服务器地址和端口 + - 确认防火墙设置 + +2. **邮件未被处理** + - 检查发件人是否在白名单中 + - 验证邮件主题格式 + - 查看日志中的错误信息 + +3. **命令未执行** + - 确认 Claude Code 进程正在运行 + - 检查会话是否已过期 + - 验证命令格式是否正确 + +### 查看日志 + +```bash +# 启动时查看详细日志 +LOG_LEVEL=debug npm run relay:pty + +# 查看 PTY 输出 +PTY_OUTPUT_LOG=true npm run relay:pty +``` + +### 测试命令 + +```bash +# 测试邮件解析 +npm run relay:test + +# 手动启动(调试模式) +INJECTION_MODE=pty LOG_LEVEL=debug node src/relay/relay-pty.js +``` + +## 最佳实践 + +1. **使用专用邮箱**: 为 TaskPing 创建专用邮箱账号 +2. **定期清理**: 定期清理过期会话和已处理邮件 +3. **命令简洁**: 保持命令简短明确 +4. **及时回复**: 在会话过期前回复邮件 +5. **安全优先**: 不要在邮件中包含敏感信息 + +## 集成到现有项目 + +如果您想将邮件回复功能集成到现有的 Claude Code 工作流: + +1. 在发送提醒时创建会话: +```javascript +const token = generateToken(); +const session = { + type: 'pty', + createdAt: Math.floor(Date.now() / 1000), + expiresAt: Math.floor((Date.now() + 3600000) / 1000), + cwd: process.cwd() +}; +saveSession(token, session); +``` + +2. 在邮件主题中包含 Token: +```javascript +const subject = `[TaskPing #${token}] ${taskDescription}`; +``` + +3. 启动 PTY Relay 服务监听回复 + +## 相关文档 + +- [邮件配置指南](./EMAIL_GUIDE.md) +- [快速邮件设置](./QUICK_EMAIL_SETUP.md) +- [系统架构说明](./EMAIL_ARCHITECTURE.md) + +## 支持 + +如有问题,请查看: +- 项目 Issue: https://github.com/JessyTsui/TaskPing/issues +- 详细日志: `LOG_LEVEL=debug npm run relay:pty` \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ebdb996..344800e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,9 +15,15 @@ "win32" ], "dependencies": { + "dotenv": "^17.2.1", + "execa": "^9.6.0", + "imapflow": "^1.0.191", "mailparser": "^3.7.4", "node-imap": "^0.9.6", + "node-pty": "^1.0.0", "nodemailer": "^7.0.5", + "pino": "^9.7.0", + "pino-pretty": "^13.0.0", "uuid": "^11.1.0" }, "bin": { @@ -25,11 +31,15 @@ "taskping-install": "install.js", "taskping-notify": "hook-notify.js" }, - "devDependencies": {}, "engines": { "node": ">=14.0.0" } }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==" + }, "node_modules/@selderee/plugin-htmlparser2": { "version": "0.11.0", "resolved": "https://registry.npmmirror.com/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", @@ -42,6 +52,51 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmmirror.com/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmmirror.com/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "engines": { + "node": "*" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmmirror.com/deepmerge/-/deepmerge-4.3.1.tgz", @@ -101,6 +156,17 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dotenv": { + "version": "17.2.1", + "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-17.2.1.tgz", + "integrity": "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/encoding-japanese": { "version": "2.2.0", "resolved": "https://registry.npmmirror.com/encoding-japanese/-/encoding-japanese-2.2.0.tgz", @@ -109,6 +175,14 @@ "node": ">=8.10.0" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz", @@ -120,6 +194,78 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/execa": { + "version": "9.6.0", + "resolved": "https://registry.npmmirror.com/execa/-/execa-9.6.0.tgz", + "integrity": "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-copy": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/fast-copy/-/fast-copy-3.0.2.tgz", + "integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==" + }, + "node_modules/fast-redact": { + "version": "3.5.0", + "resolved": "https://registry.npmmirror.com/fast-redact/-/fast-redact-3.5.0.tgz", + "integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", @@ -128,6 +274,11 @@ "he": "bin/he" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==" + }, "node_modules/html-to-text": { "version": "9.0.5", "resolved": "https://registry.npmmirror.com/html-to-text/-/html-to-text-9.0.5.tgz", @@ -161,6 +312,14 @@ "entities": "^4.4.0" } }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -172,11 +331,90 @@ "node": ">=0.10.0" } }, + "node_modules/imapflow": { + "version": "1.0.191", + "resolved": "https://registry.npmmirror.com/imapflow/-/imapflow-1.0.191.tgz", + "integrity": "sha512-cjGj5RYOZe6L/B1sn/yaSK0HJt/6OH5KVvuZ4wi4eJwm+3DUZT5rFljAqJH7Nc/UlOqdeCH2Tm47/Aa5apOkrg==", + "dependencies": { + "encoding-japanese": "2.2.0", + "iconv-lite": "0.6.3", + "libbase64": "1.3.0", + "libmime": "5.3.7", + "libqp": "2.1.1", + "mailsplit": "5.4.5", + "nodemailer": "7.0.5", + "pino": "9.7.0", + "socks": "2.8.5" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmmirror.com/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "engines": { + "node": ">=10" + } + }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==" + }, "node_modules/leac": { "version": "0.6.0", "resolved": "https://registry.npmmirror.com/leac/-/leac-0.6.0.tgz", @@ -249,6 +487,19 @@ "libqp": "2.1.1" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/nan": { + "version": "2.23.0", + "resolved": "https://registry.npmmirror.com/nan/-/nan-2.23.0.tgz", + "integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==" + }, "node_modules/node-imap": { "version": "0.9.6", "resolved": "https://registry.npmmirror.com/node-imap/-/node-imap-0.9.6.tgz", @@ -261,6 +512,15 @@ "node": ">=0.8.0" } }, + "node_modules/node-pty": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/node-pty/-/node-pty-1.0.0.tgz", + "integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==", + "hasInstallScript": true, + "dependencies": { + "nan": "^2.17.0" + } + }, "node_modules/nodemailer": { "version": "7.0.5", "resolved": "https://registry.npmmirror.com/nodemailer/-/nodemailer-7.0.5.tgz", @@ -269,6 +529,59 @@ "node": ">=6.0.0" } }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parseley": { "version": "0.12.1", "resolved": "https://registry.npmmirror.com/parseley/-/parseley-0.12.1.tgz", @@ -281,6 +594,14 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "engines": { + "node": ">=8" + } + }, "node_modules/peberminta": { "version": "0.9.0", "resolved": "https://registry.npmmirror.com/peberminta/-/peberminta-0.9.0.tgz", @@ -289,6 +610,101 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/pino": { + "version": "9.7.0", + "resolved": "https://registry.npmmirror.com/pino/-/pino-9.7.0.tgz", + "integrity": "sha512-vnMCM6xZTb1WDmLvtG2lE/2p+t9hDEIvTWJsu6FejkE62vB7gDhvzrpFR4Cw2to+9JNQxVnkAKVPA1KPB98vWg==", + "dependencies": { + "atomic-sleep": "^1.0.0", + "fast-redact": "^3.1.1", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.0.0", + "resolved": "https://registry.npmmirror.com/pino-pretty/-/pino-pretty-13.0.0.tgz", + "integrity": "sha512-cQBBIVG3YajgoUjo1FdKVRX6t9XPxwB9lcNJVD5GCnNM4Y6T12YYx8c6zEejxQsU0wrg9TwmDulcE9LR7qcJqA==", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^3.0.2", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^2.4.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^3.1.1" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz", + "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==" + }, + "node_modules/pretty-ms": { + "version": "9.2.0", + "resolved": "https://registry.npmmirror.com/pretty-ms/-/pretty-ms-9.2.0.tgz", + "integrity": "sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz", @@ -297,6 +713,11 @@ "node": ">=6" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", @@ -310,6 +731,14 @@ "node": ">= 6" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -329,11 +758,24 @@ } ] }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==" + }, "node_modules/selderee": { "version": "0.11.0", "resolved": "https://registry.npmmirror.com/selderee/-/selderee-0.11.0.tgz", @@ -353,6 +795,79 @@ "semver": "bin/semver" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.5", + "resolved": "https://registry.npmmirror.com/socks/-/socks-2.8.5.tgz", + "integrity": "sha512-iF+tNDQla22geJdTyJB1wM/qrX9DMRwWrciEPwWLPRWAUEM8sQiyxgckLxWT1f7+9VabJS0jTGGr4QgBuvi6Ww==", + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/sonic-boom/-/sonic-boom-4.2.0.tgz", + "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", @@ -361,6 +876,36 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/tlds": { "version": "1.259.0", "resolved": "https://registry.npmmirror.com/tlds/-/tlds-1.259.0.tgz", @@ -374,6 +919,17 @@ "resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz", "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==" }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/utf7": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/utf7/-/utf7-1.0.2.tgz", @@ -398,6 +954,36 @@ "bin": { "uuid": "dist/esm/bin/uuid" } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/yoctocolors": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/yoctocolors/-/yoctocolors-2.1.1.tgz", + "integrity": "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 33c8143..6f0c4c4 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,13 @@ "test-completed": "node hook-notify.js --type completed", "test-waiting": "node hook-notify.js --type waiting", "daemon:start": "node taskping.js daemon start", - "daemon:stop": "node taskping.js daemon stop", + "daemon:stop": "node taskping.js daemon stop", "daemon:status": "node taskping.js daemon status", "test:clipboard": "node test-clipboard.js", - "start": "node email-automation.js" + "start": "node email-automation.js", + "relay:pty": "node start-relay-pty.js", + "relay:test": "node test-email-reply.js", + "relay:start": "INJECTION_MODE=pty node src/relay/relay-pty.js" }, "bin": { "taskping-install": "./install.js", @@ -50,9 +53,15 @@ }, "homepage": "https://github.com/TaskPing/TaskPing#readme", "dependencies": { + "dotenv": "^17.2.1", + "execa": "^9.6.0", + "imapflow": "^1.0.191", "mailparser": "^3.7.4", "node-imap": "^0.9.6", + "node-pty": "^1.0.0", "nodemailer": "^7.0.5", + "pino": "^9.7.0", + "pino-pretty": "^13.0.0", "uuid": "^11.1.0" }, "files": [ diff --git a/relay-injection.md b/relay-injection.md new file mode 100644 index 0000000..cfd5318 --- /dev/null +++ b/relay-injection.md @@ -0,0 +1,447 @@ +# TaskPing — 邮件回复写回 Claude Code CLI(执行手册) + +> 目标读者:能执行脚本的 AI/自动化代理 +> 运行环境:Linux/macOS(建议),Node.js ≥ 18,Claude Code 已安装并可在终端运行 `claude` + +--- + +## 1. 背景与目标 + +**项目背景**:用户在本机通过 Claude Code(CLI)进行编码。当前已实现: +- 通过 hooks 在任务结束/等待下一步时**发出邮件提醒**; +- 用户在手机上**回复邮件**; +- 本机有一个常驻“中继/Relay”进程轮询收件箱,能够拿到该回复。 + +**当前卡点**:**把邮件正文写回到对应的 Claude Code CLI 会话,并回车执行**。 + +**本文档目的**:为 AI/自动化代理提供两种可执行的落地方案: +1) **子进程/PTY 模式**:用 `node-pty` 驱动一个 Claude Code 子进程,直接向伪终端写入并回车; +2) **tmux 模式**:让 Claude Code 运行在指定 `tmux` pane,收到邮件后用 `tmux send-keys` 注入文本并回车。 + +两种方案都要求: +- 能**路由**到正确的 Claude 会话; +- 能**清洗**邮件正文,仅保留要注入的命令; +- 有**安全**与**幂等**控制; +- 有**可观测性**(日志/Tracing)。 + +--- + +## 2. 全局约束与安全要求 + +- **会话识别**:优先按 `In-Reply-To` 的 `Message-ID` 匹配;回退使用 `Subject` 中的 token(形如 `[TaskPing #ABC123]`)。 +- **白名单**:仅允许来自配置的发件人域/邮箱(通过 SPF/DKIM/DMARC 验证后再放行)。 +- **正文提取**:只取**最新回复**;去除历史引用、签名、HTML 标签、图片占位。支持三种输入: + - 纯文本一行命令; + - 代码块 ``` 包起来的命令(优先级最高); + - 主题/首行以 `CMD:` 前缀标识。 +- **幂等**:用 `Message-ID` 或 `gmailThreadId` 去重(重复投递不再次注入)。 +- **限流**:同一会话每分钟最多 1 条;单条长度上限(例如 8KB)。 +- **日志**:记录 token、会话、pane/pty id、注入摘要(前 120 字符),屏蔽隐私。 + +--- + +## 3. 公共依赖与配置 + +### 3.1 依赖(Node.js) +```bash +npm i imapflow mailparser node-pty pino dotenv execa +# tmux 方案需要系统安装 tmux:macOS: brew install tmux;Debian/Ubuntu: apt-get install tmux +``` + +### 3.2 环境变量(`.env`) +```bash +# 邮件接收 +IMAP_HOST=imap.example.com +IMAP_PORT=993 +IMAP_SECURE=true +IMAP_USER=bot@example.com +IMAP_PASS=******** + +# 邮件路由安全 +ALLOWED_SENDERS=jessy@example.com,panda@company.com + +# 路由与会话存储(JSON 文件路径) +SESSION_MAP_PATH=/var/lib/taskping/session-map.json + +# 模式选择:pty 或 tmux +INJECTION_MODE=pty + +# tmux 方案可选:默认 session/pane 名称前缀 +TMUX_SESSION_PREFIX=taskping +``` + +### 3.3 会话映射(`SESSION_MAP_PATH`) +结构示例: +```json +{ + "ABC123": { + "type": "pty", + "ptyId": "b4c1...", + "createdAt": 1732672100, + "expiresAt": 1732679300, + "messageId": "", + "imapUid": 12345 + }, + "XYZ789": { + "type": "tmux", + "session": "taskping-XYZ789", + "pane": "taskping-XYZ789.0", + "createdAt": 1732672200, + "expiresAt": 1732679400, + "messageId": "" + } +} +``` +> 注:会话映射由**发送提醒时**创建(包含 token 与目标 CLI 会话信息)。 + +--- + +## 4. 方案一:子进程/PTY 模式(推荐) + +### 4.1 适用场景 +- 由中继程序**直接管理** Claude Code 生命周期; +- 需要最稳定的注入通道(不依赖额外终端复用器)。 + +### 4.2 实现思路 +- 用 `node-pty` `spawn('claude', [...])` 启动 CLI; +- 将返回的 `pty` 与生成的 token 绑定,写入 `SESSION_MAP_PATH`; +- 收到邮件 → 路由 token → 清洗正文 → `pty.write(cmd + '\r')`; +- 监控 `pty.onData`,必要时截取摘要回传提醒(可选)。 + +### 4.3 关键代码骨架(`relay-pty.js`) +```js +import { ImapFlow } from 'imapflow'; +import { simpleParser } from 'mailparser'; +import pino from 'pino'; +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { spawn as spawnPty } from 'node-pty'; +import dotenv from 'dotenv'; + +dotenv.config(); +const log = pino({ level: 'info' }); +const SESS_PATH = process.env.SESSION_MAP_PATH; + +function loadSessions() { + if (!existsSync(SESS_PATH)) return {}; + return JSON.parse(readFileSync(SESS_PATH, 'utf8')); +} +function saveSessions(map) { + writeFileSync(SESS_PATH, JSON.stringify(map, null, 2)); +} + +function normalizeAllowlist() { + return (process.env.ALLOWED_SENDERS || '') + .split(',') + .map(s => s.trim().toLowerCase()) + .filter(Boolean); +} +const ALLOW = new Set(normalizeAllowlist()); + +function isAllowed(addressObj) { + const list = [] + .concat(addressObj?.value || []) + .map(a => (a.address || '').toLowerCase()); + return list.some(addr => ALLOW.has(addr)); +} + +function extractTokenFromSubject(subject = '') { + const m = subject.match(/\[TaskPing\s+#([A-Za-z0-9_-]+)\]/); + return m ? m[1] : null; +} + +function stripReply(text = '') { + // 优先识别 ``` 块 + const codeBlock = text.match(/```[\s\S]*?```/); + if (codeBlock) { + return codeBlock[0].replace(/```/g, '').trim(); + } + // 取首行或以 CMD: 前缀 + const firstLine = text.split(/\r?\n/).find(l => l.trim().length > 0) || ''; + const cmdPrefix = firstLine.match(/^CMD:\s*(.+)$/i); + const candidate = (cmdPrefix ? cmdPrefix[1] : firstLine).trim(); + + // 去除引用与签名 + return candidate + .replace(/^>.*$/gm, '') + .replace(/^--\s+[\s\S]*$/m, '') + .slice(0, 8192) // 长度限制 + .trim(); +} + +async function handleMailMessage(source) { + const parsed = await simpleParser(source); + if (!isAllowed(parsed.from)) { + log.warn({ from: parsed.from?.text }, 'sender not allowed'); + return; + } + + const subject = parsed.subject || ''; + const token = extractTokenFromSubject(subject); + if (!token) { + log.warn({ subject }, 'no token in subject'); + return; + } + + const sessions = loadSessions(); + const sess = sessions[token]; + if (!sess || sess.expiresAt * 1000 < Date.now()) { + log.warn({ token }, 'session not found or expired'); + return; + } + if (sess.type !== 'pty' || !sess.ptyId) { + log.error({ token }, 'session is not pty type'); + return; + } + + const cmd = stripReply(parsed.text || parsed.html || ''); + if (!cmd) { + log.warn({ token }, 'empty command after strip'); + return; + } + + // 取得 pty 实例:这里演示为“按需重建/保持单例”两种策略之一 + const pty = getOrRestorePty(sess); + log.info({ token, cmd: cmd.slice(0, 120) }, 'inject command'); + pty.write(cmd + '\r'); +} + +const PTY_POOL = new Map(); +function getOrRestorePty(sess) { + if (PTY_POOL.has(sess.ptyId)) return PTY_POOL.get(sess.ptyId); + + // 如果需要重建,会话应当保存启动参数;这里演示简化为新起一个 claude + const shell = 'claude'; // 或绝对路径 + const pty = spawnPty(shell, [], { + name: 'xterm-color', + cols: 120, + rows: 32 + }); + PTY_POOL.set(sess.ptyId, pty); + pty.onData(d => process.stdout.write(d)); // 可替换为日志/回传 + pty.onExit(() => PTY_POOL.delete(sess.ptyId)); + return pty; +} + +async function startImap() { + const client = new ImapFlow({ + host: process.env.IMAP_HOST, + port: Number(process.env.IMAP_PORT || 993), + secure: process.env.IMAP_SECURE === 'true', + auth: { user: process.env.IMAP_USER, pass: process.env.IMAP_PASS } + }); + await client.connect(); + const lock = await client.getMailboxLock('INBOX'); + try { + for await (const msg of client.monitor()) { + if (msg.type === 'exists') { + const { uid } = msg; + const { source } = await client.download('INBOX', uid); + const chunks = []; + for await (const c of source) chunks.push(c); + await handleMailMessage(Buffer.concat(chunks)); + } + } + } finally { + lock.release(); + } +} + +if (process.env.INJECTION_MODE === 'pty') { + startImap().catch(err => { + console.error(err); + process.exit(1); + }); +} +``` + +> 说明:生产化时,建议在**发送提醒**一侧创建会话并持久化 `ptyId` 与参数;这里演示了“按需复原”的简化路径。 + +### 4.4 启动 +```bash +node relay-pty.js +``` + +### 4.5 验收清单 +- [ ] 未授权邮箱无法触发注入; +- [ ] 合法邮件写入后,Claude Code 能立即进入下一步; +- [ ] 重复转发的同一邮件不会二次注入; +- [ ] 过期会话拒绝注入; +- [ ] 大段/HTML/带签名的邮件能正确抽取命令。 + +--- + +## 5. 方案二:tmux 模式(简单稳妥) + +### 5.1 适用场景 +- 你已经在 `tmux` 里运行 Claude Code; +- 希望由外部进程向**指定 pane** 注入按键,不改变现有启动流程。 + +### 5.2 实现思路 +- 为每个 token 创建/记录一个 `tmux` session 与 pane:`session = taskping-`; +- Claude Code 在该 pane 里运行; +- 收到邮件 → 定位到 pane → `tmux send-keys -t "" Enter`。 + +### 5.3 管理脚本(`tmux-utils.sh`) +```bash +#!/usr/bin/env bash +set -euo pipefail + +SESSION="$1" # 例如 taskping-ABC123 +CMD="${2:-}" # 可选:一次性注入命令 + +if ! tmux has-session -t "${SESSION}" 2>/dev/null; then + tmux new-session -d -s "${SESSION}" + tmux rename-window -t "${SESSION}:0" "claude" + tmux send-keys -t "${SESSION}:0" "claude" C-m + sleep 0.5 +fi + +if [ -n "${CMD}" ]; then + tmux send-keys -t "${SESSION}:0" "${CMD}" C-m +fi + +# 输出 pane 目标名,供上层程序记录 +echo "${SESSION}.0" +``` + +### 5.4 注入程序(`relay-tmux.js`) +```js +import { ImapFlow } from 'imapflow'; +import { simpleParser } from 'mailparser'; +import pino from 'pino'; +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import dotenv from 'dotenv'; +import { execa } from 'execa'; + +dotenv.config(); +const log = pino({ level: 'info' }); +const SESS_PATH = process.env.SESSION_MAP_PATH; + +function loadSessions() { + if (!existsSync(SESS_PATH)) return {}; + return JSON.parse(readFileSync(SESS_PATH, 'utf8')); +} + +function extractTokenFromSubject(subject = '') { + const m = subject.match(/\[TaskPing\s+#([A-Za-z0-9_-]+)\]/); + return m ? m[1] : null; +} + +function stripReply(text = '') { + const codeBlock = text.match(/```[\s\S]*?```/); + if (codeBlock) return codeBlock[0].replace(/```/g, '').trim(); + const firstLine = text.split(/\r?\n/).find(l => l.trim().length > 0) || ''; + const cmdPrefix = firstLine.match(/^CMD:\s*(.+)$/i); + return (cmdPrefix ? cmdPrefix[1] : firstLine).trim().slice(0, 8192); +} + +async function injectToTmux(sessionName, cmd) { + const utils = new URL('./tmux-utils.sh', import.meta.url).pathname; + const { stdout } = await execa('bash', [utils, sessionName, cmd], { stdio: 'pipe' }); + return stdout.trim(); // 返回 pane 目标名 +} + +async function startImap() { + const client = new ImapFlow({ + host: process.env.IMAP_HOST, + port: Number(process.env.IMAP_PORT || 993), + secure: process.env.IMAP_SECURE === 'true', + auth: { user: process.env.IMAP_USER, pass: process.env.IMAP_PASS } + }); + await client.connect(); + await client.mailboxOpen('INBOX'); + + for await (const msg of client.monitor()) { + if (msg.type !== 'exists') continue; + const { uid } = msg; + const { source } = await client.download('INBOX', uid); + const chunks = []; + for await (const c of source) chunks.push(c); + const parsed = await simpleParser(Buffer.concat(chunks)); + + const token = extractTokenFromSubject(parsed.subject || ''); + if (!token) continue; + + const cmd = stripReply(parsed.text || parsed.html || ''); + if (!cmd) continue; + + const sessionName = `${process.env.TMUX_SESSION_PREFIX || 'taskping'}-${token}`; + const pane = await injectToTmux(sessionName, cmd); + console.log(`[inject] ${sessionName} -> ${pane} :: ${cmd.slice(0, 120)}`); + } +} + +if (process.env.INJECTION_MODE === 'tmux') { + startImap().catch(err => { + console.error(err); + process.exit(1); + }); +} +``` + +### 5.5 启动 +```bash +chmod +x tmux-utils.sh +INJECTION_MODE=tmux node relay-tmux.js +``` + +### 5.6 验收清单 +- [ ] `tmux` 中能看到 Claude 进程始终在运行; +- [ ] 邮件回复注入后,当前 pane 光标位置正确、命令无截断; +- [ ] 会话名与 token 一一对应,过期会话自动清理; +- [ ] `tmux` 重启或 SSH 断连后自动恢复。 + +--- + +## 6. 选择建议与权衡 + +| 维度 | 子进程/PTY | tmux | +|---|---|---| +| 复杂度 | 中 | 低 | +| 稳定性 | 高(直连伪终端) | 高(依赖 tmux 自愈) | +| 多会话并发 | 简单(多 PTY) | 简单(多 pane/session) | +| 恢复/重连 | 需自管 | `tmux` 自带 | +| 可移植性 | 受 pty/Windows 影响 | Linux/macOS 友好 | +| 外部可观测 | 需自行实现 | `tmux capture-pane` 可用 | + +**建议**:桌面或服务器长期常驻,优先 **PTY 模式**;已广泛使用 `tmux` 的团队,直接 **tmux 模式**。 + +--- + +## 7. 测试用例(AI 执行) + +1. **路由正确性**:构造两个 token(A、B),分别回复不同命令,验证注入不串台。 +2. **HTML 邮件**:从 iOS/Android 客户端发送富文本,确认能抽取纯文本命令。 +3. **代码块优先**:正文包含代码块和普通文本时,优先选用代码块内容。 +4. **幂等**:同一封邮件重复投递三次,仅执行一次。 +5. **过期处理**:将 `expiresAt` 回退到过去,验证拒绝注入。 +6. **白名单**:非授权邮箱发送,日志报警且不注入。 +7. **超长命令**:超过 8KB 截断并拒绝,返回提示。 + +--- + +## 8. 运行与守护 + +- 建议用 `pm2`/`systemd` 管理 `relay-pty.js` 或 `relay-tmux.js`; +- 每 20 分钟向 IMAP 发送 NOOP(ImapFlow monitor 已处理),断线自动重连; +- 定时清理过期会话与孤儿 `tmux` session / PTY。 + +--- + +## 9. 故障排查 + +- **注入后无响应**:检查会话路由是否匹配(token、pane/pty);确认 Claude 是否在**等待输入**的状态。 +- **乱码/截断**:调大 pty 列数;tmux 保证 pane 有焦点且无行编辑冲突。 +- **重复执行**:确认以 `Message-ID` 去重;避免多客户端造成“已读后又转发”。 +- **权限问题**:`tmux` 由同一用户启动;Node 进程需与 `tmux` 同一用户。 + +--- + +## 10. 清单(AI 请按序执行) + +1. 安装依赖与 `tmux`(如需)。 +2. 写入 `.env`(IMAP/白名单/模式)。 +3. 准备并加载 `SESSION_MAP_PATH`(由发送提醒侧写入)。 +4. 选择 **INJECTION_MODE=pty** 或 **tmux**,启动对应 `relay-*.js`。 +5. 发送带 `[TaskPing #TOKEN]` 的提醒邮件,收到后**回复命令**进行回归测试。 +6. 通过测试清单后,接入 hooks,开启常驻与告警监控。 diff --git a/src/data/sessions/8003453f-8d95-48c1-992f-9d4fab001c85.json b/src/data/sessions/8003453f-8d95-48c1-992f-9d4fab001c85.json new file mode 100644 index 0000000..efe440b --- /dev/null +++ b/src/data/sessions/8003453f-8d95-48c1-992f-9d4fab001c85.json @@ -0,0 +1,13 @@ +{ + "id": "8003453f-8d95-48c1-992f-9d4fab001c85", + "created": "2025-07-26T18:22:53.439Z", + "expires": "2025-07-27T18:22:53.439Z", + "notification": { + "type": "completed", + "project": "TaskPing", + "message": "[TaskPing] 任务已完成,Claude正在等待下一步指令" + }, + "status": "waiting", + "commandCount": 0, + "maxCommands": 10 +} \ No newline at end of file diff --git a/src/relay/relay-pty.js b/src/relay/relay-pty.js new file mode 100644 index 0000000..2f7740c --- /dev/null +++ b/src/relay/relay-pty.js @@ -0,0 +1,507 @@ +/** + * Relay PTY Service + * 使用 node-pty 管理 Claude Code 进程并注入邮件命令 + */ + +const { ImapFlow } = require('imapflow'); +const { simpleParser } = require('mailparser'); +const pino = require('pino'); +const { readFileSync, writeFileSync, existsSync } = require('fs'); +const { spawn: spawnPty } = require('node-pty'); +const dotenv = require('dotenv'); +const path = require('path'); + +// 加载环境变量 +dotenv.config(); + +// 初始化日志 +const log = pino({ + level: process.env.LOG_LEVEL || 'info', + transport: { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'HH:MM:ss.l', + ignore: 'pid,hostname' + } + } +}); + +// 会话映射文件路径 +const SESS_PATH = process.env.SESSION_MAP_PATH || path.join(__dirname, '../data/session-map.json'); + +// PTY 池,管理活跃的 Claude Code 进程 +const PTY_POOL = new Map(); + +// 已处理的邮件ID集合,用于去重 +const PROCESSED_MESSAGES = new Set(); + +// 加载会话映射 +function loadSessions() { + if (!existsSync(SESS_PATH)) return {}; + try { + return JSON.parse(readFileSync(SESS_PATH, 'utf8')); + } catch (error) { + log.error({ error }, 'Failed to load session map'); + return {}; + } +} + +// 保存会话映射 +function saveSessions(map) { + try { + const dir = path.dirname(SESS_PATH); + if (!existsSync(dir)) { + require('fs').mkdirSync(dir, { recursive: true }); + } + writeFileSync(SESS_PATH, JSON.stringify(map, null, 2)); + } catch (error) { + log.error({ error }, 'Failed to save session map'); + } +} + +// 标准化允许的发件人列表 +function normalizeAllowlist() { + return (process.env.ALLOWED_SENDERS || '') + .split(',') + .map(s => s.trim().toLowerCase()) + .filter(Boolean); +} + +const ALLOW = new Set(normalizeAllowlist()); + +// 检查发件人是否在白名单中 +function isAllowed(addressObj) { + if (!addressObj) return false; + const list = [] + .concat(addressObj.value || []) + .map(a => (a.address || '').toLowerCase()); + return list.some(addr => ALLOW.has(addr)); +} + +// 从主题中提取 TaskPing token +function extractTokenFromSubject(subject = '') { + // 支持多种格式: [TaskPing #TOKEN], [TaskPing TOKEN], TaskPing: TOKEN + const patterns = [ + /\[TaskPing\s+#([A-Za-z0-9_-]+)\]/, + /\[TaskPing\s+([A-Za-z0-9_-]+)\]/, + /TaskPing:\s*([A-Za-z0-9_-]+)/i + ]; + + for (const pattern of patterns) { + const match = subject.match(pattern); + if (match) return match[1]; + } + + return null; +} + +// 从会话ID中提取 token (支持向后兼容) +function extractTokenFromSessionId(sessionId) { + if (!sessionId) return null; + + // 如果sessionId本身就是token格式 + if (/^[A-Za-z0-9_-]+$/.test(sessionId)) { + return sessionId; + } + + // 从UUID格式的sessionId中提取token(如果有映射) + const sessions = loadSessions(); + for (const [token, session] of Object.entries(sessions)) { + if (session.sessionId === sessionId) { + return token; + } + } + + return null; +} + +// 清理邮件回复内容,提取命令 +function stripReply(text = '') { + // 优先识别 ``` 代码块 + const codeBlock = text.match(/```[\s\S]*?```/); + if (codeBlock) { + return codeBlock[0].replace(/```/g, '').trim(); + } + + // 查找 CMD: 前缀的命令 + const cmdMatch = text.match(/^CMD:\s*(.+)$/im); + if (cmdMatch) { + return cmdMatch[1].trim(); + } + + // 清理邮件内容,移除引用和签名 + const lines = text.split(/\r?\n/); + const cleanLines = []; + + for (const line of lines) { + // 检测邮件引用开始标记 + if (line.match(/^>+\s*/) || // 引用标记 + line.includes('-----Original Message-----') || + line.includes('--- Original Message ---') || + line.includes('在') && line.includes('写道:') || + line.includes('On') && line.includes('wrote:') || + line.includes('会话ID:') || + line.includes('Session ID:')) { + break; + } + + // 检测邮件签名 + if (line.match(/^--\s*$/) || + line.includes('Sent from') || + line.includes('发自我的')) { + break; + } + + cleanLines.push(line); + } + + // 获取有效内容的第一行 + const cleanText = cleanLines.join('\n').trim(); + const firstLine = cleanText.split(/\r?\n/).find(l => l.trim().length > 0) || ''; + + // 长度限制 + return firstLine.slice(0, 8192).trim(); +} + +// 处理邮件消息 +async function handleMailMessage(source, uid) { + try { + const parsed = await simpleParser(source); + + // 检查是否已处理过 + const messageId = parsed.messageId; + if (messageId && PROCESSED_MESSAGES.has(messageId)) { + log.debug({ messageId }, 'Message already processed, skipping'); + return; + } + + // 验证发件人 + if (!isAllowed(parsed.from)) { + log.warn({ from: parsed.from?.text }, 'Sender not allowed'); + return; + } + + // 提取 token + const subject = parsed.subject || ''; + let token = extractTokenFromSubject(subject); + + // 如果主题中没有token,尝试从邮件头或正文中提取 + if (!token) { + // 检查自定义邮件头 + const sessionIdHeader = parsed.headers?.get('x-taskping-session-id'); + if (sessionIdHeader) { + token = extractTokenFromSessionId(sessionIdHeader); + } + + // 从正文中查找会话ID + if (!token) { + const bodyText = parsed.text || ''; + const sessionMatch = bodyText.match(/会话ID:\s*([a-f0-9-]{36})/i); + if (sessionMatch) { + token = extractTokenFromSessionId(sessionMatch[1]); + } + } + } + + if (!token) { + log.warn({ subject }, 'No token found in email'); + return; + } + + // 加载会话信息 + const sessions = loadSessions(); + const sess = sessions[token]; + + if (!sess) { + log.warn({ token }, 'Session not found'); + return; + } + + // 检查会话是否过期 + if (sess.expiresAt && sess.expiresAt * 1000 < Date.now()) { + log.warn({ token }, 'Session expired'); + // 清理过期会话 + delete sessions[token]; + saveSessions(sessions); + return; + } + + // 提取命令 + const cmd = stripReply(parsed.text || parsed.html || ''); + if (!cmd) { + log.warn({ token }, 'Empty command after stripping'); + return; + } + + // 获取或创建 PTY + const pty = await getOrCreatePty(token, sess); + if (!pty) { + log.error({ token }, 'Failed to get PTY'); + return; + } + + // 注入命令 + log.info({ + token, + command: cmd.slice(0, 120), + from: parsed.from?.text + }, 'Injecting command to Claude Code'); + + // 发送命令并按回车 + pty.write(cmd + '\r'); + + // 标记邮件为已处理 + if (messageId) { + PROCESSED_MESSAGES.add(messageId); + } + + // 更新会话状态 + sess.lastCommand = cmd; + sess.lastCommandAt = Date.now(); + sess.commandCount = (sess.commandCount || 0) + 1; + sessions[token] = sess; + saveSessions(sessions); + + } catch (error) { + log.error({ error, uid }, 'Error handling mail message'); + } +} + +// 获取或创建 PTY 实例 +async function getOrCreatePty(token, session) { + // 检查是否已有 PTY 实例 + if (PTY_POOL.has(token)) { + const pty = PTY_POOL.get(token); + // 检查 PTY 是否还活着 + try { + // node-pty 没有直接的 isAlive 方法,但我们可以尝试写入空字符来测试 + pty.write(''); + return pty; + } catch (error) { + log.warn({ token }, 'PTY is dead, removing from pool'); + PTY_POOL.delete(token); + } + } + + // 创建新的 PTY 实例 + try { + const shell = process.env.CLAUDE_CLI_PATH || 'claude'; + const args = []; + + // 如果会话有特定的工作目录,设置它 + const cwd = session.cwd || process.cwd(); + + log.info({ token, shell, cwd }, 'Spawning new Claude Code PTY'); + + const pty = spawnPty(shell, args, { + name: 'xterm-256color', + cols: 120, + rows: 40, + cwd: cwd, + env: process.env + }); + + // 存储 PTY 实例 + PTY_POOL.set(token, pty); + + // 设置输出处理(可选:记录到文件或发送通知) + pty.onData((data) => { + // 可以在这里添加输出日志或通知逻辑 + if (process.env.PTY_OUTPUT_LOG === 'true') { + log.debug({ token, output: data.slice(0, 200) }, 'PTY output'); + } + }); + + // 处理 PTY 退出 + pty.onExit(({ exitCode, signal }) => { + log.info({ token, exitCode, signal }, 'PTY exited'); + PTY_POOL.delete(token); + + // 更新会话状态 + const sessions = loadSessions(); + if (sessions[token]) { + sessions[token].ptyExited = true; + sessions[token].ptyExitedAt = Date.now(); + saveSessions(sessions); + } + }); + + // 等待 Claude Code 初始化 + await new Promise(resolve => setTimeout(resolve, 1000)); + + return pty; + + } catch (error) { + log.error({ error, token }, 'Failed to spawn PTY'); + return null; + } +} + +// 启动 IMAP 监听 +async function startImap() { + const client = new ImapFlow({ + host: process.env.IMAP_HOST, + port: Number(process.env.IMAP_PORT || 993), + secure: process.env.IMAP_SECURE === 'true', + auth: { + user: process.env.IMAP_USER, + pass: process.env.IMAP_PASS + }, + logger: false // 禁用 ImapFlow 的内置日志 + }); + + try { + await client.connect(); + log.info('Connected to IMAP server'); + + // 打开收件箱 + const lock = await client.getMailboxLock('INBOX'); + + try { + // 获取最近的未读邮件 + const messages = await client.search({ seen: false }); + if (messages.length > 0) { + log.info(`Found ${messages.length} unread messages`); + + for (const uid of messages) { + const { source } = await client.download(uid, '1', { uid: true }); + const chunks = []; + for await (const chunk of source) { + chunks.push(chunk); + } + await handleMailMessage(Buffer.concat(chunks), uid); + + // 标记为已读 + await client.messageFlagsAdd(uid, ['\\Seen'], { uid: true }); + } + } + + // 监听新邮件 + log.info('Starting IMAP monitor...'); + + for await (const msg of client.idle()) { + if (msg.path === 'INBOX' && msg.type === 'exists') { + log.debug({ count: msg.count }, 'New message notification'); + + // 获取最新的邮件 + const messages = await client.search({ seen: false }); + for (const uid of messages) { + const { source } = await client.download(uid, '1', { uid: true }); + const chunks = []; + for await (const chunk of source) { + chunks.push(chunk); + } + await handleMailMessage(Buffer.concat(chunks), uid); + + // 标记为已读 + await client.messageFlagsAdd(uid, ['\\Seen'], { uid: true }); + } + } + } + } finally { + lock.release(); + } + } catch (error) { + log.error({ error }, 'IMAP error'); + throw error; + } finally { + await client.logout(); + } +} + +// 清理过期会话和孤儿 PTY +function cleanupSessions() { + const sessions = loadSessions(); + const now = Date.now(); + let cleaned = 0; + + for (const [token, session] of Object.entries(sessions)) { + // 清理过期会话 + if (session.expiresAt && session.expiresAt * 1000 < now) { + delete sessions[token]; + cleaned++; + + // 终止相关的 PTY + if (PTY_POOL.has(token)) { + const pty = PTY_POOL.get(token); + try { + pty.kill(); + } catch (error) { + log.warn({ token, error }, 'Failed to kill PTY'); + } + PTY_POOL.delete(token); + } + } + } + + if (cleaned > 0) { + log.info({ cleaned }, 'Cleaned up expired sessions'); + saveSessions(sessions); + } +} + +// 主函数 +async function main() { + // 验证配置 + const required = ['IMAP_HOST', 'IMAP_USER', 'IMAP_PASS']; + const missing = required.filter(key => !process.env[key]); + + if (missing.length > 0) { + log.error({ missing }, 'Missing required environment variables'); + process.exit(1); + } + + // 显示配置信息 + log.info({ + mode: 'pty', + imapHost: process.env.IMAP_HOST, + imapUser: process.env.IMAP_USER, + allowedSenders: Array.from(ALLOW), + sessionMapPath: SESS_PATH + }, 'Starting relay-pty service'); + + // 定期清理 + setInterval(cleanupSessions, 5 * 60 * 1000); // 每5分钟清理一次 + + // 处理退出信号 + process.on('SIGINT', () => { + log.info('Shutting down...'); + + // 终止所有 PTY + for (const [token, pty] of PTY_POOL.entries()) { + try { + pty.kill(); + } catch (error) { + log.warn({ token }, 'Failed to kill PTY on shutdown'); + } + } + + process.exit(0); + }); + + // 启动 IMAP 监听 + while (true) { + try { + await startImap(); + } catch (error) { + log.error({ error }, 'IMAP connection lost, retrying in 30s...'); + await new Promise(resolve => setTimeout(resolve, 30000)); + } + } +} + +// 仅在作为主模块运行时启动 +if (require.main === module) { + main().catch(error => { + log.error({ error }, 'Fatal error'); + process.exit(1); + }); +} + +module.exports = { + handleMailMessage, + getOrCreatePty, + extractTokenFromSubject, + stripReply +}; \ No newline at end of file diff --git a/start-relay-pty.js b/start-relay-pty.js new file mode 100755 index 0000000..a8077fb --- /dev/null +++ b/start-relay-pty.js @@ -0,0 +1,152 @@ +#!/usr/bin/env node + +/** + * TaskPing PTY Relay 启动脚本 + * 启动基于 node-pty 的邮件命令中继服务 + */ + +const { spawn } = require('child_process'); +const path = require('path'); +const fs = require('fs'); + +// 检查环境配置 +function checkConfig() { + const envPath = path.join(__dirname, '.env'); + + if (!fs.existsSync(envPath)) { + console.error('❌ 错误: 未找到 .env 配置文件'); + console.log('\n请先复制 .env.example 到 .env 并配置您的邮件信息:'); + console.log(' cp .env.example .env'); + console.log(' 然后编辑 .env 文件填入您的邮件配置\n'); + process.exit(1); + } + + // 加载环境变量 + require('dotenv').config(); + + // 检查必需的配置 + const required = ['IMAP_HOST', 'IMAP_USER', 'IMAP_PASS']; + const missing = required.filter(key => !process.env[key]); + + if (missing.length > 0) { + console.error('❌ 错误: 缺少必需的环境变量:'); + missing.forEach(key => console.log(` - ${key}`)); + console.log('\n请编辑 .env 文件并填入所有必需的配置\n'); + process.exit(1); + } + + console.log('✅ 配置检查通过'); + console.log(`📧 IMAP服务器: ${process.env.IMAP_HOST}`); + console.log(`👤 邮件账号: ${process.env.IMAP_USER}`); + console.log(`🔒 白名单发件人: ${process.env.ALLOWED_SENDERS || '(未设置,将接受所有邮件)'}`); + console.log(`💾 会话存储路径: ${process.env.SESSION_MAP_PATH || '(使用默认路径)'}`); + console.log(''); +} + +// 创建会话示例 +function createExampleSession() { + const sessionMapPath = process.env.SESSION_MAP_PATH || path.join(__dirname, 'src/data/session-map.json'); + const sessionDir = path.dirname(sessionMapPath); + + // 确保目录存在 + if (!fs.existsSync(sessionDir)) { + fs.mkdirSync(sessionDir, { recursive: true }); + } + + // 如果会话文件不存在,创建一个示例 + if (!fs.existsSync(sessionMapPath)) { + const exampleToken = 'TEST123'; + const exampleSession = { + [exampleToken]: { + type: 'pty', + createdAt: Math.floor(Date.now() / 1000), + expiresAt: Math.floor((Date.now() + 24 * 60 * 60 * 1000) / 1000), // 24小时后过期 + cwd: process.cwd(), + description: '测试会话 - 发送邮件时主题包含 [TaskPing #TEST123]' + } + }; + + fs.writeFileSync(sessionMapPath, JSON.stringify(exampleSession, null, 2)); + console.log(`📝 已创建示例会话文件: ${sessionMapPath}`); + console.log(`🔑 测试Token: ${exampleToken}`); + console.log(' 发送测试邮件时,主题中包含: [TaskPing #TEST123]'); + console.log(''); + } +} + +// 启动服务 +function startService() { + console.log('🚀 正在启动 TaskPing PTY Relay 服务...\n'); + + const relayPath = path.join(__dirname, 'src/relay/relay-pty.js'); + + // 使用 node 直接运行,这样可以看到完整的日志输出 + const relay = spawn('node', [relayPath], { + stdio: 'inherit', + env: { + ...process.env, + INJECTION_MODE: 'pty' + } + }); + + // 处理退出 + process.on('SIGINT', () => { + console.log('\n⏹️ 正在停止服务...'); + relay.kill('SIGINT'); + process.exit(0); + }); + + relay.on('error', (error) => { + console.error('❌ 启动失败:', error.message); + process.exit(1); + }); + + relay.on('exit', (code, signal) => { + if (signal) { + console.log(`\n服务已停止 (信号: ${signal})`); + } else if (code !== 0) { + console.error(`\n服务异常退出 (代码: ${code})`); + process.exit(code); + } + }); +} + +// 显示使用说明 +function showInstructions() { + console.log('📖 使用说明:'); + console.log('1. 在 Claude Code 中执行任务时,会发送包含 Token 的提醒邮件'); + console.log('2. 回复该邮件,内容为要执行的命令'); + console.log('3. 支持的命令格式:'); + console.log(' - 直接输入命令文本'); + console.log(' - 使用 CMD: 前缀,如 "CMD: 继续"'); + console.log(' - 使用代码块包裹,如:'); + console.log(' ```'); + console.log(' 你的命令'); + console.log(' ```'); + console.log('4. 系统会自动提取命令并注入到对应的 Claude Code 会话中'); + console.log('\n⌨️ 按 Ctrl+C 停止服务\n'); + console.log('━'.repeat(60) + '\n'); +} + +// 主函数 +function main() { + console.log('╔══════════════════════════════════════════════════════════╗'); + console.log('║ TaskPing PTY Relay Service ║'); + console.log('║ 邮件命令中继服务 - 基于 node-pty 的 PTY 模式 ║'); + console.log('╚══════════════════════════════════════════════════════════╝\n'); + + // 检查配置 + checkConfig(); + + // 创建示例会话 + createExampleSession(); + + // 显示使用说明 + showInstructions(); + + // 启动服务 + startService(); +} + +// 运行 +main(); \ No newline at end of file diff --git a/test-email-reply.js b/test-email-reply.js new file mode 100755 index 0000000..69f1cce --- /dev/null +++ b/test-email-reply.js @@ -0,0 +1,231 @@ +#!/usr/bin/env node + +/** + * TaskPing 邮件回复测试工具 + * 用于测试邮件命令提取和 PTY 注入功能 + */ + +const path = require('path'); +const fs = require('fs'); + +// 加载 relay-pty 模块 +const { + extractTokenFromSubject, + stripReply, + handleMailMessage +} = require('./src/relay/relay-pty'); + +// 测试用例 +const testCases = [ + { + name: '基本命令', + email: { + subject: 'Re: [TaskPing #TEST123] 任务等待您的指示', + from: { text: 'user@example.com', value: [{ address: 'user@example.com' }] }, + text: '继续执行\n\n> 原始邮件内容...' + }, + expectedToken: 'TEST123', + expectedCommand: '继续执行' + }, + { + name: 'CMD前缀', + email: { + subject: 'Re: TaskPing: ABC789', + from: { text: 'user@example.com', value: [{ address: 'user@example.com' }] }, + text: 'CMD: npm run build\n\n发自我的iPhone' + }, + expectedToken: 'ABC789', + expectedCommand: 'npm run build' + }, + { + name: '代码块', + email: { + subject: 'Re: [TaskPing #XYZ456]', + from: { text: 'user@example.com', value: [{ address: 'user@example.com' }] }, + text: '这是我的命令:\n\n```\ngit add .\ngit commit -m "Update"\n```\n\n谢谢!' + }, + expectedToken: 'XYZ456', + expectedCommand: 'git add .\ngit commit -m "Update"' + }, + { + name: '复杂邮件引用', + email: { + subject: 'Re: [TaskPing #TASK999] 请输入下一步操作', + from: { text: 'boss@company.com', value: [{ address: 'boss@company.com' }] }, + text: `yes, please continue + +-- +Best regards, +Boss + +On 2024-01-01, TaskPing wrote: +> 任务已完成第一步 +> 会话ID: 12345-67890 +> 请回复您的下一步指示` + }, + expectedToken: 'TASK999', + expectedCommand: 'yes, please continue' + }, + { + name: 'HTML邮件转纯文本', + email: { + subject: 'Re: [TaskPing #HTML123]', + from: { text: 'user@example.com', value: [{ address: 'user@example.com' }] }, + html: '
运行测试套件

原始邮件...
', + text: '运行测试套件\n\n> 原始邮件...' + }, + expectedToken: 'HTML123', + expectedCommand: '运行测试套件' + } +]; + +// 运行测试 +function runTests() { + console.log('🧪 TaskPing 邮件解析测试\n'); + + let passed = 0; + let failed = 0; + + testCases.forEach((testCase, index) => { + console.log(`测试 ${index + 1}: ${testCase.name}`); + + try { + // 测试 Token 提取 + const token = extractTokenFromSubject(testCase.email.subject); + if (token === testCase.expectedToken) { + console.log(` ✅ Token提取正确: ${token}`); + } else { + console.log(` ❌ Token提取错误: 期望 "${testCase.expectedToken}", 实际 "${token}"`); + failed++; + console.log(''); + return; + } + + // 测试命令提取 + const command = stripReply(testCase.email.text || testCase.email.html); + if (command === testCase.expectedCommand) { + console.log(` ✅ 命令提取正确: "${command}"`); + passed++; + } else { + console.log(` ❌ 命令提取错误:`); + console.log(` 期望: "${testCase.expectedCommand}"`); + console.log(` 实际: "${command}"`); + failed++; + } + + } catch (error) { + console.log(` ❌ 测试出错: ${error.message}`); + failed++; + } + + console.log(''); + }); + + // 显示结果 + console.log('━'.repeat(50)); + console.log(`测试完成: ${passed} 通过, ${failed} 失败`); + + if (failed === 0) { + console.log('\n✅ 所有测试通过!'); + } else { + console.log('\n❌ 部分测试失败,请检查实现'); + } +} + +// 测试实际邮件处理 +async function testEmailProcessing() { + console.log('\n\n📧 测试邮件处理流程\n'); + + // 设置测试环境变量 + process.env.ALLOWED_SENDERS = 'user@example.com,boss@company.com'; + process.env.SESSION_MAP_PATH = path.join(__dirname, 'test-session-map.json'); + + // 创建测试会话 + const testSessions = { + 'TEST123': { + type: 'pty', + createdAt: Math.floor(Date.now() / 1000), + expiresAt: Math.floor((Date.now() + 3600000) / 1000), + cwd: process.cwd() + } + }; + + fs.writeFileSync(process.env.SESSION_MAP_PATH, JSON.stringify(testSessions, null, 2)); + console.log('✅ 创建测试会话文件'); + + // 模拟邮件消息 + const { simpleParser } = require('mailparser'); + const testEmail = `From: user@example.com +To: taskping@example.com +Subject: Re: [TaskPing #TEST123] 测试 +Content-Type: text/plain; charset=utf-8 + +这是测试命令 + +> 原始邮件内容...`; + + try { + console.log('🔄 处理模拟邮件...'); + + // 注意:实际的 handleMailMessage 会尝试创建 PTY + // 在测试环境中可能会失败,这是预期的 + await handleMailMessage(Buffer.from(testEmail), 'test-uid-123'); + + console.log('✅ 邮件处理流程完成(注意:PTY创建可能失败,这在测试中是正常的)'); + } catch (error) { + console.log(`⚠️ 邮件处理出错(预期的): ${error.message}`); + } + + // 清理测试文件 + if (fs.existsSync(process.env.SESSION_MAP_PATH)) { + fs.unlinkSync(process.env.SESSION_MAP_PATH); + console.log('🧹 清理测试文件'); + } +} + +// 显示集成说明 +function showIntegrationGuide() { + console.log('\n\n📚 集成指南\n'); + console.log('1. 配置邮件服务器信息:'); + console.log(' 编辑 .env 文件,填入 IMAP 配置'); + console.log(''); + console.log('2. 设置白名单发件人:'); + console.log(' ALLOWED_SENDERS=your-email@gmail.com'); + console.log(''); + console.log('3. 创建会话映射:'); + console.log(' 当发送提醒邮件时,在 session-map.json 中添加:'); + console.log(' {'); + console.log(' "YOUR_TOKEN": {'); + console.log(' "type": "pty",'); + console.log(' "createdAt": 1234567890,'); + console.log(' "expiresAt": 1234567890,'); + console.log(' "cwd": "/path/to/project"'); + console.log(' }'); + console.log(' }'); + console.log(''); + console.log('4. 启动服务:'); + console.log(' ./start-relay-pty.js'); + console.log(''); + console.log('5. 发送测试邮件:'); + console.log(' 主题包含 [TaskPing #YOUR_TOKEN]'); + console.log(' 正文为要执行的命令'); +} + +// 主函数 +async function main() { + console.log('╔══════════════════════════════════════════════════════════╗'); + console.log('║ TaskPing Email Reply Test Suite ║'); + console.log('╚══════════════════════════════════════════════════════════╝\n'); + + // 运行单元测试 + runTests(); + + // 测试邮件处理 + await testEmailProcessing(); + + // 显示集成指南 + showIntegrationGuide(); +} + +// 运行测试 +main().catch(console.error); \ No newline at end of file