实现邮件回复到 Claude Code CLI 的 PTY 模式
- 基于 relay-injection.md 方案一实现 - 使用 node-pty 管理 Claude Code 进程 - 支持从邮件回复中提取命令并注入到 CLI - 添加完整的测试工具和使用文档 - 支持多种邮件格式和命令输入方式 功能特性: - 邮件监听和命令提取 - PTY 进程管理和命令注入 - 会话管理和过期控制 - 安全验证(发件人白名单) - 详细的日志和错误处理 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
45576e5a3e
commit
ba4a6343aa
|
|
@ -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
|
||||
|
|
@ -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`
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
package.json
13
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": [
|
||||
|
|
|
|||
|
|
@ -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": "<CA+abc@mail.example.com>",
|
||||
"imapUid": 12345
|
||||
},
|
||||
"XYZ789": {
|
||||
"type": "tmux",
|
||||
"session": "taskping-XYZ789",
|
||||
"pane": "taskping-XYZ789.0",
|
||||
"createdAt": 1732672200,
|
||||
"expiresAt": 1732679400,
|
||||
"messageId": "<CA+xyz@mail.example.com>"
|
||||
}
|
||||
}
|
||||
```
|
||||
> 注:会话映射由**发送提醒时**创建(包含 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-<token>`;
|
||||
- Claude Code 在该 pane 里运行;
|
||||
- 收到邮件 → 定位到 pane → `tmux send-keys -t <pane> "<cmd>" 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,开启常驻与告警监控。
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
@ -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: '<div>运行测试套件</div><br><blockquote>原始邮件...</blockquote>',
|
||||
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);
|
||||
Loading…
Reference in New Issue