From e8ae74bc775c46da7d1c1c201cb1e6ec01920474 Mon Sep 17 00:00:00 2001 From: duguwanglong Date: Tue, 31 Mar 2026 13:05:42 +0800 Subject: [PATCH] Fix: Remove dingtalk channel --- .../.github/workflows/ai-fix-and-test.yml | 518 --- .../.github/workflows/issue-to-dingtalk.yml | 49 - .../.github/workflows/pr-review.yml | 108 - .../dingtalk-openclaw-connector/.gitignore | 17 - .../dingtalk-openclaw-connector/.npmignore | 4 - .../dingtalk-openclaw-connector/LICENSE | 21 - .../dingtalk-openclaw-connector/README.md | 665 --- .../dingtalk-openclaw-connector/bun.lock | 154 - .../docs/RELEASE_NOTES_V0.7.2.md | 143 - .../docs/RELEASE_NOTES_V0.7.3.md | 149 - .../docs/RELEASE_NOTES_v0.7.0.md | 142 - .../docs/RELEASE_NOTES_v0.7.1.md | 74 - .../openclaw.plugin.json | 15 - .../dingtalk-openclaw-connector/package.json | 60 - .../dingtalk-openclaw-connector/plugin.ts | 3867 ----------------- .flocks/plugins/channels/dingtalk/dingtalk.py | 247 -- .flocks/plugins/channels/dingtalk/runner.ts | 355 -- 17 files changed, 6588 deletions(-) delete mode 100644 .flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/.github/workflows/ai-fix-and-test.yml delete mode 100644 .flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/.github/workflows/issue-to-dingtalk.yml delete mode 100644 .flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/.github/workflows/pr-review.yml delete mode 100644 .flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/.gitignore delete mode 100644 .flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/.npmignore delete mode 100644 .flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/LICENSE delete mode 100644 .flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/README.md delete mode 100644 .flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/bun.lock delete mode 100644 .flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/docs/RELEASE_NOTES_V0.7.2.md delete mode 100644 .flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/docs/RELEASE_NOTES_V0.7.3.md delete mode 100644 .flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/docs/RELEASE_NOTES_v0.7.0.md delete mode 100644 .flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/docs/RELEASE_NOTES_v0.7.1.md delete mode 100644 .flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/openclaw.plugin.json delete mode 100644 .flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/package.json delete mode 100644 .flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/plugin.ts delete mode 100644 .flocks/plugins/channels/dingtalk/dingtalk.py delete mode 100644 .flocks/plugins/channels/dingtalk/runner.ts diff --git a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/.github/workflows/ai-fix-and-test.yml b/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/.github/workflows/ai-fix-and-test.yml deleted file mode 100644 index 02625ff27..000000000 --- a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/.github/workflows/ai-fix-and-test.yml +++ /dev/null @@ -1,518 +0,0 @@ -name: 🤖 AI 修复分支与测试 - -on: - issues: - types: [labeled] - workflow_dispatch: - inputs: - issue_number: - description: 'Issue 编号' - required: true - type: string - test_mode: - description: '测试模式' - required: false - default: 'local' - type: choice - options: - - local - - github - -env: - DINGTALK_WEBHOOK: ${{ secrets.DINGTALK_WEBHOOK }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - -jobs: - # 检查是否需要执行 AI 修复 - check-trigger: - runs-on: ubuntu-latest - outputs: - should_run: ${{ steps.check.outputs.should_run }} - issue_number: ${{ steps.check.outputs.issue_number }} - issue_title: ${{ steps.check.outputs.issue_title }} - issue_body: ${{ steps.check.outputs.issue_body }} - steps: - - name: 检查触发条件 - id: check - uses: actions/github-script@v7 - with: - script: | - let issueNumber, issueTitle, issueBody; - - // 判断触发方式 - if (context.eventName === 'workflow_dispatch') { - // 手动触发 - issueNumber = context.payload.inputs.issue_number; - - // 获取 Issue 信息 - const { data: issue } = await github.rest.issues.get({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parseInt(issueNumber) - }); - - issueTitle = issue.title; - issueBody = issue.body || ''; - - } else if (context.eventName === 'issues') { - // Issue 标签触发 - if (context.payload.label?.name !== 'ai-fix') { - console.log('标签不是 ai-fix,跳过'); - core.setOutput('should_run', 'false'); - return; - } - - issueNumber = context.payload.issue.number; - issueTitle = context.payload.issue.title; - issueBody = context.payload.issue.body || ''; - } - - core.setOutput('should_run', 'true'); - core.setOutput('issue_number', issueNumber); - core.setOutput('issue_title', issueTitle); - core.setOutput('issue_body', issueBody); - - console.log(`✅ 触发 AI 修复流程 - Issue #${issueNumber}`); - - # 创建修复分支 - create-branch: - needs: check-trigger - runs-on: ubuntu-latest - if: needs.check-trigger.outputs.should_run == 'true' - outputs: - branch_name: ${{ steps.branch.outputs.branch_name }} - steps: - - name: 检出代码 - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: 创建修复分支 - id: branch - run: | - ISSUE_NUMBER=${{ needs.check-trigger.outputs.issue_number }} - ISSUE_TITLE="${{ needs.check-trigger.outputs.issue_title }}" - - # 生成安全的分支名 - SAFE_TITLE=$(echo "$ISSUE_TITLE" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9' '-' | sed 's/-*$//' | cut -c1-50) - BRANCH_NAME="ai-fix/issue-${ISSUE_NUMBER}-${SAFE_TITLE}" - - echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT - echo "🌿 创建分支: $BRANCH_NAME" - - # 配置 Git - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - # 创建并推送分支 - git checkout -b "$BRANCH_NAME" - git push origin "$BRANCH_NAME" - - echo "✅ 分支已创建: $BRANCH_NAME" - - # 本地 AI 修复(通过 webhook 调用本地 OpenClaw) - ai-fix-local: - needs: [check-trigger, create-branch] - runs-on: ubuntu-latest - if: github.event.inputs.test_mode != 'github' - steps: - - name: 调用本地 AI 修复服务 - uses: actions/github-script@v7 - with: - script: | - const issueNumber = '${{ needs.check-trigger.outputs.issue_number }}'; - const issueTitle = '${{ needs.check-trigger.outputs.issue_title }}'; - const issueBody = `${{ needs.check-trigger.outputs.issue_body }}`; - const branchName = '${{ needs.create-branch.outputs.branch_name }}'; - - // 构建调用本地 OpenClaw 的 payload - const payload = { - action: 'ai-fix-issue', - issue: { - number: issueNumber, - title: issueTitle, - body: issueBody - }, - branch: branchName, - repo: { - owner: context.repo.owner, - name: context.repo.repo - }, - callback_url: `https://api.github.com/repos/${context.repo.owner}/${context.repo.repo}/issues/${issueNumber}/comments` - }; - - console.log('📤 发送 AI 修复请求到本地服务...'); - console.log('Payload:', JSON.stringify(payload, null, 2)); - - // 这里可以通过 webhook 或 API 调用本地服务 - // 实际实现需要根据你的本地 OpenClaw 部署方式调整 - - // 在 Issue 中添加等待评论 - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parseInt(issueNumber), - body: `⏳ **AI 修复进行中**\n\n分支 \`${branchName}\` 已创建\n\n已发送请求到本地 AI 服务,等待修复结果...\n\n**提示:** 本地 AI 修复完成后,测试结果将自动同步到钉钉群。` - }); - - # GitHub 环境 AI 修复(使用 GitHub Actions 运行器) - ai-fix-github: - needs: [check-trigger, create-branch] - runs-on: ubuntu-latest - # 标签触发时自动执行;手动触发时根据 test_mode 选择 - if: | - github.event_name == 'issues' || - github.event.inputs.test_mode == 'github' || - (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode == 'github') - outputs: - lint_status: ${{ steps.lint.outputs.lint_status }} - typecheck_status: ${{ steps.typecheck.outputs.typecheck_status }} - test_result: ${{ steps.test.outputs.test_result }} - steps: - - name: 检出修复分支 - uses: actions/checkout@v4 - with: - ref: ${{ needs.create-branch.outputs.branch_name }} - fetch-depth: 0 - - - name: 设置 Node.js - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: 'npm' - - - name: 安装依赖 - run: npm ci - - - name: 分析 Issue 并调用 AI 生成修复 - id: ai-analysis - env: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} - run: | - echo "🤖 分析 Issue 内容..." - echo "Issue #${{ needs.check-trigger.outputs.issue_number }}: ${{ needs.check-trigger.outputs.issue_title }}" - - # 创建修复标记文件(开始分析) - cat > .ai-fix-info.json << EOF - { - "issue_number": ${{ needs.check-trigger.outputs.issue_number }}, - "issue_title": "${{ needs.check-trigger.outputs.issue_title }}", - "fix_timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", - "fix_method": "github-actions-ai", - "status": "analyzing" - } - EOF - - # 检查是否有 AI API Key - if [ -n "$OPENAI_API_KEY" ] || [ -n "$ANTHROPIC_API_KEY" ] || [ -n "$DASHSCOPE_API_KEY" ]; then - echo "✅ 发现 AI API Key,将尝试调用 AI 模型生成修复..." - - # 准备 Issue 内容 - ISSUE_TITLE="${{ needs.check-trigger.outputs.issue_title }}" - ISSUE_BODY='${{ needs.check-trigger.outputs.issue_body }}' - - # 创建 AI 提示词 - cat > /tmp/ai-prompt.txt << 'PROMPT' - 你是一个专业的代码修复助手。请根据下面的 GitHub Issue 描述,分析代码问题并提供修复建议。 - - Issue 标题: $ISSUE_TITLE - Issue 描述: $ISSUE_BODY - - 请分析: - 1. 问题的根本原因 - 2. 需要修改的文件 - 3. 具体的修复方案 - - 请以结构化的方式输出分析结果。 - PROMPT - - # 尝试调用 OpenAI API(如果配置了) - if [ -n "$OPENAI_API_KEY" ]; then - echo "🔄 调用 OpenAI API..." - curl -s -X POST https://api.openai.com/v1/chat/completions \ - -H "Authorization: Bearer $OPENAI_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "model": "gpt-4o-mini", - "messages": [ - {"role": "system", "content": "你是一个专业的代码修复助手。请分析 GitHub Issue 并提供修复建议。"}, - {"role": "user", "content": "Issue 标题: '"$ISSUE_TITLE"'\n\nIssue 描述: '"$ISSUE_BODY"'\n\n请分析问题原因并提供修复方案。"} - ], - "temperature": 0.3 - }' > /tmp/ai-response.json 2>/dev/null || echo "⚠️ OpenAI API 调用失败" - - if [ -f /tmp/ai-response.json ] && grep -q '"choices"' /tmp/ai-response.json; then - echo "✅ OpenAI 分析完成" - cat /tmp/ai-response.json | jq -r '.choices[0].message.content' > /tmp/ai-analysis.md || true - fi - fi - - # 尝试调用 Anthropic API(如果配置了且 OpenAI 失败) - if [ -n "$ANTHROPIC_API_KEY" ] && [ ! -f /tmp/ai-analysis.md ]; then - echo "🔄 调用 Anthropic API..." - curl -s -X POST https://api.anthropic.com/v1/messages \ - -H "x-api-key: $ANTHROPIC_API_KEY" \ - -H "Content-Type: application/json" \ - -H "anthropic-version: 2023-06-01" \ - -d '{ - "model": "claude-3-haiku-20240307", - "max_tokens": 2000, - "messages": [ - {"role": "user", "content": "Issue 标题: '"$ISSUE_TITLE"'\n\nIssue 描述: '"$ISSUE_BODY"'\n\n请分析问题原因并提供修复方案。"} - ] - }' > /tmp/ai-response.json 2>/dev/null || echo "⚠️ Anthropic API 调用失败" - - if [ -f /tmp/ai-response.json ] && grep -q '"content"' /tmp/ai-response.json; then - echo "✅ Anthropic 分析完成" - cat /tmp/ai-response.json | jq -r '.content[0].text' > /tmp/ai-analysis.md || true - fi - fi - - # 尝试调用阿里云百炼 Qwen3.5 Plus API(如果配置了且前面的都失败) - if [ -n "$DASHSCOPE_API_KEY" ] && [ ! -f /tmp/ai-analysis.md ]; then - echo "🔄 调用阿里云百炼 Qwen3.5 Plus API..." - curl -s -X POST https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions \ - -H "Authorization: Bearer $DASHSCOPE_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "model": "qwen3.5-plus", - "messages": [ - {"role": "system", "content": "你是一个专业的代码修复助手。请分析 GitHub Issue 并提供修复建议。"}, - {"role": "user", "content": "Issue 标题: '"$ISSUE_TITLE"'\n\nIssue 描述: '"$ISSUE_BODY"'\n\n请分析问题原因并提供修复方案。"} - ], - "temperature": 0.3 - }' > /tmp/ai-response.json 2>/dev/null || echo "⚠️ Qwen3.5 Plus API 调用失败" - - if [ -f /tmp/ai-response.json ] && grep -q '"choices"' /tmp/ai-response.json; then - echo "✅ Qwen3.5 Plus 分析完成" - cat /tmp/ai-response.json | jq -r '.choices[0].message.content' > /tmp/ai-analysis.md || true - fi - fi - - # 保存 AI 分析结果 - if [ -f /tmp/ai-analysis.md ]; then - echo "📝 AI 分析结果:" - cat /tmp/ai-analysis.md - cp /tmp/ai-analysis.md .ai-fix-analysis.md - else - echo "⚠️ AI 分析未完成,将使用默认分析流程" - echo "AI 分析暂不可用,请手动查看 Issue 并修复。" > .ai-fix-analysis.md - fi - else - echo "⚠️ 未配置 AI API Key (OPENAI_API_KEY、ANTHROPIC_API_KEY 或 DASHSCOPE_API_KEY)" - echo "请配置 Secrets 后重新运行,或手动修复代码。" - echo "未配置 AI API Key,请手动修复。" > .ai-fix-analysis.md - fi - - echo "analysis_complete=true" >> $GITHUB_OUTPUT - - - name: 运行代码检查 - id: lint - continue-on-error: true - run: | - echo "🔍 运行代码检查..." - npm run lint 2>&1 | tee lint-output.txt || true - echo "lint_status=$?" >> $GITHUB_OUTPUT - - - name: 运行类型检查 - id: typecheck - continue-on-error: true - run: | - echo "🔍 运行类型检查..." - npm run type-check 2>&1 | tee typecheck-output.txt || true - echo "typecheck_status=$?" >> $GITHUB_OUTPUT - - - name: 运行测试 - id: test - continue-on-error: true - run: | - echo "🧪 运行自动化测试..." - npm test 2>&1 | tee test-output.txt || true - echo "test_status=$?" >> $GITHUB_OUTPUT - - # 提取测试结果摘要 - if grep -q "PASS\|✓" test-output.txt; then - echo "test_result=passed" >> $GITHUB_OUTPUT - else - echo "test_result=failed" >> $GITHUB_OUTPUT - fi - - - name: 提交修复结果 - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - # 更新修复状态 - cat > .ai-fix-info.json << EOF - { - "issue_number": ${{ needs.check-trigger.outputs.issue_number }}, - "issue_title": "${{ needs.check-trigger.outputs.issue_title }}", - "fix_timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", - "fix_method": "github-actions-ai", - "status": "completed", - "test_results": { - "lint": "${{ steps.lint.outputs.lint_status == '0' && 'passed' || 'failed' }}", - "typecheck": "${{ steps.typecheck.outputs.typecheck_status == '0' && 'passed' || 'failed' }}", - "test": "${{ steps.test.outputs.test_result }}" - } - } - EOF - - # 添加 AI 分析结果(如果存在) - if [ -f .ai-fix-analysis.md ]; then - git add .ai-fix-analysis.md - fi - - git add .ai-fix-info.json - git commit -m "🤖 AI 修复: Issue #${{ needs.check-trigger.outputs.issue_number }} - 自动修复结果" || echo "无变更需要提交" - git push origin ${{ needs.create-branch.outputs.branch_name }} - - # 创建 PR - create-pr: - needs: [check-trigger, create-branch, ai-fix-github] - runs-on: ubuntu-latest - if: always() && (needs.ai-fix-github.result == 'success' || needs.ai-fix-github.result == 'skipped') - steps: - - name: 创建 Pull Request - uses: actions/github-script@v7 - with: - script: | - const issueNumber = parseInt('${{ needs.check-trigger.outputs.issue_number }}'); - const branchName = '${{ needs.create-branch.outputs.branch_name }}'; - - try { - const { data: pr } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: `🤖 AI 修复: ${context.payload.issue?.title || `Issue #${issueNumber}`}`, - head: branchName, - base: 'main', - body: `## 🤖 AI 自动修复\n\n**关联 Issue:** #${issueNumber}\n\n**修复分支:** \`${branchName}\`\n\n**修复说明:**\n- 由 GitHub Actions 自动生成的修复\n- 基于 Issue 描述进行代码分析和修复\n- 包含自动化测试结果\n\n**测试状态:**\n- 代码检查: ${{ needs.ai-fix-github.outputs.lint_status == '0' && '✅ 通过' || '⚠️ 需关注' }}\n- 类型检查: ${{ needs.ai-fix-github.outputs.typecheck_status == '0' && '✅ 通过' || '⚠️ 需关注' }}\n- 单元测试: ${{ needs.ai-fix-github.outputs.test_result == 'passed' && '✅ 通过' || '⚠️ 需关注' }}\n\n**需要人工审核:**\n- [ ] 代码逻辑正确性\n- [ ] 边界情况处理\n- [ ] 性能影响评估\n\n---\n*此 PR 由 AI 修复工作流自动生成*`, - draft: false - }); - - console.log(`✅ PR 创建成功: #${pr.number}`); - core.setOutput('pr_number', pr.number); - core.setOutput('pr_url', pr.html_url); - - } catch (error) { - console.error('创建 PR 失败:', error.message); - // PR 可能已存在,查找现有 PR - const { data: prs } = await github.rest.pulls.list({ - owner: context.repo.owner, - repo: context.repo.repo, - head: `${context.repo.owner}:${branchName}`, - base: 'main', - state: 'open' - }); - - if (prs.length > 0) { - console.log(`ℹ️ PR 已存在: #${prs[0].number}`); - core.setOutput('pr_number', prs[0].number); - core.setOutput('pr_url', prs[0].html_url); - } - } - - # 发送测试结果到钉钉 - notify-result: - needs: [check-trigger, create-branch, ai-fix-github, create-pr] - runs-on: ubuntu-latest - if: always() - outputs: - notified: ${{ steps.notify.outputs.notified }} - steps: - - name: 发送测试结果到钉钉 - id: notify - uses: actions/github-script@v7 - with: - script: | - const issueNumber = '${{ needs.check-trigger.outputs.issue_number }}'; - const issueTitle = '${{ needs.check-trigger.outputs.issue_title }}'; - const branchName = '${{ needs.create-branch.outputs.branch_name }}'; - - // 确定整体状态(处理 ai-fix-github 被跳过的情况) - const lintStatus = '${{ needs.ai-fix-github.outputs.lint_status }}'; - const typecheckStatus = '${{ needs.ai-fix-github.outputs.typecheck_status }}'; - const testResult = '${{ needs.ai-fix-github.outputs.test_result }}'; - - // 判断执行模式 - const isLocalMode = '${{ github.event.inputs.test_mode }}' === 'local' && '${{ github.event_name }}' === 'workflow_dispatch'; - const isLabelTrigger = '${{ github.event_name }}' === 'issues'; - - let allPassed, statusEmoji, statusText, testResultsText; - - if (isLocalMode && lintStatus === '') { - // Local 模式:GitHub Actions 未执行测试 - statusEmoji = '⏳'; - statusText = '等待本地 AI 修复'; - testResultsText = '- 📝 代码检查: ⏳ 等待本地执行\n- 🔍 类型检查: ⏳ 等待本地执行\n- 🧪 单元测试: ⏳ 等待本地执行'; - allPassed = false; - } else { - // GitHub 模式(标签触发或手动选择 github 模式):显示实际测试结果 - allPassed = lintStatus === '0' && typecheckStatus === '0' && testResult === 'passed'; - statusEmoji = allPassed ? '✅' : '⚠️'; - statusText = allPassed ? '测试通过' : '需要关注'; - testResultsText = `- 📝 代码检查: ${lintStatus === '0' ? '✅ 通过' : '❌ 失败'}\n- 🔍 类型检查: ${typecheckStatus === '0' ? '✅ 通过' : '❌ 失败'}\n- 🧪 单元测试: ${testResult === 'passed' ? '✅ 通过' : '❌ 失败'}`; - } - - // 构建钉钉消息(确保包含小写关键字以支持 Custom Keywords 模式) - const message = { - msgtype: 'markdown', - markdown: { - title: `${statusEmoji} AI 修复测试完成`, - text: `## ${statusEmoji} AI 修复测试${statusText}\n\n**Issue:** [#${issueNumber} ${issueTitle}](https://github.com/${context.repo.owner}/${context.repo.repo}/issues/${issueNumber})\n\n**修复分支:** \`${branchName}\`\n\n**测试结果:**\n${testResultsText}\n\n**下一步:**\n${allPassed ? '✅ 所有测试通过,请审核 PR 后合并' : (lintStatus === '' ? '⏳ 本地 AI 修复完成后,请手动运行测试' : '⚠️ 部分测试未通过,请查看详细日志')}\n\n---\n⏰ ${new Date().toLocaleString('zh-CN')}\n\n**关键词**: issue, pr, ai-fix` - } - }; - - // 发送钉钉通知 - const axios = require('axios'); - try { - await axios.post(process.env.DINGTALK_WEBHOOK, message); - console.log('✅ 钉钉通知发送成功'); - core.setOutput('notified', 'true'); - } catch (error) { - console.error('❌ 钉钉通知发送失败:', error.message); - core.setOutput('notified', 'false'); - } - - # 更新 Issue 状态 - update-issue: - needs: [check-trigger, create-branch, ai-fix-github, create-pr, notify-result] - runs-on: ubuntu-latest - if: always() - steps: - - name: 更新 Issue 评论 - uses: actions/github-script@v7 - with: - script: | - const issueNumber = parseInt('${{ needs.check-trigger.outputs.issue_number }}'); - const branchName = '${{ needs.create-branch.outputs.branch_name }}'; - - const lintStatus = '${{ needs.ai-fix-github.outputs.lint_status }}'; - const typecheckStatus = '${{ needs.ai-fix-github.outputs.typecheck_status }}'; - const testResult = '${{ needs.ai-fix-github.outputs.test_result }}'; - - // 判断执行模式 - const isLocalMode = '${{ github.event.inputs.test_mode }}' === 'local' && '${{ github.event_name }}' === 'workflow_dispatch'; - const isLabelTrigger = '${{ github.event_name }}' === 'issues'; - - let comment = ''; - - if (isLocalMode && lintStatus === '') { - // Local 模式:GitHub Actions 未执行测试 - comment = `⏳ **AI 修复分支已创建**\n\n**修复分支:** \`${branchName}\`\n\n已触发本地 AI 修复流程。\n\n**注意:** 本地 AI 修复完成后,请手动推送代码并创建 PR。\n\n钉钉群已收到通知,可在群内查看进度。`; - } else if (lintStatus === '0' && typecheckStatus === '0' && testResult === 'passed') { - comment = `✅ **AI 修复完成**\n\n所有测试已通过!\n\n**修复分支:** \`${branchName}\`\n\n**测试结果:**\n- 📝 代码检查: ✅ 通过\n- 🔍 类型检查: ✅ 通过\n- 🧪 单元测试: ✅ 通过\n\n已创建 PR,请审核后合并。`; - } else { - comment = `⚠️ **AI 修复完成(需关注)**\n\n**修复分支:** \`${branchName}\`\n\n**测试结果:**\n- 📝 代码检查: ${lintStatus === '0' ? '✅ 通过' : '❌ 失败'}\n- 🔍 类型检查: ${typecheckStatus === '0' ? '✅ 通过' : '❌ 失败'}\n- 🧪 单元测试: ${testResult === 'passed' ? '✅ 通过' : '❌ 失败'}\n\n部分测试未通过,请查看详细日志并手动调整。`; - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - body: comment - }); diff --git a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/.github/workflows/issue-to-dingtalk.yml b/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/.github/workflows/issue-to-dingtalk.yml deleted file mode 100644 index 093447305..000000000 --- a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/.github/workflows/issue-to-dingtalk.yml +++ /dev/null @@ -1,49 +0,0 @@ -# Issue 自动同步到钉钉群 -# 当有新 Issue 时,自动发送到钉钉群(不包括 comment) -name: 🔔 Issue Notification to DingTalk - -on: - issues: - types: [opened, reopened, closed, labeled] - -jobs: - notify: - runs-on: ubuntu-latest - steps: - - name: 📬 Send Issue to DingTalk - uses: actions/github-script@v7 - with: - script: | - const webhook = process.env.DINGTALK_WEBHOOK; - if (!webhook) { - console.log('⚠️ DINGTALK_WEBHOOK not set, skipping notification'); - return; - } - - const payload = context.payload; - const issue = payload.issue; - const action = payload.action; - - // 构建消息标题和内容(确保包含关键字 "issue" 以支持 Custom Keywords 模式) - const title = `[${action.toUpperCase()}] Issue #${issue.number}: ${issue.title}`; - const content = issue.body?.substring(0, 500) || 'No description'; - const url = issue.html_url; - - // 消息内容必须包含关键字(如 "issue")以支持 Custom Keywords 安全模式 - const message = { - msgtype: 'markdown', - markdown: { - title: 'GitHub Issue 通知', - text: `## 🔔 GitHub Issue 通知\n\n**${title}**\n\n${content}${content.length >= 500 ? '...' : ''}\n\n[点击查看详情](${url})\n\n---\n📦 ${context.repo.owner}/${context.repo.repo}\n\n**关键词**: issue` - } - }; - - await fetch(webhook, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(message) - }); - - console.log('✅ DingTalk notification sent'); - env: - DINGTALK_WEBHOOK: ${{ secrets.DINGTALK_WEBHOOK }} diff --git a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/.github/workflows/pr-review.yml b/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/.github/workflows/pr-review.yml deleted file mode 100644 index 79c871158..000000000 --- a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/.github/workflows/pr-review.yml +++ /dev/null @@ -1,108 +0,0 @@ -# PR 审核提醒工作流 -# PR 创建/更新时自动通知钉钉群,提醒审核 -name: 👀 PR Review Request - -on: - pull_request: - types: [opened, reopened, ready_for_review] - pull_request_review: - types: [submitted] - -jobs: - notify-review-request: - if: github.event_name == 'pull_request' && github.event.action != 'closed' - runs-on: ubuntu-latest - steps: - - name: 📢 Notify DingTalk - Review Request - if: env.DINGTALK_WEBHOOK != '' - run: | - PR_TITLE="${{ github.event.pull_request.title }}" - PR_URL="${{ github.event.pull_request.html_url }}" - PR_AUTHOR="${{ github.event.pull_request.user.login }}" - PR_NUMBER="${{ github.event.pull_request.number }}" - - curl -X POST "${{ secrets.DINGTALK_WEBHOOK }}" \ - -H "Content-Type: application/json" \ - -d "{ - \"msgtype\": \"markdown\", - \"markdown\": { - \"title\": \"PR审核提醒\", - \"text\": \"## 👀 新的 PR 需要审核\\n\\n**#${PR_NUMBER}**: ${PR_TITLE}\\n\\n**作者**: @${PR_AUTHOR}\\n\\n[点击审核](${PR_URL})\\n\\n---\\n📦 ${{ github.repository }}\\n\\n**关键词**: pr\" - } - }" - env: - DINGTALK_WEBHOOK: ${{ secrets.DINGTALK_WEBHOOK }} - - notify-review-completed: - if: github.event_name == 'pull_request_review' - runs-on: ubuntu-latest - steps: - - name: 📢 Notify DingTalk - Review Completed - if: env.DINGTALK_WEBHOOK != '' - run: | - REVIEW_STATE="${{ github.event.review.state }}" - PR_TITLE="${{ github.event.pull_request.title }}" - PR_URL="${{ github.event.pull_request.html_url }}" - REVIEWER="${{ github.event.review.user.login }}" - PR_NUMBER="${{ github.event.pull_request.number }}" - - if [ "$REVIEW_STATE" == "approved" ]; then - EMOJI="✅" - STATUS="已通过" - elif [ "$REVIEW_STATE" == "changes_requested" ]; then - EMOJI="🔄" - STATUS="需要修改" - else - EMOJI="💬" - STATUS="已评论" - fi - - curl -X POST "${{ secrets.DINGTALK_WEBHOOK }}" \ - -H "Content-Type: application/json" \ - -d "{ - \"msgtype\": \"markdown\", - \"markdown\": { - \"title\": \"PR审核完成\", - \"text\": \"## ${EMOJI} PR 审核${STATUS}\\n\\n**#${PR_NUMBER}**: ${PR_TITLE}\\n\\n**审核人**: @${REVIEWER}\\n\\n**状态**: ${REVIEW_STATE}\\n\\n[查看详情](${PR_URL})\\n\\n---\\n📦 ${{ github.repository }}\\n\\n**关键词**: pr\" - } - }" - env: - DINGTALK_WEBHOOK: ${{ secrets.DINGTALK_WEBHOOK }} - - auto-merge-check: - if: github.event_name == 'pull_request_review' && github.event.review.state == 'approved' - runs-on: ubuntu-latest - steps: - - name: 📥 Checkout Code - uses: actions/checkout@v4 - - - name: 🔍 Check Auto-Merge Conditions - id: check - run: | - # 检查是否满足自动合并条件 - REVIEWS=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ - "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/reviews") - - APPROVALS=$(echo "$REVIEWS" | grep -c '"state": "APPROVED"' || true) - - echo "approvals=$APPROVALS" >> $GITHUB_OUTPUT - echo "✅ 当前 approvals: $APPROVALS" - - - name: 📢 Notify DingTalk - Ready to Merge - if: steps.check.outputs.approvals >= 1 && env.DINGTALK_WEBHOOK != '' - run: | - PR_TITLE="${{ github.event.pull_request.title }}" - PR_URL="${{ github.event.pull_request.html_url }}" - PR_NUMBER="${{ github.event.pull_request.number }}" - - curl -X POST "${{ secrets.DINGTALK_WEBHOOK }}" \ - -H "Content-Type: application/json" \ - -d "{ - \"msgtype\": \"markdown\", - \"markdown\": { - \"title\": \"PR可合并\", - \"text\": \"## 🎉 PR 已审核通过,可以合并\\n\\n**#${PR_NUMBER}**: ${PR_TITLE}\\n\\n**审核状态**: ✅ 已通过\\n\\n**操作**: 点击合并到 main\\n\\n[立即合并](${PR_URL})\\n\\n---\\n📦 ${{ github.repository }}\\n\\n**关键词**: pr\" - } - }" - env: - DINGTALK_WEBHOOK: ${{ secrets.DINGTALK_WEBHOOK }} diff --git a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/.gitignore b/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/.gitignore deleted file mode 100644 index b22138cdd..000000000 --- a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/.gitignore +++ /dev/null @@ -1,17 +0,0 @@ -__pycache__/ -*.py[cod] -*.egg-info/ -dist/ -build/ -.venv/ -.env -*.egg -node_modules/ -package-lock.json - -# IDE -.idea/ - -openclaw/ -.aone_copilot/ -AGENTS.md \ No newline at end of file diff --git a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/.npmignore b/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/.npmignore deleted file mode 100644 index b32fe0f90..000000000 --- a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/.npmignore +++ /dev/null @@ -1,4 +0,0 @@ -.git/ -.claude/ -.env -.gitignore diff --git a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/LICENSE b/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/LICENSE deleted file mode 100644 index ad00cc544..000000000 --- a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 DingTalk Real Team - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/README.md b/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/README.md deleted file mode 100644 index 2070fa119..000000000 --- a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/README.md +++ /dev/null @@ -1,665 +0,0 @@ -# DingTalk OpenClaw Connector - -以下提供两种方案连接到 [OpenClaw](https://openclaw.ai) Gateway,分别是钉钉机器人和钉钉 DEAP Agent。 - -> 📝 **版本信息**:当前版本 v0.7.5 | [查看变更日志](CHANGELOG.md) | [发布说明](docs/RELEASE_NOTES_V0.7.4.md) | [发布指南](RELEASE.md) - -## 快速导航 - -| 方案 | 名称 | 详情 | -|------|------|------| -| 方案一 | 钉钉机器人集成 | [查看详情](#方案一钉钉机器人集成) | -| 方案二 | 钉钉 DEAP Agent 集成 | [查看详情](#方案二钉钉-deap-agent-集成) | - -# 方案一:钉钉机器人集成 -将钉钉机器人连接到 [OpenClaw](https://openclaw.ai) Gateway,支持 AI Card 流式响应和会话管理。 - -## 特性 - -- ✅ **AI Card 流式响应** - 打字机效果,实时显示 AI 回复 -- ✅ **会话持久化** - 同一用户的多轮对话共享上下文 -- ✅ **会话与记忆隔离** - 按单聊/群聊/群区分 session,不同场景下的对话上下文互不干扰,可配置跨会话记忆共享 -- ✅ **超时自动新会话** - 默认 30 分钟无活动自动开启新对话 -- ✅ **手动新会话** - 发送 `/new` 或 `新会话` 清空对话历史 -- ✅ **图片自动上传** - 本地图片路径自动上传到钉钉 -- ✅ **主动发送消息** - 支持主动给钉钉个人或群发送消息 -- ✅ **富媒体接收** - 支持接收 JPEG/PNG 图片消息,自动下载并传递给视觉模型 -- ✅ **文件附件提取** - 支持解析 .docx、.pdf、纯文本文件(.txt、.md、.json 等)和二进制文件(.xlsx、.pptx、.zip 等) -- ✅ **音频消息支持** - 支持发送音频消息,支持多种格式(mp3、wav、amr、ogg),自动提取音频时长,支持通过标记或文件附件方式发送 -- ✅ **钉钉文档 API** - 支持创建、追加、搜索、列举钉钉文档 -- ✅ **多 Agent 路由** - 支持一个连接器实例连接多个 Agent,多个钉钉机器人可分别绑定到不同 Agent,实现角色分工和专业化服务 -- ✅ **Markdown 表格转换** - 自动将 Markdown 表格转换为钉钉支持的文本格式,提升消息可读性 -- ✅ **异步模式** - 立即回执用户消息,后台处理任务,然后主动推送最终结果作为独立消息(可选) - - -## 架构 - -```mermaid -graph LR - subgraph "钉钉" - A["用户发消息"] --> B["Stream WebSocket"] - E["AI 流式卡片"] --> F["用户看到回复"] - end - - subgraph "Connector" - B --> C["消息处理器"] - C -->|"HTTP SSE"| D["Gateway /v1/chat/completions"] - D -->|"流式 chunk"| C - C -->|"streaming API"| E - end -``` - -## 效果 - -image -image - -## 安装 - -### 1. 安装插件 - -```bash -# 通过 npm 安装(推荐) -openclaw plugins install @dingtalk-real-ai/dingtalk-connector - -# 或通过 Git 安装 -openclaw plugins install https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector.git - -# 升级插件 -openclaw plugins update dingtalk-connector - -# 或本地开发模式 -git clone https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector.git -cd dingtalk-openclaw-connector -npm install -openclaw plugins install -l . -``` - -> **⚠️ 旧版本升级提示:** 如果你之前安装过旧版本的 Clawdbot/Moltbot 或 0.4.0 以下版本的 connector 插件,可能会出现兼容性问题,请参考 [Q: 升级后出现插件加载异常或配置不生效](#q-升级后出现插件加载异常或配置不生效)。 - -### 2. 配置 - -在 `~/.openclaw/openclaw.json` 中添加: - -```json5 -{ - "channels": { - "dingtalk-connector": { - "enabled": true, - "clientId": "dingxxxxxxxxx", // 钉钉 AppKey - "clientSecret": "your_secret_here", // 钉钉 AppSecret - "gatewayToken": "", // 可选:Gateway 认证 token, openclaw.json配置中 gateway.auth.token 的值 - "gatewayPassword": "", // 可选:Gateway 认证 password(与 token 二选一) - "sessionTimeout": 1800000, // ⚠️ 已废弃,请使用 Gateway 的 session.reset.idleMinutes 配置 - "separateSessionByConversation": true, // 可选:是否按单聊/群聊/群区分 session(默认:true) - "groupSessionScope": "group", // 可选:群聊会话隔离策略,group=群共享,group_sender=群内用户独立(默认:group) - "sharedMemoryAcrossConversations": false, // 可选:是否在不同会话间共享记忆;false 时群聊与私聊、不同群记忆隔离(默认:false) - "asyncMode": false, // 可选:异步模式,立即回执用户消息,后台处理并推送结果(默认:false) - "ackText": "🫡 任务已接收" // 可选:异步模式下的回执消息文本(默认:'🫡 任务已接收,处理中...') - } - }, - "gateway": { // gateway通常是已有的节点,配置时注意把http部分追加到已有节点下 - "http": { - "endpoints": { - "chatCompletions": { - "enabled": true - } - } - } - } -} -``` - -或者在 OpenClaw Dashboard 页面配置: - -image - -### 3. 重启 Gateway - -```bash -openclaw gateway restart -``` - -验证: - -```bash -openclaw plugins list # 确认 dingtalk-connector 已加载 -``` - -## 创建钉钉机器人 - -1. 打开 [钉钉开放平台](https://open.dingtalk.com/) -2. 进入 **应用开发** → **企业内部开发** → 创建应用 -3. 添加 **机器人** 能力,消息接收模式选择 **Stream 模式** -4. 开通权限: - - `Card.Streaming.Write` - AI Card 流式响应 - - `Card.Instance.Write` - AI Card 实例写入 - - `qyapi_robot_sendmsg` - 主动发送消息 - - 如需使用文档 API 功能,还需开通文档相关权限 -5. **发布应用**,记录 **AppKey** 和 **AppSecret** - -## 配置参考 - -| 配置项 | 环境变量 | 说明 | -|--------|----------|------| -| `clientId` | `DINGTALK_CLIENT_ID` | 钉钉 AppKey | -| `clientSecret` | `DINGTALK_CLIENT_SECRET` | 钉钉 AppSecret | -| `gatewayToken` | `OPENCLAW_GATEWAY_TOKEN` | Gateway 认证 token(可选) | -| `gatewayPassword` | — | Gateway 认证 password(可选,与 token 二选一) | -| `sessionTimeout` | — | ⚠️ 已废弃,请使用 Gateway 的 [`session.reset.idleMinutes`](https://docs.openclaw.ai/gateway/configuration) 配置 | -| `separateSessionByConversation` | — | 是否按单聊/群聊/群区分 session(默认:true) | -| `groupSessionScope` | — | 群聊会话隔离策略(仅当 separateSessionByConversation=true 时生效):`group`=群共享,`group_sender`=群内用户独立(默认:group) | -| `sharedMemoryAcrossConversations` | — | 是否在不同会话间共享记忆;false 时群聊与私聊、不同群记忆隔离(默认:false) | -| `asyncMode` | — | 异步模式,立即回执用户消息,后台处理并推送结果(默认:false) | -| `ackText` | — | 异步模式下的回执消息文本(默认:'🫡 任务已接收,处理中...') | - -## 会话与记忆隔离 - -连接器支持按单聊、群聊、不同群分别维护独立会话和记忆,确保同一用户在不同场景下的对话上下文互不干扰。 - -### 会话隔离(separateSessionByConversation) - -- **默认开启**(`true`):单聊、群聊、不同群各自拥有独立的 session -- **关闭**(`false`):按用户维度维护 session,不区分单聊/群聊(兼容旧行为) - -### 群聊会话隔离(groupSessionScope) - -仅当 `separateSessionByConversation=true` 时生效: - -- **`group`**(默认):整个群共享一个会话,群内所有用户共用同一个对话上下文 -- **`group_sender`**:群内每个用户独立会话,不同用户的对话上下文互不干扰 - -### 记忆隔离(sharedMemoryAcrossConversations) - -- **默认关闭**(`false`):不同群聊、群聊与私聊之间的记忆隔离,AI 不会混淆不同场景下的对话历史 -- **开启**(`true`):单 Agent 场景下,同一用户在不同会话间共享记忆 - -### 适用场景 - -- ✅ 同一机器人在多个群中服务,希望每个群的对话互不干扰 -- ✅ 用户既在私聊也在群聊中使用机器人,希望私聊与群聊上下文分离 -- ✅ 群内所有成员共享对话上下文(默认 `groupSessionScope: "group"`) -- ✅ 群内每个用户独立对话(设置 `groupSessionScope: "group_sender"`) -- ✅ 需要跨会话共享记忆时,可设置 `sharedMemoryAcrossConversations: true` - -## 异步模式 - -异步模式允许连接器立即回执用户消息,然后在后台处理任务,最后主动推送最终结果作为独立消息。这种模式特别适合处理耗时较长的任务,可以给用户更好的交互体验。 - -### 启用异步模式 - -在配置中设置 `asyncMode: true`: - -```json5 -{ - "channels": { - "dingtalk-connector": { - "enabled": true, - "clientId": "dingxxxxxxxxx", - "clientSecret": "your_secret_here", - "asyncMode": true, // 启用异步模式 - "ackText": "🫡 任务已接收" // 可选:自定义回执消息 - } - } -} -``` - -### 工作流程 - -1. **立即回执** - 用户发送消息后,连接器立即发送回执消息(默认:`🫡 任务已接收,处理中...`) -2. **后台处理** - 连接器在后台调用 Gateway 处理任务,支持文件附件和图片 -3. **推送结果** - 处理完成后,连接器主动推送最终结果作为独立消息 - -### 适用场景 - -- ✅ 处理耗时较长的任务(如文档分析、代码生成等) -- ✅ 需要给用户即时反馈的场景 -- ✅ 希望将处理过程和结果分离的场景 - -### 注意事项 - -- 异步模式下不支持 AI Card 流式响应(因为结果通过主动推送发送) -- 异步模式支持文件附件和图片处理 -- 错误信息也会通过主动推送发送给用户 - -## 多 Agent 配置 - -钉钉 Connector 支持多 Agent 模式,可以配置多个钉钉机器人连接到不同的 Agent,实现角色分工和专业化服务。 - -### 核心配置 - -在 `~/.openclaw/openclaw.json` 中配置多个钉钉账号和 Agent 绑定: - -```json5 -{ - "channels": { - "dingtalk-connector": { - "enabled": true, - "accounts": { - "bot1": { - "enabled": true, - "clientId": "ding_bot1_app_key", - "clientSecret": "bot1_secret" - }, - "bot2": { - "enabled": true, - "clientId": "ding_bot2_app_key", - "clientSecret": "bot2_secret" - } - } - } - }, - "bindings": [ - { - "agentId": "ding-bot1", - "match": { - "channel": "dingtalk-connector", - "accountId": "bot1" - } - }, - { - "agentId": "ding-bot2", - "match": { - "channel": "dingtalk-connector", - "accountId": "bot2" - } - } - ] -} -``` - -### 基于单聊/群聊的路由(peer.kind) - -连接器支持根据会话类型(单聊/群聊)将消息路由到不同的 Agent。这对于以下场景非常有用: - -- **安全隔离**:群聊使用受限功能的 Agent,单聊使用完整功能的 Agent -- **多角色支持**:不同用户或会话类型分配不同的 Agent -- **成本优化**:普通用户路由到低成本模型,VIP 用户使用高端模型 - -#### 配置示例 - -```json5 -{ - "bindings": [ - // 场景1:特定用户的单聊 → main agent(完整功能) - { - "agentId": "main", - "match": { - "channel": "dingtalk-connector", - "peer": { - "kind": "direct", - "id": "YOUR_VIP_USER_ID" - } - } - }, - // 场景2:所有群聊 → guest agent(受限功能) - { - "agentId": "guest", - "match": { - "channel": "dingtalk-connector", - "peer": { - "kind": "group", - "id": "*" - } - } - }, - // 场景3:其他单聊 → guest agent(受限功能) - { - "agentId": "guest", - "match": { - "channel": "dingtalk-connector", - "peer": { - "kind": "direct", - "id": "*" - } - } - } - ] -} -``` - -#### peer.kind 配置说明 - -| 字段 | 类型 | 说明 | -|------|------|------| -| `peer.kind` | `'direct'` \| `'group'` | 会话类型:`direct` 表示单聊,`group` 表示群聊 | -| `peer.id` | `string` | 发送者 ID(单聊)或 `*` 通配符匹配所有 | - -#### 匹配优先级 - -bindings 按以下优先级匹配(从高到低): - -1. **peer.kind + peer.id 精确匹配**:指定会话类型和具体用户 ID -2. **peer.kind + peer.id='*' 通配匹配**:指定会话类型,匹配所有用户 -3. **仅 peer.kind 匹配**:只指定会话类型(无 peer.id) -4. **accountId 匹配**:按钉钉账号路由 -5. **channel 匹配**:仅指定 channel -6. **默认 fallback**:使用 `main` agent - -### 官方文档 - -详细的配置指南和架构说明,请参考 OpenClaw 官方文档: - -- [OpenClaw 多 Agent 架构配置指南](https://gist.github.com/smallnest/c5c13482740fd179e40070e620f66a52) - - -## 会话命令 - -用户可以发送以下命令开启新会话(清空对话历史): - -- `/new`、`/reset`、`/clear` -- `新会话`、`重新开始`、`清空对话` - -## 富媒体接收 - -### 图片消息支持 - -连接器支持接收和处理钉钉中的图片消息: - -- **JPEG 图片** - 直接发送的 JPEG 图片会自动下载到 `~/.openclaw/workspace/media/inbound/` 目录 -- **PNG 图片** - 富文本消息中包含的 PNG 图片会自动提取 URL 和 downloadCode 并下载 -- **视觉模型集成** - 下载的图片会自动传递给视觉模型,AI 可以识别和分析图片内容 - -### 媒体文件存储 - -所有接收的媒体文件会保存在: - -```bash -~/.openclaw/workspace/media/inbound/ -``` - -文件命名格式:`openclaw-media-{timestamp}.{ext}` - -查看媒体目录: - -```bash -ls -la ~/.openclaw/workspace/media/inbound/ -``` - -## 文件附件提取 - -连接器支持自动提取和处理钉钉消息中的文件附件: - -### 支持的文件类型 - -| 文件类型 | 处理方式 | 说明 | -|---------|---------|------| -| `.docx` | 通过 `mammoth` 解析 | 提取 Word 文档中的文本内容,注入到 AI 上下文 | -| `.pdf` | 通过 `pdf-parse` 解析 | 提取 PDF 文档中的文本内容,注入到 AI 上下文 | -| `.txt`、`.md`、`.json` 等 | 直接读取 | 纯文本文件内容直接读取并注入到消息中 | -| `.xlsx`、`.pptx`、`.zip` 等 | 保存到磁盘 | 二进制文件保存到磁盘,文件路径和名称会在消息中报告 | - -### 使用方式 - -直接在钉钉中发送文件附件,连接器会自动: -1. 下载文件到本地 -2. 根据文件类型进行解析或保存 -3. 将文本内容注入到 AI 对话上下文中 - -## 钉钉文档 API - -连接器提供了丰富的钉钉文档操作能力,可在 OpenClaw Agent 中调用: - -### 创建文档 - -```javascript -dingtalk-connector.docs.create({ - spaceId: "your-space-id", - title: "测试文档", - content: "# 测试内容" -}) -``` - -### 追加内容 - -```javascript -dingtalk-connector.docs.append({ - docId: "your-doc-id", - markdownContent: "\n## 追加的内容" -}) -``` - -### 搜索文档 - -```javascript -dingtalk-connector.docs.search({ - keyword: "搜索关键词" -}) -``` - -### 列举文档 - -```javascript -dingtalk-connector.docs.list({ - spaceId: "your-space-id" -}) -``` - -## 多 Agent 路由支持 - -连接器支持同时连接多个 Agent,实现多 Agent 会话隔离: - -- **独立会话空间** - 每个 Agent 拥有独立的会话上下文,互不干扰 -- **灵活路由** - 可根据不同场景将请求路由到不同的 Agent -- **向后兼容** - 单 Agent 场景下功能完全兼容,无需额外配置 - -## 项目结构 - -``` -dingtalk-openclaw-connector/ -├── plugin.ts # 插件入口 -├── openclaw.plugin.json # 插件清单 -├── package.json # npm 依赖 -└── LICENSE -``` - -## 常见问题 - -### Q: 出现 405 错误 - -image - -需要在 `~/.openclaw/openclaw.json` 中启用 chatCompletions 端点: - -```json5 -{ - "gateway": { // gateway通常是已有的节点,配置时注意把http部分追加到已有节点下 - "http": { - "endpoints": { - "chatCompletions": { - "enabled": true - } - } - } - } -} -``` - -### Q: 出现 401 错误 - -image - -检查 `~/.openclaw/openclaw.json` 中的gateway.auth鉴权的 token/password 是否正确: - -image - -### Q: 钉钉机器人无响应 - -1. 确认 Gateway 正在运行:`curl http://127.0.0.1:18789/health` -2. 确认机器人配置为 **Stream 模式**(非 Webhook) -3. 确认 AppKey/AppSecret 正确 - -### Q: AI Card 不显示,只有纯文本 - -需要开通权限 `Card.Streaming.Write` 和 `Card.Instance.Write`,并重新发布应用。 - -### Q: 升级后出现插件加载异常或配置不生效 - -由于官方两次更名(Clawdbot → Moltbot → OpenClaw),旧版本(0.4.0 以下)的 connector 插件可能与新版本不兼容。建议按以下步骤处理: - -1. 先检查 `~/.openclaw/openclaw.json`(或旧版的 `~/.clawdbot/clawdbot.json`、`~/.moltbot/moltbot.json`),如果其中存在 dingtalk 相关的 JSON 节点(如 `channels.dingtalk`、`plugins.entries.dingtalk` 等),请将这些节点全部删除。 - -2. 然后清除旧插件并重新安装: - -```bash -rm -rf ~/.clawdbot/extensions/dingtalk-connector -rm -rf ~/.moltbot/extensions/dingtalk-connector -rm -rf ~/.openclaw/extensions/dingtalk-connector -openclaw plugins install @dingtalk-real-ai/dingtalk-connector -``` - -### Q: 图片不显示 - -1. 确认 `enableMediaUpload: true`(默认开启) -2. 检查日志 `[DingTalk][Media]` 相关输出 -3. 确认钉钉应用有图片上传权限 - -### Q: 图片消息无法识别 - -1. 检查图片是否成功下载到 `~/.openclaw/workspace/media/inbound/` 目录 -2. 确认 Gateway 配置的模型支持视觉能力(vision model) -3. 查看日志中是否有图片下载或处理的错误信息 - -### Q: 文件附件无法解析 - -1. **Word 文档(.docx)**:确认已安装 `mammoth` 依赖包 -2. **PDF 文档**:确认已安装 `pdf-parse` 依赖包 -3. 检查文件是否成功下载,查看日志中的文件处理信息 -4. 对于不支持的二进制文件,会保存到磁盘并在消息中报告文件路径 - -### Q: 钉钉文档 API 调用失败 - -1. 确认钉钉应用已开通文档相关权限 -2. 检查 `spaceId`、`docId` 等参数是否正确 -3. 确认 API 调用时的认证信息(AppKey/AppSecret)有效 -4. 注意:读取文档功能需要 MCP 提供相应的 tool,当前版本暂不支持 - -### Q: 多 Agent 路由如何配置 - -多 Agent 路由功能会自动处理,无需额外配置。连接器会根据配置自动管理多个 Agent 的会话隔离。如需自定义路由逻辑,请参考插件源码中的路由实现。 - -## 依赖 - -| 包 | 用途 | -|----|------| -| `dingtalk-stream` | 钉钉 Stream 协议客户端 | -| `axios` | HTTP 客户端 | -| `mammoth` | Word 文档(.docx)解析 | -| `pdf-parse` | PDF 文档解析 | - -# 方案二:钉钉 DEAP Agent 集成 - -通过将钉钉 [DEAP](https://deap.dingtalk.com) Agent 与 [OpenClaw](https://openclaw.ai) Gateway 连接,实现自然语言驱动的本地设备操作能力。 - -## 核心功能 - -- ✅ **自然语言交互** - 用户在钉钉对话框中输入自然语言指令(如"帮我查找桌面上的 PDF 文件"),Agent 将自动解析并执行相应操作 -- ✅ **内网穿透机制** - 专为本地设备无公网 IP 场景设计,通过 Connector 客户端建立稳定的内外网通信隧道 -- ✅ **跨平台兼容** - 提供 Windows、macOS 和 Linux 系统的原生二进制执行文件,确保各平台下的顺畅运行 - -## 系统架构 - -该方案采用分层架构模式,包含三个核心组件: - -1. **OpenClaw Gateway** - 部署于本地设备,提供标准化 HTTP 接口,负责接收并处理来自云端的操作指令,调动 OpenClaw 引擎执行具体任务 -2. **DingTalk OpenClaw Connector** - 运行于本地环境,构建本地与云端的通信隧道,解决内网设备无公网 IP 的问题 -3. **DingTalk DEAP MCP** - 作为 DEAP Agent 的扩展能力模块,负责将用户自然语言请求经由云端隧道转发至 OpenClaw Gateway - -```mermaid -graph LR - subgraph "钉钉 App" - A["用户与 Agent 对话"] --> B["DEAP Agent"] - end - - subgraph "本地环境" - D["DingTalk OpenClaw Connector"] --> C["OpenClaw Gateway"] - C --> E["PC 操作执行"] - end - - B -.-> D -``` - -## 实施指南 - -### 第一步:部署本地环境 - -确认本地设备已成功安装并启动 OpenClaw Gateway,默认监听地址为 `127.0.0.1:18789`: - -```bash -openclaw gateway start -``` - -#### 配置 Gateway 参数 - -1. 访问 [配置页面](http://127.0.0.1:18789/config) -2. 在 **Auth 标签页** 中设置 Gateway Token 并妥善保存: - - Gateway Auth 配置界面 - -3. 切换至 **Http 标签页**,启用 `OpenAI Chat Completions Endpoint` 功能: - - Gateway Http 配置界面 - -4. 点击右上角 `Save` 按钮完成配置保存 - -### 第二步:获取必要参数 - -#### 获取 corpId - -登录 [钉钉开发者平台](https://open-dev.dingtalk.com) 查看企业 CorpId: - -钉钉开发者平台获取 corpId - -#### 获取 apiKey - -登录 [钉钉 DEAP 平台](https://deap.dingtalk.com),在 **安全与权限** → **API-Key 管理** 页面创建新的 API Key: - -钉钉 DEAP 平台 API-Key 管理 - -### 第三步:启动 Connector 客户端 - -1. 从 [Releases](https://github.com/hoskii/dingtalk-openclaw-connector/releases/tag/v0.0.1) 页面下载适配您操作系统的安装包 -2. 解压并运行 Connector(以 macOS 为例): - - ```bash - unzip connector-mac.zip - ./connector-darwin -deapCorpId YOUR_CORP_ID -deapApiKey YOUR_API_KEY - ``` - -### 第四步:配置 DEAP Agent - -1. 登录 [钉钉 DEAP 平台](https://deap.dingtalk.com),创建新的智能体: - - 新建智能体界面 - -2. 在技能管理页面,搜索并集成 OpenClaw 技能: - - 添加 OpenClaw 技能 - -3. 配置技能参数: - - | 参数 | 来源 | 说明 | - |------|------|------| - | apikey | 第二步获取 | DEAP 平台 API Key | - | apihost | 默认值 | 通常为 `127.0.0.1:18789`,在Windows环境下可能需要配置为 `localhost:18789` 才能正常工作 | - | gatewayToken | 第一步获取 | Gateway 配置的认证令牌 | - - 配置 OpenClaw 技能参数 - -4. 发布 Agent: - - 发布 Agent - -### 第五步:开始使用 - -1. 在钉钉 App 中搜索并找到您创建的 Agent: - - 搜索 Agent - -2. 开始自然语言对话体验: - - 与 Agent 对话 - -## License - -[MIT](LICENSE) diff --git a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/bun.lock b/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/bun.lock deleted file mode 100644 index e566526bc..000000000 --- a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/bun.lock +++ /dev/null @@ -1,154 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "@dingtalk-real-ai/dingtalk-connector", - "dependencies": { - "@ffmpeg-installer/ffmpeg": "^1.1.0", - "axios": "^1.6.0", - "dingtalk-stream": "^2.1.4", - "fluent-ffmpeg": "^2.1.3", - "mammoth": "^1.8.0", - "pdf-parse": "^1.1.1", - }, - }, - }, - "packages": { - "@ffmpeg-installer/darwin-arm64": ["@ffmpeg-installer/darwin-arm64@4.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hYqTiP63mXz7wSQfuqfFwfLOfwwFChUedeCVKkBtl/cliaTM7/ePI9bVzfZ2c+dWu3TqCwLDRWNSJ5pqZl8otA=="], - - "@ffmpeg-installer/darwin-x64": ["@ffmpeg-installer/darwin-x64@4.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z4EyG3cIFjdhlY8wI9aLUXuH8nVt7E9SlMVZtWvSPnm2sm37/yC2CwjUzyCQbJbySnef1tQwGG2Sx+uWhd9IAw=="], - - "@ffmpeg-installer/ffmpeg": ["@ffmpeg-installer/ffmpeg@1.1.0", "", { "optionalDependencies": { "@ffmpeg-installer/darwin-arm64": "4.1.5", "@ffmpeg-installer/darwin-x64": "4.1.0", "@ffmpeg-installer/linux-arm": "4.1.3", "@ffmpeg-installer/linux-arm64": "4.1.4", "@ffmpeg-installer/linux-ia32": "4.1.0", "@ffmpeg-installer/linux-x64": "4.1.0", "@ffmpeg-installer/win32-ia32": "4.1.0", "@ffmpeg-installer/win32-x64": "4.1.0" } }, "sha512-Uq4rmwkdGxIa9A6Bd/VqqYbT7zqh1GrT5/rFwCwKM70b42W5gIjWeVETq6SdcL0zXqDtY081Ws/iJWhr1+xvQg=="], - - "@ffmpeg-installer/linux-arm": ["@ffmpeg-installer/linux-arm@4.1.3", "", { "os": "linux", "cpu": "arm" }, "sha512-NDf5V6l8AfzZ8WzUGZ5mV8O/xMzRag2ETR6+TlGIsMHp81agx51cqpPItXPib/nAZYmo55Bl2L6/WOMI3A5YRg=="], - - "@ffmpeg-installer/linux-arm64": ["@ffmpeg-installer/linux-arm64@4.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dljEqAOD0oIM6O6DxBW9US/FkvqvQwgJ2lGHOwHDDwu/pX8+V0YsDL1xqHbj1DMX/+nP9rxw7G7gcUvGspSoKg=="], - - "@ffmpeg-installer/linux-ia32": ["@ffmpeg-installer/linux-ia32@4.1.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-0LWyFQnPf+Ij9GQGD034hS6A90URNu9HCtQ5cTqo5MxOEc7Rd8gLXrJvn++UmxhU0J5RyRE9KRYstdCVUjkNOQ=="], - - "@ffmpeg-installer/linux-x64": ["@ffmpeg-installer/linux-x64@4.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Y5BWhGLU/WpQjOArNIgXD3z5mxxdV8c41C+U15nsE5yF8tVcdCGet5zPs5Zy3Ta6bU7haGpIzryutqCGQA/W8A=="], - - "@ffmpeg-installer/win32-ia32": ["@ffmpeg-installer/win32-ia32@4.1.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV2D7RlaZv/lrtdhaQ4oETwoFUsUjlUiasiZLDxhEUPdNDWcH1OU9K1xTvqz+OXLdsmYelUDuBS/zkMOTtlUAw=="], - - "@ffmpeg-installer/win32-x64": ["@ffmpeg-installer/win32-x64@4.1.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Drt5u2vzDnIONf4ZEkKtFlbvwj6rI3kxw1Ck9fpudmtgaZIHD4ucsWB2lCZBXRxJgXR+2IMSti+4rtM4C4rXgg=="], - - "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], - - "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - - "async": ["async@0.2.10", "", {}, "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="], - - "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], - - "axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="], - - "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - - "bluebird": ["bluebird@3.4.7", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="], - - "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - - "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], - - "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], - - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], - - "dingbat-to-unicode": ["dingbat-to-unicode@1.0.1", "", {}, "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w=="], - - "dingtalk-stream": ["dingtalk-stream@2.1.4", "", { "dependencies": { "axios": "^1.4.0", "debug": "^4.3.4", "ws": "^8.13.0" } }, "sha512-rgQbXLGWfASuB9onFcqXTnRSj4ZotimhBOnzrB4kS19AaU9lshXiuofs1GAYcKh5uzPWCAuEs3tMtiadTQWP4A=="], - - "duck": ["duck@0.1.12", "", { "dependencies": { "underscore": "^1.13.1" } }, "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg=="], - - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - - "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], - - "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - - "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], - - "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], - - "fluent-ffmpeg": ["fluent-ffmpeg@2.1.3", "", { "dependencies": { "async": "^0.2.9", "which": "^1.1.1" } }, "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q=="], - - "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], - - "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], - - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - - "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], - - "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - - "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], - - "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], - - "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], - - "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - - "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], - - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - - "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], - - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - - "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], - - "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], - - "lop": ["lop@0.4.2", "", { "dependencies": { "duck": "^0.1.12", "option": "~0.2.1", "underscore": "^1.13.1" } }, "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw=="], - - "mammoth": ["mammoth@1.11.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": { "mammoth": "bin/mammoth" } }, "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ=="], - - "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - - "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - - "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "node-ensure": ["node-ensure@0.0.0", "", {}, "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw=="], - - "option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="], - - "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], - - "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], - - "pdf-parse": ["pdf-parse@1.1.4", "", { "dependencies": { "node-ensure": "^0.0.0" } }, "sha512-XRIRcLgk6ZnUbsHsYXExMw+krrPE81hJ6FQPLdBNhhBefqIQKXu/WeTgNBGSwPrfU0v+UCEwn7AoAUOsVKHFvQ=="], - - "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], - - "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], - - "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], - - "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], - - "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], - - "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], - - "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], - - "underscore": ["underscore@1.13.8", "", {}, "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="], - - "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - - "which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="], - - "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], - - "xmlbuilder": ["xmlbuilder@10.1.1", "", {}, "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg=="], - } -} diff --git a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/docs/RELEASE_NOTES_V0.7.2.md b/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/docs/RELEASE_NOTES_V0.7.2.md deleted file mode 100644 index e5440bfda..000000000 --- a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/docs/RELEASE_NOTES_V0.7.2.md +++ /dev/null @@ -1,143 +0,0 @@ -# Release Notes - v0.7.2 - -## ✨ 功能增强版本 / Feature Enhancement Release - -本次更新新增异步模式功能,并修复了多个关键问题,提升了连接器的稳定性和用户体验。 - -This update adds async mode functionality and fixes several critical issues, improving connector stability and user experience. - -## ✨ 新增功能 / New Features - -### 1. 异步模式 / Async Mode - -**功能描述 / Feature Description**:立即回执用户消息,后台处理任务,然后主动推送最终结果作为独立消息 -Immediately acknowledge user messages, process in background, then push the final result as a separate message - -**使用场景 / Use Cases**: -- 处理耗时较长的任务(如文档分析、代码生成等) - Processing time-consuming tasks (e.g., document analysis, code generation) -- 需要给用户即时反馈的场景 - Scenarios requiring immediate user feedback -- 希望将处理过程和结果分离的场景 - Scenarios where separating processing and results is desired - -**配置方式 / Configuration**: - -```json5 -{ - "channels": { - "dingtalk-connector": { - "asyncMode": true, // 启用异步模式 / Enable async mode - "ackText": "🫡 任务已接收" // 可选:自定义回执消息 / Optional: Custom ack message - } - } -} -``` - -**工作流程 / Workflow**: -1. **立即回执** - 用户发送消息后,连接器立即发送回执消息 - **Immediate Acknowledgment** - Connector immediately sends acknowledgment message after user sends message -2. **后台处理** - 连接器在后台调用 Gateway 处理任务,支持文件附件和图片 - **Background Processing** - Connector processes task in background via Gateway, supports file attachments and images -3. **推送结果** - 处理完成后,连接器主动推送最终结果作为独立消息 - **Push Result** - After processing completes, connector proactively pushes final result as separate message - -**影响范围 / Impact**:所有用户均可选择启用此功能,默认关闭,不影响现有使用方式 -All users can optionally enable this feature. Default is off, does not affect existing usage. - -## 🐛 修复 / Fixes - -### 1. 异步模式下 Agent 路由问题修复 / Agent Routing Fix in Async Mode - -**问题描述 / Issue Description**:异步模式下 `streamFromGateway` 调用时缺少 `accountId` 参数,导致会话路由到 undefined agent -In async mode, `streamFromGateway` was called without `accountId`, causing sessions to route to undefined agent - -**修复内容 / Fix**: -- 修复 `streamFromGateway` 调用,正确传递 `accountId` 参数 - Fixed `streamFromGateway` call to correctly pass `accountId` parameter -- 确保异步模式下 Agent 路由正常工作 - Ensures Agent routing works correctly in async mode - -**影响范围 / Impact**:影响使用异步模式的用户,修复后异步模式下的 Agent 路由将正常工作 -Affects users using async mode. After the fix, Agent routing in async mode will work correctly. - -### 2. 默认 Agent 路由修复 / Default Agent Routing Fix - -**问题描述 / Issue Description**:当 `accountId` 为 `'default'` 时,仍然发送 `X-OpenClaw-Agent-Id` header,导致路由异常 -When `accountId` is `'default'`, `X-OpenClaw-Agent-Id` header was still sent, causing routing issues - -**修复内容 / Fix**: -- 当 `accountId` 为 `'default'` 时跳过 `X-OpenClaw-Agent-Id` header - Skip `X-OpenClaw-Agent-Id` header when `accountId` is `'default'` -- 让 gateway 路由到其配置的默认 agent - Let gateway route to its configured default agent - -**影响范围 / Impact**:影响使用默认 Agent 配置的用户,修复后默认 Agent 路由将正常工作 -Affects users with default Agent configuration. After the fix, default Agent routing will work correctly. - -### 3. 异步模式内容处理修复 / Async Mode Content Fix - -**问题描述 / Issue Description**:异步模式使用原始 `content.text`,未包含文件附件内容 -Async mode used raw `content.text`, did not include file attachment content - -**修复内容 / Fix**: -- 使用 `userContent`(包含文件附件)替代原始 `content.text` - Use `userContent` (includes file attachments) instead of raw `content.text` -- 确保文件附件内容正确传递给 Gateway - Ensures file attachment content is correctly passed to Gateway - -**影响范围 / Impact**:影响使用异步模式且发送文件附件的用户,修复后文件附件将正确处理 -Affects users using async mode and sending file attachments. After the fix, file attachments will be processed correctly. - -### 4. 异步模式图片支持修复 / Async Mode Image Support Fix - -**问题描述 / Issue Description**:异步模式下未将图片路径传递给 Gateway stream -Image paths were not passed to Gateway stream in async mode - -**修复内容 / Fix**: -- 将 `imageLocalPaths` 传递给 gateway stream - Pass `imageLocalPaths` to gateway stream -- 确保图片在异步模式下正确处理 - Ensures images are processed correctly in async mode - -**影响范围 / Impact**:影响使用异步模式且发送图片的用户,修复后图片将正确处理 -Affects users using async mode and sending images. After the fix, images will be processed correctly. - -## 🔧 配置更新 / Configuration Updates - -### 新增配置项 / New Configuration Options - -- **`asyncMode`**(类型:`boolean`,默认:`false`)- 启用异步模式 - **`asyncMode`** (type: `boolean`, default: `false`) - Enable async mode -- **`ackText`**(类型:`string`,默认:`'🫡 任务已接收,处理中...'`)- 自定义回执消息文本 - **`ackText`** (type: `string`, default: `'🫡 任务已接收,处理中...'`) - Custom ack message text - -## 📥 安装升级 / Installation & Upgrade - -```bash -# 通过 npm 安装最新版本 / Install latest version via npm -openclaw plugins install @dingtalk-real-ai/dingtalk-connector - -# 或升级现有版本 / Or upgrade existing version -openclaw plugins update dingtalk-connector - -# 通过 Git 安装 / Install via Git -openclaw plugins install https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector.git -``` - -## 🔗 相关链接 / Related Links - -- [完整变更日志 / Full Changelog](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/blob/main/CHANGELOG.md) -- [使用文档 / Documentation](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/blob/main/README.md) -- [问题反馈 / Issue Feedback](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/issues) - -## 🙏 致谢 / Acknowledgments - -感谢所有贡献者和用户的支持与反馈! -Thanks to all contributors and users for their support and feedback! - ---- - -**发布日期 / Release Date**:2026-03-05 -**版本号 / Version**:v0.7.2 -**兼容性 / Compatibility**:OpenClaw Gateway 0.4.0+ diff --git a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/docs/RELEASE_NOTES_V0.7.3.md b/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/docs/RELEASE_NOTES_V0.7.3.md deleted file mode 100644 index a8e85793a..000000000 --- a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/docs/RELEASE_NOTES_V0.7.3.md +++ /dev/null @@ -1,149 +0,0 @@ -# Release Notes - v0.7.3 - -## 🔧 兼容性修复版本 / Compatibility Fix Release - -本次更新主要修复了 0.7.0 版本引入的默认 Agent 路由回归问题,确保与 0.7.0 之前版本的向下兼容性。 - -This update primarily fixes the default Agent routing regression introduced in version 0.7.0, ensuring backward compatibility with versions before 0.7.0. - -## 🐛 修复 / Fixes - -### 1. 默认 Agent 路由兼容性修复 / Default Agent Routing Compatibility Fix - -**问题描述 / Issue Description**: -在 0.7.0 版本中,默认路由(没有配置 `accountId` 时)从 `main` agent 错误地改成了 `default` agent,导致与 0.7.0 之前版本的行为不一致,可能影响现有用户的配置和会话路由。 -In version 0.7.0, the default route (when no `accountId` was configured) was incorrectly changed from `main` agent to `default` agent, causing inconsistency with versions before 0.7.0, which may affect existing user configurations and session routing. - -**修复内容 / Fix**: -- 恢复默认路由到 `main` agent,与 0.7.0 之前版本保持一致 - Restored default routing to `main` agent, consistent with versions before 0.7.0 -- 使用 `__default__` 作为内部默认账号标识,避免与用户配置的 `default` 账号冲突 - Use `__default__` as internal default account identifier to avoid conflicts with user-configured `default` accounts -- 在 `streamFromGateway` 中将 `__default__` 正确映射到 `main` agent - Correctly map `__default__` to `main` agent in `streamFromGateway` - -**影响范围 / Impact**: -影响所有使用默认配置(未配置 `accounts`)的用户。修复后,默认路由将恢复到 0.7.0 之前的行为,路由到 `main` agent,确保向下兼容性。 -Affects all users using default configuration (without `accounts` configuration). After the fix, default routing will be restored to pre-0.7.0 behavior, routing to `main` agent, ensuring backward compatibility. - -### 2. 用户配置 `default` 账号映射修复 / User-Configured `default` Account Mapping Fix - -**问题描述 / Issue Description**: -当用户显式配置名为 `default` 的账号时,系统会错误地将其映射为内部默认账号,导致用户配置的 `default` 账号无法正常使用。 -When users explicitly configure an account named `default`, the system incorrectly maps it to the internal default account, preventing the user-configured `default` account from working properly. - -**修复内容 / Fix**: -- 使用 `__default__` 作为内部默认账号标识,与用户配置的 `default` 账号区分开 - Use `__default__` as internal default account identifier, separate from user-configured `default` accounts -- 确保用户配置的 `default` 账号能够正常使用 - Ensure user-configured `default` accounts can work properly - -**影响范围 / Impact**: -影响显式配置了名为 `default` 的账号的用户。修复后,用户配置的 `default` 账号将能够正常工作,不会被错误映射。 -Affects users who explicitly configured an account named `default`. After the fix, user-configured `default` accounts will work properly and will not be incorrectly mapped. - -## 🔧 改进 / Improvements - -### 1. 代码结构优化 / Code Structure Optimization - -**改进内容 / Improvements**: -- 抽取 `DEFAULT_ACCOUNT_ID` 常量到文件顶部(值为 `__default__`),统一管理默认账号标识 - Extracted `DEFAULT_ACCOUNT_ID` constant to file top (value: `__default__`), unified management of default account identifier -- 更新所有相关代码,使用常量替代硬编码的字符串 - Updated all related code to use constants instead of hardcoded strings -- 提高代码可维护性和可读性 - Improved code maintainability and readability - -**影响范围 / Impact**: -内部代码改进,不影响用户使用,但提高了代码质量和可维护性。 -Internal code improvements, does not affect user usage, but improves code quality and maintainability. - -### 2. API 文档更新 / API Documentation Updates - -**改进内容 / Improvements**: -- 更新 API 文档注释,移除对 `default` 的硬编码引用 - Updated API documentation comments, removed hardcoded references to `default` -- 明确说明 `accountId` 参数为可选,不传则使用默认配置 - Clarified that `accountId` parameter is optional, uses default configuration if not provided - -**影响范围 / Impact**: -文档改进,帮助开发者更好地理解 API 的使用方式。 -Documentation improvements, helping developers better understand API usage. - -## 📋 技术细节 / Technical Details - -### 内部实现变更 / Internal Implementation Changes - -**变更前 / Before**: -- 默认账号标识使用 `'default'` 字符串 -- 当 `accountId` 为 `'default'` 时,不发送 `X-OpenClaw-Agent-Id` header,让 gateway 路由到其配置的默认 agent - -**变更后 / After**: -- 默认账号标识使用 `'__default__'` 常量 -- 在 `streamFromGateway` 中,将 `'__default__'` 映射到 `'main'` agent,并发送 `X-OpenClaw-Agent-Id: main` header -- 用户配置的 `'default'` 账号正常使用,不会被特殊处理 - -### 相关代码位置 / Related Code Locations - -主要修改文件: -- `plugin.ts` - 核心逻辑修改 - -关键变更点: -- 新增 `DEFAULT_ACCOUNT_ID` 常量定义 -- `streamFromGateway` 函数中的 agent 路由逻辑 -- `listAccountIds`、`resolveAccount`、`defaultAccountId` 等配置相关函数 -- API 方法文档注释 - -## 📥 安装升级 / Installation & Upgrade - -```bash -# 通过 npm 安装最新版本 / Install latest version via npm -openclaw plugins install @dingtalk-real-ai/dingtalk-connector - -# 或升级现有版本 / Or upgrade existing version -openclaw plugins update dingtalk-connector - -# 通过 Git 安装 / Install via Git -openclaw plugins install https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector.git -``` - -## ⚠️ 升级注意事项 / Upgrade Notes - -### 兼容性说明 / Compatibility Notes - -- **向下兼容**:本次更新恢复了 0.7.0 之前版本的默认路由行为,对现有用户完全兼容 - **Backward Compatible**: This update restores the default routing behavior of versions before 0.7.0, fully compatible with existing users -- **无需配置变更**:现有配置无需修改即可正常工作 - **No Configuration Changes Required**: Existing configurations work without modification -- **推荐升级**:使用默认配置的用户强烈建议升级到此版本,以确保正确的 Agent 路由 - **Recommended Upgrade**: Users with default configuration are strongly recommended to upgrade to this version to ensure correct Agent routing - -### 迁移指南 / Migration Guide - -如果您在 0.7.0 或 0.7.1 版本中遇到了默认路由问题,升级到此版本后: -If you encountered default routing issues in versions 0.7.0 or 0.7.1, after upgrading to this version: - -1. **默认路由将自动恢复**:无需任何配置,默认路由将自动恢复到 `main` agent - **Default routing will be automatically restored**: No configuration needed, default routing will automatically restore to `main` agent -2. **检查 Agent 配置**:确认您的 Gateway 中 `main` agent 的配置是否正确 - **Check Agent Configuration**: Verify that your Gateway's `main` agent configuration is correct -3. **验证路由**:升级后测试会话路由,确认消息正确路由到预期的 agent - **Verify Routing**: Test session routing after upgrade to confirm messages are correctly routed to the expected agent - -## 🔗 相关链接 / Related Links - -- [完整变更日志 / Full Changelog](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/blob/main/CHANGELOG.md) -- [使用文档 / Documentation](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/blob/main/README.md) -- [问题反馈 / Issue Feedback](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/issues) -- [Pull Request #108](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/pull/108) - -## 🙏 致谢 / Acknowledgments - -感谢所有贡献者和用户的支持与反馈! -Thanks to all contributors and users for their support and feedback! - ---- - -**发布日期 / Release Date**:2026-03-09 -**版本号 / Version**:v0.7.3 -**兼容性 / Compatibility**:OpenClaw Gateway 0.4.0+ diff --git a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/docs/RELEASE_NOTES_v0.7.0.md b/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/docs/RELEASE_NOTES_v0.7.0.md deleted file mode 100644 index 648820b60..000000000 --- a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/docs/RELEASE_NOTES_v0.7.0.md +++ /dev/null @@ -1,142 +0,0 @@ -# Release Notes - v0.7.0 - -## 🎉 新版本亮点 / Highlights - -本次更新带来了丰富的富媒体和文档处理能力,让 AI 助手能够更好地理解和处理各种类型的内容。新增了图片识别、文件解析、钉钉文档操作和多 Agent 路由等核心功能。 - -This update brings rich media and document processing capabilities, enabling AI assistants to better understand and process various types of content. New core features include image recognition, file parsing, DingTalk document operations, and multi-Agent routing. - -## ✨ 新增功能 / Added Features - -### 富媒体接收支持 / Rich Media Reception Support -- ✅ **JPEG 图片消息** - 支持接收钉钉中直接发送的 JPEG 图片,自动下载到 `~/.openclaw/workspace/media/inbound/` 目录 - **JPEG Image Messages** - Support receiving JPEG images sent directly in DingTalk, automatically downloaded to `~/.openclaw/workspace/media/inbound/` directory -- ✅ **PNG 图片(富文本)** - 支持接收富文本消息中包含的 PNG 图片,自动提取 URL 和 downloadCode 并下载 - **PNG Images (Rich Text)** - Support receiving PNG images contained in rich text messages, automatically extract URL and downloadCode and download -- ✅ **视觉模型集成** - 下载的图片自动传递给视觉模型,AI 可以识别和分析图片内容 - **Vision Model Integration** - Downloaded images are automatically passed to vision models, AI can recognize and analyze image content -- ✅ **媒体文件管理** - 统一的文件命名格式 `openclaw-media-{timestamp}.{ext}`,便于管理和追踪 - **Media File Management** - Unified file naming format `openclaw-media-{timestamp}.{ext}` for easy management and tracking - -### 文件附件提取 / File Attachment Extraction -- ✅ **Word 文档解析** - 支持解析 `.docx` 文件,通过 `mammoth` 库提取文本内容并注入到 AI 上下文 - **Word Document Parsing** - Support parsing `.docx` files, extract text content via `mammoth` library and inject into AI context -- ✅ **PDF 文档解析** - 支持解析 `.pdf` 文件,通过 `pdf-parse` 库提取文本内容并注入到 AI 上下文 - **PDF Document Parsing** - Support parsing `.pdf` files, extract text content via `pdf-parse` library and inject into AI context -- ✅ **纯文本文件** - 支持读取 `.txt`、`.md`、`.json` 等纯文本文件,内容直接注入到消息中 - **Plain Text Files** - Support reading plain text files (`.txt`, `.md`, `.json`, etc.), content directly injected into messages -- ✅ **二进制文件处理** - 支持处理 `.xlsx`、`.pptx`、`.zip` 等二进制文件,文件保存到磁盘并在消息中报告路径 - **Binary File Processing** - Support processing binary files (`.xlsx`, `.pptx`, `.zip`, etc.), files saved to disk and paths reported in messages - -### 钉钉文档 API / DingTalk Document API -- ✅ **创建文档** - `docs.create()` - 在指定空间中创建新的钉钉文档 - **Create Document** - `docs.create()` - Create new DingTalk documents in specified spaces -- ✅ **追加内容** - `docs.append()` - 在现有文档上追加 Markdown 内容 - **Append Content** - `docs.append()` - Append Markdown content to existing documents -- ✅ **搜索文档** - `docs.search()` - 根据关键词搜索钉钉文档 - **Search Documents** - `docs.search()` - Search DingTalk documents by keywords -- ✅ **列举文档** - `docs.list()` - 列举指定空间下的所有文档 - **List Documents** - `docs.list()` - List all documents under specified spaces -- ⚠️ **读取文档** - `docs.read()` - 当前不可用(见已知问题) - **Read Document** - `docs.read()` - Currently unavailable (see Known Issues) - -### 多 Agent 路由支持 / Multi-Agent Routing Support -- ✅ **多 Agent 会话隔离** - 支持一个连接器实例同时连接多个 Agent - **Multi-Agent Session Isolation** - Support one connector instance connecting to multiple Agents simultaneously -- ✅ **多机器人绑定** - 支持多个钉钉机器人分别绑定到不同的 Agent,实现角色分工和专业化服务 - **Multi-Bot Binding** - Support multiple DingTalk bots binding to different Agents, enabling role division and specialized services -- ✅ **独立会话空间** - 每个 Agent 拥有独立的会话上下文,互不干扰 - **Independent Session Space** - Each Agent has an independent session context without interference -- ✅ **灵活配置** - 通过 `accounts` 和 `bindings` 配置多个机器人,提供详细的配置示例和说明 - **Flexible Configuration** - Configure multiple bots via `accounts` and `bindings`, with detailed configuration examples and instructions -- ✅ **向后兼容** - 单 Agent 场景下功能完全兼容,无需额外配置 - **Backward Compatible** - Fully compatible with single Agent scenarios, no additional configuration required - -## 🐛 修复 / Fixes - -- **语音消息播放异常修复** - 修复机器人发送语音消息播放异常问题,音频进度和播放功能现已正常工作 - **Voice Message Playback Fix** - Fixed bot voice message playback issues, audio progress and playback functionality now work correctly - -## 🔧 改进 / Improvements - -- **媒体文件处理优化** - 优化了媒体文件下载和存储机制,提升处理效率 - **Media File Processing Optimization** - Optimized media file download and storage mechanism, improved processing efficiency -- **文件附件流程改进** - 改进了文件附件处理流程,支持更多文件类型,错误处理更完善 - **File Attachment Process Improvement** - Improved file attachment processing flow, supporting more file types with better error handling -- **Markdown 表格转换** - 自动将 Markdown 表格转换为钉钉支持的文本格式,提升消息可读性 - **Markdown Table Conversion** - Automatically convert Markdown tables to DingTalk-supported text format for better message readability -- **日志增强** - 增强了错误处理和日志输出,便于问题排查和调试 - **Log Enhancement** - Enhanced error handling and log output for easier troubleshooting and debugging - -## 📦 依赖更新 / Dependency Updates - -- 新增 `mammoth@^1.8.0` - Word 文档(.docx)解析库 - Added `mammoth@^1.8.0` - Word document (.docx) parsing library -- 新增 `pdf-parse@^1.1.1` - PDF 文档解析库 - Added `pdf-parse@^1.1.1` - PDF document parsing library - -## ⚠️ 已知问题 / Known Issues - -### 1. 钉钉文档读取功能不可用 / DingTalk Document Reading Unavailable - -**问题描述 / Issue Description**:`docs.read()` API 当前无法正常工作 -`docs.read()` API is currently not working properly - -**原因 / Cause**:MCP(Model Context Protocol)中未提供读取文档的 tool,虽然代码层面实现正常,但缺少底层支持 -MCP (Model Context Protocol) does not provide the tool for reading documents. While the implementation is correct at the code level, underlying support is missing - -**影响范围 / Impact**:仅影响文档读取功能,其他文档操作(创建、追加、搜索、列举)均正常 -Only affects document reading functionality, other document operations (create, append, search, list) work normally - -**解决方案 / Solution**:等待 MCP 提供相应的 tool 支持,或使用其他文档操作 API 作为替代方案 -Wait for MCP to provide corresponding tool support, or use other document operation APIs as alternatives - -**状态 / Status**:已记录,等待上游支持 -Recorded, waiting for upstream support - -## 📚 文档更新 / Documentation Updates - -- ✅ 更新 README.md,添加新功能使用说明 - Updated README.md, added usage instructions for new features -- ✅ 新增"富媒体接收"章节,说明图片消息处理方式 - Added "Rich Media Reception" section, explaining image message processing -- ✅ 新增"文件附件提取"章节,列出支持的文件类型和处理方式 - Added "File Attachment Extraction" section, listing supported file types and processing methods -- ✅ 新增"钉钉文档 API"章节,提供 API 使用示例 - Added "DingTalk Document API" section, providing API usage examples -- ✅ 新增"多 Agent 路由支持"章节,说明多 Agent 会话隔离功能 - Added "Multi-Agent Routing Support" section, explaining multi-Agent session isolation functionality -- ✅ 新增"多 Agent 配置"章节,提供详细的配置示例和说明(`accounts` 和 `bindings` 配置) - Added "Multi-Agent Configuration" section with detailed configuration examples and instructions (`accounts` and `bindings` configuration) -- ✅ 补充常见问题解答,包括新功能的故障排查 - Added FAQ section, including troubleshooting for new features - -## 🔗 相关链接 / Related Links - -- [完整变更日志 / Full Changelog](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/blob/main/CHANGELOG.md) -- [使用文档 / Documentation](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/blob/main/README.md) -- [问题反馈 / Issue Feedback](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/issues) - -## 📥 安装升级 / Installation & Upgrade - -```bash -# 通过 npm 安装最新版本 / Install latest version via npm -openclaw plugins install @dingtalk-real-ai/dingtalk-connector - -# 或升级现有版本 / Or upgrade existing version -openclaw plugins update dingtalk-connector - -# 通过 Git 安装 / Install via Git -openclaw plugins install https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector.git -``` - -## 🙏 致谢 / Acknowledgments - -感谢所有贡献者和用户的支持与反馈! -Thanks to all contributors and users for their support and feedback! - ---- - -**发布日期 / Release Date**:2026-03-05 -**版本号 / Version**:v0.7.0 -**兼容性 / Compatibility**:OpenClaw Gateway 0.4.0+ diff --git a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/docs/RELEASE_NOTES_v0.7.1.md b/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/docs/RELEASE_NOTES_v0.7.1.md deleted file mode 100644 index 628a14248..000000000 --- a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/docs/RELEASE_NOTES_v0.7.1.md +++ /dev/null @@ -1,74 +0,0 @@ -# Release Notes - v0.7.1 - -## 🐛 修复版本 / Bug Fix Release - -本次更新修复了 stream 模式下的关键问题,确保 Agent 路由功能正常工作。 - -This update fixes critical issues in stream mode to ensure Agent routing functionality works correctly. - -## 🐛 修复 / Fixes - -### 1. Stream 模式 Session 路由失败问题 / Stream Mode Session Routing Failure - -**问题描述 / Issue Description**:stream 模式下 model 参数错误导致 session 路由失败 -Incorrect model parameter in stream mode caused session routing failures - -**修复内容 / Fix**: -- 将 Gateway 请求中的 `model` 参数从 `'default'` 更正为 `'main'` - Corrected `model` parameter in Gateway requests from `'default'` to `'main'` -- 确保正确的 Agent 路由和会话管理 - Ensures proper Agent routing and session management - -**影响范围 / Impact**:影响所有使用 stream 模式的用户,修复后 Agent 路由将正常工作 -Affects all users using stream mode. After the fix, Agent routing will work correctly. - -### 2. 多 Agent 路由问题修复 / Multi-Agent Routing Fix - -**问题描述 / Issue Description**:多个钉钉机器人绑定到不同 Agent 时路由异常 -Multiple DingTalk bots binding to different Agents failed to route correctly - -**修复内容 / Fix**: -- 修复多 Agent 路由机制,确保多个钉钉机器人可以正确绑定到不同的 Agent - Fixed multi-Agent routing mechanism, ensuring multiple DingTalk bots can correctly bind to different Agents -- 改进会话隔离和路由逻辑 - Improved session isolation and routing logic - -**影响范围 / Impact**:影响使用多 Agent 配置的用户,修复后多机器人多 Agent 场景将正常工作 -Affects users with multi-Agent configurations. After the fix, multi-bot multi-Agent scenarios will work correctly. - -## 🔧 改进 / Improvements - -- **异步模式优化** - 优化异步模式处理流程,改进错误处理和日志输出 - **Async Mode Optimization** - Optimized async mode processing flow, improved error handling and log output -- **DM Policy 增强** - 增强 DM Policy 检查机制,支持白名单配置 - **DM Policy Enhancement** - Enhanced DM Policy check mechanism, supporting allowlist configuration - -## 📥 安装升级 / Installation & Upgrade - -```bash -# 通过 npm 安装最新版本 / Install latest version via npm -openclaw plugins install @dingtalk-real-ai/dingtalk-connector - -# 或升级现有版本 / Or upgrade existing version -openclaw plugins update dingtalk-connector - -# 通过 Git 安装 / Install via Git -openclaw plugins install https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector.git -``` - -## 🔗 相关链接 / Related Links - -- [完整变更日志 / Full Changelog](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/blob/main/CHANGELOG.md) -- [使用文档 / Documentation](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/blob/main/README.md) -- [问题反馈 / Issue Feedback](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/issues) - -## 🙏 致谢 / Acknowledgments - -感谢所有贡献者和用户的支持与反馈! -Thanks to all contributors and users for their support and feedback! - ---- - -**发布日期 / Release Date**:2026-03-05 -**版本号 / Version**:v0.7.1 -**兼容性 / Compatibility**:OpenClaw Gateway 0.4.0+ diff --git a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/openclaw.plugin.json b/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/openclaw.plugin.json deleted file mode 100644 index 29a247e1e..000000000 --- a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/openclaw.plugin.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "id": "dingtalk-connector", - "name": "DingTalk Channel", - "version": "0.7.4", - "description": "DingTalk (钉钉) messaging channel via Stream mode with AI Card streaming", - "author": "DingTalk Real Team", - "channels": ["dingtalk-connector"], - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": { - "enabled": { "type": "boolean", "default": true } - } - } -} diff --git a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/package.json b/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/package.json deleted file mode 100644 index 4ef797940..000000000 --- a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/package.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "name": "@dingtalk-real-ai/dingtalk-connector", - "version": "0.7.5", - "description": "DingTalk (钉钉) channel connector — Stream mode with AI Card streaming", - "main": "plugin.ts", - "type": "module", - "scripts": { - "build": "echo 'No build needed - jiti loads TS at runtime'", - "lint": "echo 'Lint check skipped'", - "lint:fix": "echo 'Lint fix skipped'", - "test": "echo 'Tests skipped'", - "test:watch": "echo 'Tests skipped'", - "start:runner": "tsx ../runner.ts", - "type-check": "npx tsc --noEmit", - "version:check": "echo 'Version check skipped'", - "release:prepare": "echo 'Release prepare skipped'", - "release:publish": "npm publish --access public", - "release:verify": "npm view @dingtalk-real-ai/dingtalk-connector version", - "clean": "rm -rf node_modules package-lock.json", - "install:fresh": "npm run clean && npm install", - "dev": "echo 'Run: openclaw start'", - "validate": "npm run lint && npm run type-check && npm run version:check" - }, - "keywords": [ - "dingtalk", - "channel", - "stream", - "ai-card", - "connector" - ], - "author": "DingTalk Real Team", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector.git" - }, - "homepage": "https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector#readme", - "bugs": "https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/issues", - "publishConfig": { - "access": "public" - }, - "dependencies": { - "dingtalk-stream": "^2.1.4", - "axios": "^1.6.0", - "fluent-ffmpeg": "^2.1.3", - "@ffmpeg-installer/ffmpeg": "^1.1.0", - "mammoth": "^1.8.0", - "pdf-parse": "^1.1.1", - "tsx": "^4.20.5" - }, - "openclaw": { - "extensions": [ - "./plugin.ts" - ], - "channels": [ - "dingtalk-connector" - ], - "installDependencies": true - } -} diff --git a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/plugin.ts b/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/plugin.ts deleted file mode 100644 index 51ca41755..000000000 --- a/.flocks/plugins/channels/dingtalk/dingtalk-openclaw-connector/plugin.ts +++ /dev/null @@ -1,3867 +0,0 @@ -/** - * DingTalk Channel Plugin for Moltbot - * - * 通过钉钉 Stream 模式连接,支持 AI Card 流式响应。 - * 完整接入 Moltbot 消息处理管道。 - */ - -import { DWClient, TOPIC_ROBOT } from 'dingtalk-stream'; -import axios from 'axios'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import type { ClawdbotPluginApi, PluginRuntime, ClawdbotConfig } from 'clawdbot/plugin-sdk'; - -// ============ 常量 ============ - -export const id = 'dingtalk-connector'; - -/** 默认账号 ID,用于标记单账号模式(无 accounts 配置)时的内部标识,映射到 'main' agent */ -const DEFAULT_ACCOUNT_ID = '__default__'; - -let runtime: PluginRuntime | null = null; - -function getRuntime(): PluginRuntime { - if (!runtime) throw new Error('DingTalk runtime not initialized'); - return runtime; -} - -// ============ Session 管理 ============ - -/** 用户会话状态:记录最后活跃时间和当前 session 标识 */ -interface UserSession { - lastActivity: number; - sessionId: string; // 格式: dingtalk-connector: 或 dingtalk-connector:: -} - -/** 用户会话缓存 Map */ -const userSessions = new Map(); - -/** 消息去重缓存 Map - 防止同一消息被重复处理 */ -const processedMessages = new Map(); - -/** 消息去重缓存过期时间(5分钟) */ -const MESSAGE_DEDUP_TTL = 5 * 60 * 1000; - -/** 清理过期的消息去重缓存 */ -function cleanupProcessedMessages(): void { - const now = Date.now(); - for (const [msgId, timestamp] of processedMessages.entries()) { - if (now - timestamp > MESSAGE_DEDUP_TTL) { - processedMessages.delete(msgId); - } - } -} - -/** 检查消息是否已处理过(去重) */ -function isMessageProcessed(messageId: string): boolean { - if (!messageId) return false; - return processedMessages.has(messageId); -} - -/** 标记消息为已处理 */ -function markMessageProcessed(messageId: string): void { - if (!messageId) return; - processedMessages.set(messageId, Date.now()); - // 定期清理(每处理100条消息清理一次) - if (processedMessages.size >= 100) { - cleanupProcessedMessages(); - } -} - -/** 新会话触发命令 */ -const NEW_SESSION_COMMANDS = ['/new', '/reset', '/clear', '新会话', '重新开始', '清空对话']; - -/** 检查消息是否是新会话命令 */ -function isNewSessionCommand(text: string): boolean { - const trimmed = text.trim().toLowerCase(); - return NEW_SESSION_COMMANDS.some(cmd => trimmed === cmd.toLowerCase()); -} - -/** - * OpenClaw 标准会话上下文 - * 遵循 OpenClaw session.dmScope 机制,让 Gateway 根据配置自动处理会话隔离 - */ -interface SessionContext { - channel: 'dingtalk-connector'; - accountId: string; - chatType: 'direct' | 'group'; - peerId: string; - conversationId?: string; - senderName?: string; - groupSubject?: string; -} - -/** - * 构建 OpenClaw 标准会话上下文 - * 遵循 OpenClaw session.dmScope 机制,让 Gateway 根据配置自动处理会话隔离 - * - * @param separateSessionByConversation - 是否按单聊/群聊/群区分 session(默认 true) - * - true: 单聊、群聊、不同群各自拥有独立的 session - * - false: 按用户维度维护 session,不区分单聊/群聊(兼容旧行为) - * @param groupSessionScope - 群聊会话隔离策略(仅当 separateSessionByConversation=true 时生效) - * - 'group': 整个群共享一个会话(默认) - * - 'group_sender': 群内每个用户独立会话 - */ -function buildSessionContext(params: { - accountId: string; - senderId: string; - senderName?: string; - conversationType: string; - conversationId?: string; - groupSubject?: string; - separateSessionByConversation?: boolean; - groupSessionScope?: 'group' | 'group_sender'; -}): SessionContext { - const { accountId, senderId, senderName, conversationType, conversationId, groupSubject, separateSessionByConversation, groupSessionScope } = params; - const isDirect = conversationType === '1'; - - // separateSessionByConversation=false 时,不区分单聊/群聊,按用户维度维护 session - if (separateSessionByConversation === false) { - return { - channel: 'dingtalk-connector', - accountId, - chatType: isDirect ? 'direct' : 'group', - peerId: senderId, // 只用 senderId,不区分会话 - senderName, - }; - } - - // 以下是 separateSessionByConversation=true(默认)的逻辑 - if (isDirect) { - // 单聊:peerId 为发送者 ID,由 OpenClaw Gateway 根据 dmScope 配置处理 - return { - channel: 'dingtalk-connector', - accountId, - chatType: 'direct', - peerId: senderId, - senderName, - }; - } - - // 群聊:根据 groupSessionScope 配置决定会话隔离策略 - if (groupSessionScope === 'group_sender') { - // 群内每个用户独立会话 - return { - channel: 'dingtalk-connector', - accountId, - chatType: 'group', - peerId: `${conversationId}:${senderId}`, - conversationId, - senderName, - groupSubject, - }; - } - - // 默认:整个群共享一个会话 - return { - channel: 'dingtalk-connector', - accountId, - chatType: 'group', - peerId: conversationId || senderId, - conversationId, - senderName, - groupSubject, - }; -} - -// ============ Access Token 缓存 ============ - -let accessToken: string | null = null; -let accessTokenExpiry = 0; - -async function getAccessToken(config: any): Promise { - const now = Date.now(); - if (accessToken && accessTokenExpiry > now + 60_000) { - return accessToken; - } - - const response = await axios.post('https://api.dingtalk.com/v1.0/oauth2/accessToken', { - appKey: config.clientId, - appSecret: config.clientSecret, - }); - - accessToken = response.data.accessToken; - accessTokenExpiry = now + (response.data.expireIn * 1000); - return accessToken!; -} - -// ============ 配置工具 ============ - -function getConfig(cfg: ClawdbotConfig) { - return (cfg?.channels as any)?.['dingtalk-connector'] || {}; -} - -function isConfigured(cfg: ClawdbotConfig): boolean { - const config = getConfig(cfg); - return Boolean(config.clientId && config.clientSecret); -} - -// ============ 钉钉图片上传 ============ - -async function getOapiAccessToken(config: any): Promise { - try { - const resp = await axios.get('https://oapi.dingtalk.com/gettoken', { - params: { appkey: config.clientId, appsecret: config.clientSecret }, - }); - if (resp.data?.errcode === 0) return resp.data.access_token; - return null; - } catch { - return null; - } -} - -/** staffId → unionId 缓存 */ -const unionIdCache = new Map(); - -/** - * 通过 oapi 旧版接口将 staffId 转换为 unionId - */ -async function getUnionId(staffId: string, config: any, log?: any): Promise { - const cached = unionIdCache.get(staffId); - if (cached) return cached; - - try { - const token = await getOapiAccessToken(config); - if (!token) { - log?.error?.('[DingTalk] getUnionId: 无法获取 oapi access_token'); - return null; - } - const resp = await axios.get(`${DINGTALK_OAPI}/user/get`, { - params: { access_token: token, userid: staffId }, - timeout: 10_000, - }); - const unionId = resp.data?.unionid; - if (unionId) { - unionIdCache.set(staffId, unionId); - log?.info?.(`[DingTalk] getUnionId: ${staffId} → ${unionId}`); - return unionId; - } - log?.error?.(`[DingTalk] getUnionId: 响应中无 unionid 字段: ${JSON.stringify(resp.data)}`); - return null; - } catch (err: any) { - log?.error?.(`[DingTalk] getUnionId 失败: ${err.message}`); - return null; - } -} - -function buildMediaSystemPrompt(): string { - return `## 钉钉图片和文件显示规则 - -你正在钉钉中与用户对话。 - -### 一、图片显示 - -显示图片时,直接使用本地文件路径,系统会自动上传处理。 - -**正确方式**: -\`\`\`markdown -![描述](file:///path/to/image.jpg) -![描述](/tmp/screenshot.png) -![描述](/Users/xxx/photo.jpg) -\`\`\` - -**禁止**: -- 不要自己执行 curl 上传 -- 不要猜测或构造 URL -- **不要对路径进行转义(如使用反斜杠 \\ )** - -直接输出本地路径即可,系统会自动上传到钉钉。 - -### 二、视频分享 - -**何时分享视频**: -- ✅ 用户明确要求**分享、发送、上传**视频时 -- ❌ 仅生成视频保存到本地时,**不需要**分享 - -**视频标记格式**: -当需要分享视频时,在回复**末尾**添加: - -\`\`\` -[DINGTALK_VIDEO]{"path":"<本地视频路径>"}[/DINGTALK_VIDEO] -\`\`\` - -**支持格式**:mp4(最大 20MB) - -**重要**: -- 视频大小不得超过 20MB,超过限制时告知用户 -- 仅支持 mp4 格式 -- 系统会自动提取视频时长、分辨率并生成封面 - -### 三、音频分享 - -**何时分享音频**: -- ✅ 用户明确要求**分享、发送、上传**音频/语音文件时 -- ❌ 仅生成音频保存到本地时,**不需要**分享 - -**音频标记格式**: -当需要分享音频时,在回复**末尾**添加: - -\`\`\` -[DINGTALK_AUDIO]{"path":"<本地音频路径>"}[/DINGTALK_AUDIO] -\`\`\` - -**支持格式**:ogg、amr(最大 20MB) - -**重要**: -- 音频大小不得超过 20MB,超过限制时告知用户 -- 系统会自动提取音频时长 - -### 四、文件分享 - -**何时分享文件**: -- ✅ 用户明确要求**分享、发送、上传**文件时 -- ❌ 仅生成文件保存到本地时,**不需要**分享 - -**文件标记格式**: -当需要分享文件时,在回复**末尾**添加: - -\`\`\` -[DINGTALK_FILE]{"path":"<本地文件路径>","fileName":"<文件名>","fileType":"<扩展名>"}[/DINGTALK_FILE] -\`\`\` - -**支持的文件类型**:几乎所有常见格式 - -**重要**:文件大小不得超过 20MB,超过限制时告知用户文件过大。`; -} - -// ============ 图片后处理:自动上传本地图片到钉钉 ============ - -/** - * 匹配 markdown 图片中的本地文件路径(跨平台): - * - ![alt](file:///path/to/image.jpg) - * - ![alt](MEDIA:/var/folders/xxx.jpg) - * - ![alt](attachment:///path.jpg) - * macOS: - * - ![alt](/tmp/xxx.jpg) - * - ![alt](/var/folders/xxx.jpg) - * - ![alt](/Users/xxx/photo.jpg) - * Linux: - * - ![alt](/home/user/photo.jpg) - * - ![alt](/root/photo.jpg) - * Windows: - * - ![alt](C:\Users\xxx\photo.jpg) - * - ![alt](C:/Users/xxx/photo.jpg) - */ -const LOCAL_IMAGE_RE = /!\[([^\]]*)\]\(((?:file:\/\/\/|MEDIA:|attachment:\/\/\/)[^)]+|\/(?:tmp|var|private|Users|home|root)[^)]+|[A-Za-z]:[\\/ ][^)]+)\)/g; - -/** 图片文件扩展名 */ -const IMAGE_EXTENSIONS = /\.(png|jpg|jpeg|gif|bmp|webp|tiff|svg)$/i; - -/** - * 匹配纯文本中的本地图片路径(不在 markdown 图片语法中,跨平台): - * macOS: - * - `/var/folders/.../screenshot.png` - * - `/tmp/image.jpg` - * - `/Users/xxx/photo.png` - * Linux: - * - `/home/user/photo.png` - * - `/root/photo.png` - * Windows: - * - `C:\Users\xxx\photo.png` - * - `C:/temp/image.jpg` - * 支持 backtick 包裹: `path` - */ -const BARE_IMAGE_PATH_RE = /`?((?:\/(?:tmp|var|private|Users|home|root)\/[^\s`'",)]+|[A-Za-z]:[\\/][^\s`'",)]+)\.(?:png|jpg|jpeg|gif|bmp|webp))`?/gi; - -/** 去掉 file:// / MEDIA: / attachment:// 前缀,得到实际的绝对路径 */ -function toLocalPath(raw: string): string { - let path = raw; - if (path.startsWith('file://')) path = path.replace('file://', ''); - else if (path.startsWith('MEDIA:')) path = path.replace('MEDIA:', ''); - else if (path.startsWith('attachment://')) path = path.replace('attachment://', ''); - - // 解码 URL 编码的路径(如中文字符 %E5%9B%BE → 图) - try { - path = decodeURIComponent(path); - } catch { - // 解码失败则保持原样 - } - return path; -} - -/** - * 通用媒体文件上传函数 - * @param filePath 文件路径 - * @param mediaType 媒体类型:image, file, video, voice - * @param oapiToken 钉钉 access_token - * @param maxSize 最大文件大小(字节),默认 20MB - * @param log 日志对象 - * @returns media_id 或 null - */ -async function uploadMediaToDingTalk( - filePath: string, - mediaType: 'image' | 'file' | 'video' | 'voice', - oapiToken: string, - maxSize: number = 20 * 1024 * 1024, - log?: any, -): Promise { - try { - const fs = await import('fs'); - const path = await import('path'); - const FormData = (await import('form-data')).default; - - const absPath = toLocalPath(filePath); - if (!fs.existsSync(absPath)) { - log?.warn?.(`[DingTalk][${mediaType}] 文件不存在: ${absPath}`); - return null; - } - - // 检查文件大小 - const stats = fs.statSync(absPath); - const fileSizeMB = (stats.size / (1024 * 1024)).toFixed(2); - - if (stats.size > maxSize) { - const maxSizeMB = (maxSize / (1024 * 1024)).toFixed(0); - log?.warn?.(`[DingTalk][${mediaType}] 文件过大: ${absPath}, 大小: ${fileSizeMB}MB, 超过限制 ${maxSizeMB}MB`); - return null; - } - - const form = new FormData(); - form.append('media', fs.createReadStream(absPath), { - filename: path.basename(absPath), - contentType: mediaType === 'image' ? 'image/jpeg' : 'application/octet-stream', - }); - - log?.info?.(`[DingTalk][${mediaType}] 上传文件: ${absPath} (${fileSizeMB}MB)`); - const resp = await axios.post( - `https://oapi.dingtalk.com/media/upload?access_token=${oapiToken}&type=${mediaType}`, - form, - { headers: form.getHeaders(), timeout: 60_000 }, - ); - - const mediaId = resp.data?.media_id; - if (mediaId) { - log?.info?.(`[DingTalk][${mediaType}] 上传成功: media_id=${mediaId}`); - return mediaId; - } - log?.warn?.(`[DingTalk][${mediaType}] 上传返回无 media_id: ${JSON.stringify(resp.data)}`); - return null; - } catch (err: any) { - log?.error?.(`[DingTalk][${mediaType}] 上传失败: ${err.message}`); - return null; - } -} - -/** 扫描内容中的本地图片路径,上传到钉钉并替换为 media_id */ -async function processLocalImages( - content: string, - oapiToken: string | null, - log?: any, -): Promise { - if (!oapiToken) { - log?.warn?.(`[DingTalk][Media] 无 oapiToken,跳过图片后处理`); - return content; - } - - let result = content; - - // 第一步:匹配 markdown 图片语法 ![alt](path) - const mdMatches = [...content.matchAll(LOCAL_IMAGE_RE)]; - if (mdMatches.length > 0) { - log?.info?.(`[DingTalk][Media] 检测到 ${mdMatches.length} 个 markdown 图片,开始上传...`); - for (const match of mdMatches) { - const [fullMatch, alt, rawPath] = match; - // 清理转义字符(AI 可能会对含空格的路径添加 \ ) - const cleanPath = rawPath.replace(/\\ /g, ' '); - const mediaId = await uploadMediaToDingTalk(cleanPath, 'image', oapiToken, 20 * 1024 * 1024, log); - if (mediaId) { - result = result.replace(fullMatch, `![${alt}](${mediaId})`); - } - } - } - - // 第二步:匹配纯文本中的本地图片路径(如 `/var/folders/.../xxx.png`) - // 排除已被 markdown 图片语法包裹的路径 - const bareMatches = [...result.matchAll(BARE_IMAGE_PATH_RE)]; - const newBareMatches = bareMatches.filter(m => { - // 检查这个路径是否已经在 ![...](...) 中 - const idx = m.index!; - const before = result.slice(Math.max(0, idx - 10), idx); - return !before.includes(']('); - }); - - if (newBareMatches.length > 0) { - log?.info?.(`[DingTalk][Media] 检测到 ${newBareMatches.length} 个纯文本图片路径,开始上传...`); - // 从后往前替换,避免 index 偏移 - for (const match of newBareMatches.reverse()) { - const [fullMatch, rawPath] = match; - log?.info?.(`[DingTalk][Media] 纯文本图片: "${fullMatch}" -> path="${rawPath}"`); - const mediaId = await uploadMediaToDingTalk(rawPath, 'image', oapiToken, 20 * 1024 * 1024, log); - if (mediaId) { - const replacement = `![](${mediaId})`; - result = result.slice(0, match.index!) + result.slice(match.index!).replace(fullMatch, replacement); - log?.info?.(`[DingTalk][Media] 替换纯文本路径为图片: ${replacement}`); - } - } - } - - if (mdMatches.length === 0 && newBareMatches.length === 0) { - log?.info?.(`[DingTalk][Media] 未检测到本地图片路径`); - } - - return result; -} - -// ============ 文件后处理:提取文件标记并发送独立消息 ============ - -/** - * 文件标记正则:[DINGTALK_FILE]{"path":"...","fileName":"...","fileType":"..."}[/DINGTALK_FILE] - */ -const FILE_MARKER_PATTERN = /\[DINGTALK_FILE\]({.*?})\[\/DINGTALK_FILE\]/g; - -/** 视频大小限制:20MB */ -const MAX_VIDEO_SIZE = 20 * 1024 * 1024; - -// ============ 视频后处理:提取视频标记并发送视频消息 ============ - -/** - * 视频标记正则:[DINGTALK_VIDEO]{"path":"..."}[/DINGTALK_VIDEO] - */ -const VIDEO_MARKER_PATTERN = /\[DINGTALK_VIDEO\]({.*?})\[\/DINGTALK_VIDEO\]/g; - -/** - * 音频标记正则:[DINGTALK_AUDIO]{"path":"..."}[/DINGTALK_AUDIO] - */ -const AUDIO_MARKER_PATTERN = /\[DINGTALK_AUDIO\]({.*?})\[\/DINGTALK_AUDIO\]/g; - -/** 视频信息接口 */ -interface VideoInfo { - path: string; -} - -/** 视频元数据接口 */ -interface VideoMetadata { - duration: number; - width: number; - height: number; -} - -/** - * 提取视频元数据(时长、分辨率) - */ -async function extractVideoMetadata( - filePath: string, - log?: any, -): Promise { - try { - const ffmpeg = require('fluent-ffmpeg'); - const ffmpegPath = require('@ffmpeg-installer/ffmpeg').path; - ffmpeg.setFfmpegPath(ffmpegPath); - - return new Promise((resolve, reject) => { - ffmpeg.ffprobe(filePath, (err: any, metadata: any) => { - if (err) { - log?.error?.(`[DingTalk][Video] 提取元数据失败: ${err.message}`); - return reject(err); - } - - const videoStream = metadata.streams.find((s: any) => s.codec_type === 'video'); - if (!videoStream) { - log?.warn?.(`[DingTalk][Video] 未找到视频流`); - return resolve(null); - } - - const result = { - duration: Math.floor(metadata.format.duration || 0), - width: videoStream.width || 0, - height: videoStream.height || 0, - }; - - log?.info?.(`[DingTalk][Video] 元数据: duration=${result.duration}s, ${result.width}x${result.height}`); - resolve(result); - }); - }); - } catch (err: any) { - log?.error?.(`[DingTalk][Video] ffprobe 失败: ${err.message}`); - return null; - } -} - -/** - * 生成视频封面图(第1秒截图) - */ -async function extractVideoThumbnail( - videoPath: string, - outputPath: string, - log?: any, -): Promise { - try { - const ffmpeg = require('fluent-ffmpeg'); - const ffmpegPath = require('@ffmpeg-installer/ffmpeg').path; - const path = await import('path'); - ffmpeg.setFfmpegPath(ffmpegPath); - - return new Promise((resolve, reject) => { - ffmpeg(videoPath) - .screenshots({ - count: 1, - folder: path.dirname(outputPath), - filename: path.basename(outputPath), - timemarks: ['1'], - size: '?x360', - }) - .on('end', () => { - log?.info?.(`[DingTalk][Video] 封面生成成功: ${outputPath}`); - resolve(outputPath); - }) - .on('error', (err: any) => { - log?.error?.(`[DingTalk][Video] 封面生成失败: ${err.message}`); - reject(err); - }); - }); - } catch (err: any) { - log?.error?.(`[DingTalk][Video] ffmpeg 失败: ${err.message}`); - return null; - } -} - -/** - * 发送视频消息到钉钉 - */ -async function sendVideoMessage( - config: any, - sessionWebhook: string, - videoInfo: VideoInfo, - videoMediaId: string, - picMediaId: string, - metadata: VideoMetadata, - oapiToken: string, - log?: any, -): Promise { - try { - const path = await import('path'); - const fileName = path.basename(videoInfo.path); - - const payload = { - msgtype: 'video', - video: { - duration: metadata.duration.toString(), - videoMediaId: videoMediaId, - videoType: 'mp4', - picMediaId: picMediaId, - }, - }; - - log?.info?.(`[DingTalk][Video] 发送视频消息: ${fileName}, payload: ${JSON.stringify(payload)}`); - const resp = await axios.post(sessionWebhook, payload, { - headers: { - 'x-acs-dingtalk-access-token': oapiToken, - 'Content-Type': 'application/json', - }, - timeout: 10_000, - }); - - if (resp.data?.success !== false) { - log?.info?.(`[DingTalk][Video] 视频消息发送成功: ${fileName}`); - } else { - log?.error?.(`[DingTalk][Video] 视频消息发送失败: ${JSON.stringify(resp.data)}`); - } - } catch (err: any) { - log?.error?.(`[DingTalk][Video] 发送失败: ${err.message}`); - } -} - -/** - * 视频后处理主函数 - * 返回移除标记后的内容,并附带视频处理的状态提示 - * - * @param useProactiveApi 是否使用主动消息 API(用于 AI Card 场景) - * @param target 主动 API 需要的目标信息(useProactiveApi=true 时必须提供) - */ -async function processVideoMarkers( - content: string, - sessionWebhook: string, - config: any, - oapiToken: string | null, - log?: any, - useProactiveApi: boolean = false, - target?: AICardTarget, -): Promise { - const logPrefix = useProactiveApi ? '[DingTalk][Video][Proactive]' : '[DingTalk][Video]'; - - if (!oapiToken) { - log?.warn?.(`${logPrefix} 无 oapiToken,跳过视频处理`); - return content; - } - - const fs = await import('fs'); - const path = await import('path'); - const os = await import('os'); - - // 提取视频标记 - const matches = [...content.matchAll(VIDEO_MARKER_PATTERN)]; - const videoInfos: VideoInfo[] = []; - const invalidVideos: string[] = []; - - for (const match of matches) { - try { - const videoInfo = JSON.parse(match[1]) as VideoInfo; - if (videoInfo.path && fs.existsSync(videoInfo.path)) { - videoInfos.push(videoInfo); - log?.info?.(`${logPrefix} 提取到视频: ${videoInfo.path}`); - } else { - invalidVideos.push(videoInfo.path || '未知路径'); - log?.warn?.(`${logPrefix} 视频文件不存在: ${videoInfo.path}`); - } - } catch (err: any) { - log?.warn?.(`${logPrefix} 解析标记失败: ${err.message}`); - } - } - - if (videoInfos.length === 0 && invalidVideos.length === 0) { - log?.info?.(`${logPrefix} 未检测到视频标记`); - return content.replace(VIDEO_MARKER_PATTERN, '').trim(); - } - - // 先移除所有视频标记,保留其他文本内容 - let cleanedContent = content.replace(VIDEO_MARKER_PATTERN, '').trim(); - - // 收集处理结果状态 - const statusMessages: string[] = []; - - // 处理无效视频 - for (const invalidPath of invalidVideos) { - statusMessages.push(`⚠️ 视频文件不存在: ${path.basename(invalidPath)}`); - } - - if (videoInfos.length > 0) { - log?.info?.(`${logPrefix} 检测到 ${videoInfos.length} 个视频,开始处理...`); - } - - // 逐个处理视频 - for (const videoInfo of videoInfos) { - const fileName = path.basename(videoInfo.path); - let thumbnailPath = ''; - try { - // 1. 提取元数据 - const metadata = await extractVideoMetadata(videoInfo.path, log); - if (!metadata) { - log?.warn?.(`${logPrefix} 无法提取元数据: ${videoInfo.path}`); - statusMessages.push(`⚠️ 视频处理失败: ${fileName}(无法读取视频信息,请检查 ffmpeg 是否已安装)`); - continue; - } - - // 2. 生成封面 - thumbnailPath = path.join(os.tmpdir(), `thumbnail_${Date.now()}.jpg`); - const thumbnail = await extractVideoThumbnail(videoInfo.path, thumbnailPath, log); - if (!thumbnail) { - log?.warn?.(`${logPrefix} 无法生成封面: ${videoInfo.path}`); - statusMessages.push(`⚠️ 视频处理失败: ${fileName}(无法生成封面)`); - continue; - } - - // 3. 上传视频 - const videoMediaId = await uploadMediaToDingTalk(videoInfo.path, 'video', oapiToken, MAX_VIDEO_SIZE, log); - if (!videoMediaId) { - log?.warn?.(`${logPrefix} 视频上传失败: ${videoInfo.path}`); - statusMessages.push(`⚠️ 视频上传失败: ${fileName}(文件可能超过 20MB 限制)`); - continue; - } - - // 4. 上传封面 - const picMediaId = await uploadMediaToDingTalk(thumbnailPath, 'image', oapiToken, 20 * 1024 * 1024, log); - if (!picMediaId) { - log?.warn?.(`${logPrefix} 封面上传失败: ${thumbnailPath}`); - statusMessages.push(`⚠️ 视频封面上传失败: ${fileName}`); - continue; - } - - // 5. 发送视频消息 - if (useProactiveApi && target) { - await sendVideoProactive(config, target, videoMediaId, picMediaId, metadata, log); - } else { - await sendVideoMessage(config, sessionWebhook, videoInfo, videoMediaId, picMediaId, metadata, oapiToken, log); - } - - log?.info?.(`${logPrefix} 视频处理完成: ${fileName}`); - statusMessages.push(`✅ 视频已发送: ${fileName}`); - } catch (err: any) { - log?.error?.(`${logPrefix} 处理视频失败: ${err.message}`); - statusMessages.push(`⚠️ 视频处理异常: ${fileName}(${err.message})`); - } finally { - // 统一清理临时文件 - if (thumbnailPath) { - try { - fs.unlinkSync(thumbnailPath); - } catch { - // 文件可能不存在,忽略删除错误 - } - } - } - } - - // 将状态信息附加到清理后的内容 - if (statusMessages.length > 0) { - const statusText = statusMessages.join('\n'); - cleanedContent = cleanedContent - ? `${cleanedContent}\n\n${statusText}` - : statusText; - } - - return cleanedContent; -} - -/** 音频文件扩展名 */ -const AUDIO_EXTENSIONS = ['mp3', 'wav', 'amr', 'ogg', 'aac', 'flac', 'm4a']; - - -/** 判断是否为音频文件 */ -function isAudioFile(fileType: string): boolean { - return AUDIO_EXTENSIONS.includes(fileType.toLowerCase()); -} - -/** 文件大小限制:20MB(字节) */ -const MAX_FILE_SIZE = 20 * 1024 * 1024; - -/** 文件信息接口 */ -interface FileInfo { - path: string; // 本地文件路径 - fileName: string; // 文件名 - fileType: string; // 文件类型(扩展名) -} - -/** - * 从内容中提取文件标记 - * @returns { cleanedContent, fileInfos } - */ -function extractFileMarkers(content: string, log?: any): { cleanedContent: string; fileInfos: FileInfo[] } { - const fileInfos: FileInfo[] = []; - const matches = [...content.matchAll(FILE_MARKER_PATTERN)]; - - for (const match of matches) { - try { - const fileInfo = JSON.parse(match[1]) as FileInfo; - - // 验证必需字段 - if (fileInfo.path && fileInfo.fileName) { - fileInfos.push(fileInfo); - log?.info?.(`[DingTalk][File] 提取到文件标记: ${fileInfo.fileName}`); - } - } catch (err: any) { - log?.warn?.(`[DingTalk][File] 解析文件标记失败: ${match[1]}, 错误: ${err.message}`); - } - } - - // 移除文件标记,返回清理后的内容 - const cleanedContent = content.replace(FILE_MARKER_PATTERN, '').trim(); - return { cleanedContent, fileInfos }; -} - - -/** - * 发送文件消息到钉钉 - */ -async function sendFileMessage( - config: any, - sessionWebhook: string, - fileInfo: FileInfo, - mediaId: string, - oapiToken: string, - log?: any, -): Promise { - try { - const fileMessage = { - msgtype: 'file', - file: { - mediaId: mediaId, - fileName: fileInfo.fileName, - fileType: fileInfo.fileType, - }, - }; - - log?.info?.(`[DingTalk][File] 发送文件消息: ${fileInfo.fileName}`); - const resp = await axios.post(sessionWebhook, fileMessage, { - headers: { - 'x-acs-dingtalk-access-token': oapiToken, - 'Content-Type': 'application/json', - }, - timeout: 10_000, - }); - - if (resp.data?.success !== false) { - log?.info?.(`[DingTalk][File] 文件消息发送成功: ${fileInfo.fileName}`); - } else { - log?.error?.(`[DingTalk][File] 文件消息发送失败: ${JSON.stringify(resp.data)}`); - } - } catch (err: any) { - log?.error?.(`[DingTalk][File] 发送文件消息异常: ${fileInfo.fileName}, 错误: ${err.message}`); - } -} - -/** - * 获取 ffprobe 可执行文件路径 - * 优先级: @ffprobe-installer/ffprobe > FFPROBE_PATH 环境变量 > 系统 PATH - */ -function getFfprobePath(): string { - // 1. 尝试 @ffprobe-installer/ffprobe 包 - try { - const ffprobePath = require('@ffprobe-installer/ffprobe').path; - if (ffprobePath) return ffprobePath; - } catch { /* 未安装,跳过 */ } - - // 2. 尝试环境变量 - if (process.env.FFPROBE_PATH) return process.env.FFPROBE_PATH; - - // 3. fallback 到系统 PATH - return 'ffprobe'; -} - -/** - * 提取音频文件时长(毫秒) - * 使用 ffprobe CLI 直接获取,避免 fluent-ffmpeg 在部分运行环境中回调不触发的问题 - */ -async function extractAudioDuration( - filePath: string, - log?: any, -): Promise { - try { - const { execFile } = require('child_process'); - const ffprobeBin = getFfprobePath(); - - return new Promise((resolve) => { - execFile(ffprobeBin, [ - '-v', 'quiet', - '-print_format', 'json', - '-show_format', - filePath, - ], { timeout: 10_000 }, (err: any, stdout: string, stderr: string) => { - if (err) { - log?.error?.(`[DingTalk][Audio] ffprobe 执行失败 (${ffprobeBin}): ${err.message}`); - return resolve(null); - } - - try { - const parsed = JSON.parse(stdout); - const durationSec = parseFloat(parsed?.format?.duration); - if (isNaN(durationSec)) { - log?.warn?.(`[DingTalk][Audio] 无法解析音频时长,ffprobe 输出: ${stdout.slice(0, 200)}`); - return resolve(null); - } - - const durationMs = Math.floor(durationSec * 1000); - log?.info?.(`[DingTalk][Audio] 音频时长: ${durationMs}ms (${durationSec}s)`); - resolve(durationMs); - } catch (parseErr: any) { - log?.error?.(`[DingTalk][Audio] ffprobe 输出解析失败: ${parseErr.message}`); - resolve(null); - } - }); - }); - } catch (err: any) { - log?.error?.(`[DingTalk][Audio] extractAudioDuration 异常: ${err.message}`); - return null; - } -} - -/** - * 发送音频消息到钉钉(被动回复场景) - */ -async function sendAudioMessage( - config: any, - sessionWebhook: string, - fileInfo: FileInfo, - mediaId: string, - oapiToken: string, - log?: any, - durationMs?: number, -): Promise { - try { - // 钉钉语音消息格式 - const actualDuration = (durationMs && durationMs > 0) ? durationMs.toString() : '60000'; - const audioMessage = { - msgtype: 'voice', - voice: { - mediaId: mediaId, - duration: actualDuration, - }, - }; - - log?.info?.(`[DingTalk][Audio] 发送语音消息: ${fileInfo.fileName}`); - const resp = await axios.post(sessionWebhook, audioMessage, { - headers: { - 'x-acs-dingtalk-access-token': oapiToken, - 'Content-Type': 'application/json', - }, - timeout: 10_000, - }); - - if (resp.data?.success !== false) { - log?.info?.(`[DingTalk][Audio] 语音消息发送成功: ${fileInfo.fileName}`); - } else { - log?.error?.(`[DingTalk][Audio] 语音消息发送失败: ${JSON.stringify(resp.data)}`); - } - } catch (err: any) { - log?.error?.(`[DingTalk][Audio] 发送语音消息异常: ${fileInfo.fileName}, 错误: ${err.message}`); - } -} - -/** - * 处理文件标记:提取、上传、发送独立消息 - * 返回移除标记后的内容,并附带文件处理的状态提示 - * - * @param useProactiveApi 是否使用主动消息 API(用于 AI Card 场景,避免 sessionWebhook 失效问题) - * @param target 主动 API 需要的目标信息(useProactiveApi=true 时必须提供) - */ -async function processFileMarkers( - content: string, - sessionWebhook: string, - config: any, - oapiToken: string | null, - log?: any, - useProactiveApi: boolean = false, - target?: AICardTarget, -): Promise { - if (!oapiToken) { - log?.warn?.(`[DingTalk][File] 无 oapiToken,跳过文件处理`); - return content; - } - - const { cleanedContent, fileInfos } = extractFileMarkers(content, log); - - if (fileInfos.length === 0) { - log?.info?.(`[DingTalk][File] 未检测到文件标记`); - return cleanedContent; - } - - log?.info?.(`[DingTalk][File] 检测到 ${fileInfos.length} 个文件标记,开始处理... (useProactiveApi=${useProactiveApi})`); - - const statusMessages: string[] = []; - - const fs = await import('fs'); - - // 逐个上传并发送文件消息 - for (const fileInfo of fileInfos) { - // 预检查:文件是否存在、是否超限 - const absPath = toLocalPath(fileInfo.path); - if (!fs.existsSync(absPath)) { - statusMessages.push(`⚠️ 文件不存在: ${fileInfo.fileName}`); - continue; - } - const stats = fs.statSync(absPath); - if (stats.size > MAX_FILE_SIZE) { - const sizeMB = (stats.size / (1024 * 1024)).toFixed(1); - const maxMB = (MAX_FILE_SIZE / (1024 * 1024)).toFixed(0); - statusMessages.push(`⚠️ 文件过大无法发送: ${fileInfo.fileName}(${sizeMB}MB,限制 ${maxMB}MB)`); - continue; - } - - // 区分音频文件和普通文件 - if (isAudioFile(fileInfo.fileType)) { - // 音频文件使用 voice 类型上传 - const mediaId = await uploadMediaToDingTalk(fileInfo.path, 'voice', oapiToken, MAX_FILE_SIZE, log); - if (mediaId) { - // 提取音频实际时长 - const audioDurationMs = await extractAudioDuration(fileInfo.path, log); - if (useProactiveApi && target) { - // 使用主动消息 API(适用于 AI Card 场景) - await sendAudioProactive(config, target, fileInfo, mediaId, log, audioDurationMs ?? undefined); - } else { - // 使用 sessionWebhook(传统被动回复场景) - await sendAudioMessage(config, sessionWebhook, fileInfo, mediaId, oapiToken, log, audioDurationMs ?? undefined); - } - statusMessages.push(`✅ 音频已发送: ${fileInfo.fileName}`); - } else { - log?.error?.(`[DingTalk][Audio] 音频上传失败,跳过发送: ${fileInfo.fileName}`); - statusMessages.push(`⚠️ 音频上传失败: ${fileInfo.fileName}`); - } - } else { - // 普通文件 - const mediaId = await uploadMediaToDingTalk(fileInfo.path, 'file', oapiToken, MAX_FILE_SIZE, log); - if (mediaId) { - if (useProactiveApi && target) { - // 使用主动消息 API(适用于 AI Card 场景) - await sendFileProactive(config, target, fileInfo, mediaId, log); - } else { - // 使用 sessionWebhook(传统被动回复场景) - await sendFileMessage(config, sessionWebhook, fileInfo, mediaId, oapiToken, log); - } - statusMessages.push(`✅ 文件已发送: ${fileInfo.fileName}`); - } else { - log?.error?.(`[DingTalk][File] 文件上传失败,跳过发送: ${fileInfo.fileName}`); - statusMessages.push(`⚠️ 文件上传失败: ${fileInfo.fileName}`); - } - } - } - - // 将状态信息附加到清理后的内容 - if (statusMessages.length > 0) { - const statusText = statusMessages.join('\n'); - return cleanedContent - ? `${cleanedContent}\n\n${statusText}` - : statusText; - } - - return cleanedContent; -} - -// ============ AI Card Streaming ============ - -const DINGTALK_API = 'https://api.dingtalk.com'; -const DINGTALK_OAPI = 'https://oapi.dingtalk.com'; -const AI_CARD_TEMPLATE_ID = '382e4302-551d-4880-bf29-a30acfab2e71.schema'; - -// flowStatus 值与 Python SDK AICardStatus 一致(cardParamMap 的值必须是字符串) -const AICardStatus = { - PROCESSING: '1', - INPUTING: '2', - FINISHED: '3', - EXECUTING: '4', - FAILED: '5', -} as const; - -interface AICardInstance { - cardInstanceId: string; - accessToken: string; - inputingStarted: boolean; -} - -/** - * 创建 AI Card 实例(被动回复场景) - * 从钉钉回调 data 中提取目标信息,委托给通用函数 - */ -async function createAICard( - config: any, - data: any, - log?: any, -): Promise { - const isGroup = data.conversationType === '2'; - - log?.info?.(`[DingTalk][AICard] conversationType=${data.conversationType}, conversationId=${data.conversationId}, senderStaffId=${data.senderStaffId}, senderId=${data.senderId}`); - - // 构建通用目标 - const target: AICardTarget = isGroup - ? { type: 'group', openConversationId: data.conversationId } - : { type: 'user', userId: data.senderStaffId || data.senderId }; - - return createAICardForTarget(config, target, log); -} - -// 流式更新 AI Card 内容 -async function streamAICard( - card: AICardInstance, - content: string, - finished: boolean = false, - log?: any, -): Promise { - // 首次 streaming 前,先切换到 INPUTING 状态(与 Python SDK get_card_data(INPUTING) 一致) - if (!card.inputingStarted) { - const statusBody = { - outTrackId: card.cardInstanceId, - cardData: { - cardParamMap: { - flowStatus: AICardStatus.INPUTING, - msgContent: '', - staticMsgContent: '', - sys_full_json_obj: JSON.stringify({ - order: ['msgContent'], // 只声明实际使用的字段,避免部分客户端显示空占位 - }), - }, - }, - }; - log?.info?.(`[DingTalk][AICard] PUT /v1.0/card/instances (INPUTING) outTrackId=${card.cardInstanceId}`); - try { - const statusResp = await axios.put(`${DINGTALK_API}/v1.0/card/instances`, statusBody, { - headers: { 'x-acs-dingtalk-access-token': card.accessToken, 'Content-Type': 'application/json' }, - }); - log?.info?.(`[DingTalk][AICard] INPUTING 响应: status=${statusResp.status} data=${JSON.stringify(statusResp.data)}`); - } catch (err: any) { - log?.error?.(`[DingTalk][AICard] INPUTING 切换失败: ${err.message}, resp=${JSON.stringify(err.response?.data)}`); - throw err; - } - card.inputingStarted = true; - } - - // 调用 streaming API 更新内容 - const body = { - outTrackId: card.cardInstanceId, - guid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, - key: 'msgContent', - content: content, - isFull: true, // 全量替换 - isFinalize: finished, - isError: false, - }; - - log?.info?.(`[DingTalk][AICard] PUT /v1.0/card/streaming contentLen=${content.length} isFinalize=${finished} guid=${body.guid}`); - try { - const streamResp = await axios.put(`${DINGTALK_API}/v1.0/card/streaming`, body, { - headers: { 'x-acs-dingtalk-access-token': card.accessToken, 'Content-Type': 'application/json' }, - }); - log?.info?.(`[DingTalk][AICard] streaming 响应: status=${streamResp.status}`); - } catch (err: any) { - log?.error?.(`[DingTalk][AICard] streaming 更新失败: ${err.message}, resp=${JSON.stringify(err.response?.data)}`); - throw err; - } -} - -// 完成 AI Card:先 streaming isFinalize 关闭流式通道,再 put_card_data 更新 FINISHED 状态 -async function finishAICard( - card: AICardInstance, - content: string, - log?: any, -): Promise { - log?.info?.(`[DingTalk][AICard] 开始 finish,最终内容长度=${content.length}`); - - // 1. 先用最终内容关闭流式通道(isFinalize=true),确保卡片显示替换后的内容 - await streamAICard(card, content, true, log); - - // 2. 更新卡片状态为 FINISHED - const body = { - outTrackId: card.cardInstanceId, - cardData: { - cardParamMap: { - flowStatus: AICardStatus.FINISHED, - msgContent: content, - staticMsgContent: '', - sys_full_json_obj: JSON.stringify({ - order: ['msgContent'], // 只声明实际使用的字段,避免部分客户端显示空占位 - }), - }, - }, - }; - - log?.info?.(`[DingTalk][AICard] PUT /v1.0/card/instances (FINISHED) outTrackId=${card.cardInstanceId}`); - try { - const finishResp = await axios.put(`${DINGTALK_API}/v1.0/card/instances`, body, { - headers: { 'x-acs-dingtalk-access-token': card.accessToken, 'Content-Type': 'application/json' }, - }); - log?.info?.(`[DingTalk][AICard] FINISHED 响应: status=${finishResp.status} data=${JSON.stringify(finishResp.data)}`); - } catch (err: any) { - log?.error?.(`[DingTalk][AICard] FINISHED 更新失败: ${err.message}, resp=${JSON.stringify(err.response?.data)}`); - } -} - -// ============ Gateway SSE Streaming ============ - -// ============ Bindings 匹配逻辑 ============ - -interface BindingMatch { - channel?: string; - accountId?: string; - peer?: { - kind?: 'direct' | 'group'; - id?: string; - }; -} - -interface Binding { - agentId: string; - match?: BindingMatch; -} - -/** - * 根据 OpenClaw bindings 配置解析 agentId - * - * 匹配优先级(从高到低): - * 1. peer.kind + peer.id 精确匹配(非 '*') - * 2. peer.kind + peer.id='*' 通配匹配 - * 3. peer.kind 匹配(无 peer.id) - * 4. accountId 匹配 - * 5. channel 匹配 - * 6. 默认 fallback - * - * @param accountId 账号 ID - * @param peerKind 会话类型:'direct'(单聊)或 'group'(群聊) - * @param peerId 发送者 ID(单聊)或会话 ID(群聊) - * @param log 日志对象 - * @returns 匹配到的 agentId - */ -function resolveAgentIdByBindings( - accountId: string, - peerKind: 'direct' | 'group', - peerId: string, - log?: any, -): string { - const rt = getRuntime(); - const defaultAgentId = accountId === DEFAULT_ACCOUNT_ID ? 'main' : accountId; - - // 读取 OpenClaw 配置 - let bindings: Binding[] = []; - try { - const configPath = path.join(os.homedir(), '.openclaw', 'openclaw.json'); - if (fs.existsSync(configPath)) { - const configContent = fs.readFileSync(configPath, 'utf-8'); - const config = JSON.parse(configContent); - bindings = config.bindings || []; - } - } catch (err: any) { - log?.warn?.(`[DingTalk][Bindings] 读取 OpenClaw 配置失败: ${err.message}`); - return defaultAgentId; - } - - if (bindings.length === 0) { - log?.info?.(`[DingTalk][Bindings] 无 bindings 配置,使用默认 agentId=${defaultAgentId}`); - return defaultAgentId; - } - - // 筛选 channel='dingtalk-connector' 的 bindings - const channelBindings = bindings.filter(b => - !b.match?.channel || b.match.channel === 'dingtalk-connector' - ); - - if (channelBindings.length === 0) { - log?.info?.(`[DingTalk][Bindings] 无匹配 channel 的 bindings,使用默认 agentId=${defaultAgentId}`); - return defaultAgentId; - } - - log?.info?.(`[DingTalk][Bindings] 开始匹配: accountId=${accountId}, peerKind=${peerKind}, peerId=${peerId}, bindings数量=${channelBindings.length}`); - - // 按优先级匹配 - // 优先级1: peer.kind + peer.id 精确匹配 - for (const binding of channelBindings) { - const match = binding.match || {}; - if (match.peer?.kind === peerKind && - match.peer?.id && - match.peer.id !== '*' && - match.peer.id === peerId) { - // 还需检查 accountId 是否匹配(如果指定了) - if (match.accountId && match.accountId !== accountId) continue; - log?.info?.(`[DingTalk][Bindings] 精确匹配 peer.id: agentId=${binding.agentId}`); - return binding.agentId || defaultAgentId; - } - } - - // 优先级2: peer.kind + peer.id='*' 通配匹配 - for (const binding of channelBindings) { - const match = binding.match || {}; - if (match.peer?.kind === peerKind && match.peer?.id === '*') { - if (match.accountId && match.accountId !== accountId) continue; - log?.info?.(`[DingTalk][Bindings] 通配匹配 peer.kind=${peerKind}, peer.id=*: agentId=${binding.agentId}`); - return binding.agentId || defaultAgentId; - } - } - - // 优先级3: 仅 peer.kind 匹配(无 peer.id) - for (const binding of channelBindings) { - const match = binding.match || {}; - if (match.peer?.kind === peerKind && !match.peer?.id) { - if (match.accountId && match.accountId !== accountId) continue; - log?.info?.(`[DingTalk][Bindings] 匹配 peer.kind=${peerKind}: agentId=${binding.agentId}`); - return binding.agentId || defaultAgentId; - } - } - - // 优先级4: accountId 匹配(无 peer 配置) - for (const binding of channelBindings) { - const match = binding.match || {}; - if (!match.peer && match.accountId === accountId) { - log?.info?.(`[DingTalk][Bindings] 匹配 accountId=${accountId}: agentId=${binding.agentId}`); - return binding.agentId || defaultAgentId; - } - } - - // 优先级5: 仅 channel 匹配(无 peer 和 accountId) - for (const binding of channelBindings) { - const match = binding.match || {}; - if (!match.peer && !match.accountId) { - log?.info?.(`[DingTalk][Bindings] 匹配 channel=dingtalk-connector: agentId=${binding.agentId}`); - return binding.agentId || defaultAgentId; - } - } - - log?.info?.(`[DingTalk][Bindings] 无匹配,使用默认 agentId=${defaultAgentId}`); - return defaultAgentId; -} - -interface GatewayOptions { - userContent: string; - systemPrompts: string[]; - sessionContext: SessionContext; - gatewayAuth?: string; // token 或 password,都用 Bearer 格式 - /** 记忆归属用户标识,用于 Gateway 区分记忆;sharedMemoryAcrossConversations=true 时传 accountId,false 时传 sessionContext JSON */ - memoryUser?: string; - /** 本地图片文件路径列表,用于 OpenClaw AgentMediaPayload */ - imageLocalPaths?: string[]; - /** 会话类型:'direct'(单聊)或 'group'(群聊),用于 bindings 匹配 */ - peerKind?: 'direct' | 'group'; - /** 发送者 ID,用于 bindings 匹配 */ - peerId?: string; - gatewayPort?: number; - log?: any; -} - -async function* streamFromGateway(options: GatewayOptions, accountId: string): AsyncGenerator { - const { userContent, systemPrompts, sessionKey, gatewayAuth, memoryUser, imageLocalPaths, peerKind, peerId, gatewayPort, log } = options; - const rt = getRuntime(); - const port = gatewayPort || rt.gateway?.port || 18789; - const gatewayUrl = `http://127.0.0.1:${port}/v1/chat/completions`; - - const messages: any[] = []; - for (const prompt of systemPrompts) { - messages.push({ role: 'system', content: prompt }); - } - - // 如果有图片,在文本中嵌入本地文件路径(OpenClaw AgentMediaPayload 格式) - let finalContent = userContent; - if (imageLocalPaths && imageLocalPaths.length > 0) { - const imageMarkdown = imageLocalPaths.map(p => `![image](file://${p})`).join('\n'); - finalContent = finalContent ? `${finalContent}\n\n${imageMarkdown}` : imageMarkdown; - log?.info?.(`[DingTalk][Gateway] 附加 ${imageLocalPaths.length} 张本地图片路径`); - } - messages.push({ role: 'user', content: finalContent }); - - const headers: Record = { 'Content-Type': 'application/json' }; - if (gatewayAuth) { - headers['Authorization'] = `Bearer ${gatewayAuth}`; - } - // 使用 bindings 配置解析 agentId,支持基于 peer.kind(单聊/群聊)的路由 - // 如果没有提供 peerKind/peerId,则回退到原有逻辑 - const agentId = (peerKind && peerId) - ? resolveAgentIdByBindings(accountId, peerKind, peerId, log) - : (accountId === DEFAULT_ACCOUNT_ID ? 'main' : accountId); - headers['X-OpenClaw-Agent-Id'] = agentId; - if (memoryUser) { - // 使用 Base64 编码处理可能包含中文字符的 memoryUser - // HTTP Header 只能包含 ASCII 字符,中文字符会导致 ByteString 编码错误 - headers['X-OpenClaw-Memory-User'] = Buffer.from(memoryUser, 'utf-8').toString('base64'); - } - - log?.info?.(`[DingTalk][Gateway] POST ${gatewayUrl}, session=${sessionKey}, accountId=${accountId}, agentId=${agentId}, peerKind=${peerKind}, messages=${messages.length}`); - - const response = await fetch(gatewayUrl, { - method: 'POST', - headers, - body: JSON.stringify({ - model: 'main', - messages, - stream: true, - user: sessionKey, // 用于 session 持久化 - }), - }); - - log?.info?.(`[DingTalk][Gateway] 响应 status=${response.status}, ok=${response.ok}, hasBody=${!!response.body}`); - - if (!response.ok || !response.body) { - const errText = response.body ? await response.text() : '(no body)'; - log?.error?.(`[DingTalk][Gateway] 错误响应: ${errText}`); - throw new Error(`Gateway error: ${response.status} - ${errText}`); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (!line.startsWith('data: ')) continue; - const data = line.slice(6).trim(); - if (data === '[DONE]') return; - - try { - const chunk = JSON.parse(data); - const content = chunk.choices?.[0]?.delta?.content; - if (content) yield content; - } catch {} - } - } -} - -// ============ 图片下载到本地文件 ============ - -/** - * 下载钉钉图片到本地临时文件 - * 返回本地文件路径,用于 OpenClaw AgentMediaPayload - */ -async function downloadImageToFile( - downloadUrl: string, - log?: any, -): Promise { - try { - log?.info?.(`[DingTalk][Image] 开始下载图片: ${downloadUrl.slice(0, 100)}...`); - const resp = await axios.get(downloadUrl, { - responseType: 'arraybuffer', - timeout: 30_000, - }); - - const buffer = Buffer.from(resp.data); - const contentType = resp.headers['content-type'] || 'image/jpeg'; - const ext = contentType.includes('png') ? '.png' : contentType.includes('gif') ? '.gif' : contentType.includes('webp') ? '.webp' : '.jpg'; - const mediaDir = path.join(os.homedir(), '.openclaw', 'workspace', 'media', 'inbound'); - fs.mkdirSync(mediaDir, { recursive: true }); - const tmpFile = path.join(mediaDir, `openclaw-media-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`); - fs.writeFileSync(tmpFile, buffer); - - log?.info?.(`[DingTalk][Image] 图片下载成功: size=${buffer.length} bytes, type=${contentType}, path=${tmpFile}`); - return tmpFile; - } catch (err: any) { - log?.error?.(`[DingTalk][Image] 图片下载失败: ${err.message}`); - return null; - } -} - -/** - * 通过钉钉 API 下载媒体文件(需要 access_token) - * 适用于 picture/file 类型的 downloadCode - */ -async function downloadMediaByCode( - downloadCode: string, - config: any, - log?: any, -): Promise { - try { - const token = await getAccessToken(config); - log?.info?.(`[DingTalk][Image] 通过 downloadCode 下载媒体: ${downloadCode.slice(0, 30)}...`); - - const resp = await axios.post( - `${DINGTALK_API}/v1.0/robot/messageFiles/download`, - { downloadCode, robotCode: config.clientId }, - { - headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' }, - timeout: 30_000, - }, - ); - - const downloadUrl = resp.data?.downloadUrl; - if (!downloadUrl) { - log?.warn?.(`[DingTalk][Image] downloadCode 换取 downloadUrl 失败: ${JSON.stringify(resp.data)}`); - return null; - } - - return downloadImageToFile(downloadUrl, log); - } catch (err: any) { - log?.error?.(`[DingTalk][Image] downloadCode 下载失败: ${err.message}`); - return null; - } -} - -/** - * 通过钉钉 API 下载文件附件(需要 access_token) - * 与 downloadMediaByCode 不同,此函数保留原始文件名 - */ -async function downloadFileByCode( - downloadCode: string, - fileName: string, - config: any, - log?: any, -): Promise { - try { - const token = await getAccessToken(config); - log?.info?.(`[DingTalk][File] 通过 downloadCode 下载文件: ${fileName}`); - - const resp = await axios.post( - `${DINGTALK_API}/v1.0/robot/messageFiles/download`, - { downloadCode, robotCode: config.clientId }, - { - headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' }, - timeout: 30_000, - }, - ); - - const downloadUrl = resp.data?.downloadUrl; - if (!downloadUrl) { - log?.warn?.(`[DingTalk][File] downloadCode 换取 downloadUrl 失败: ${JSON.stringify(resp.data)}`); - return null; - } - - // 下载文件内容 - const fileResp = await axios.get(downloadUrl, { - responseType: 'arraybuffer', - timeout: 60_000, - }); - - const buffer = Buffer.from(fileResp.data); - const mediaDir = path.join(os.homedir(), '.openclaw', 'workspace', 'media', 'inbound'); - fs.mkdirSync(mediaDir, { recursive: true }); - - // 用时间戳前缀避免文件名冲突,保留原始文件名 - const safeFileName = fileName.replace(/[/\\:*?"<>|]/g, '_'); - const localPath = path.join(mediaDir, `${Date.now()}-${safeFileName}`); - fs.writeFileSync(localPath, buffer); - - log?.info?.(`[DingTalk][File] 文件下载成功: size=${buffer.length} bytes, path=${localPath}`); - return localPath; - } catch (err: any) { - log?.error?.(`[DingTalk][File] 文件下载失败: ${err.message}`); - return null; - } -} - -/** 可直接读取内容的文本类文件扩展名 */ -const TEXT_FILE_EXTENSIONS = new Set(['.txt', '.md', '.csv', '.json', '.xml', '.yaml', '.yml', '.html', '.htm', '.log', '.conf', '.ini', '.sh', '.py', '.js', '.ts', '.css', '.sql']); - -/** 需要保存但无法直接读取的 Office/二进制文件扩展名 */ -const OFFICE_FILE_EXTENSIONS = new Set(['.docx', '.xlsx', '.pptx', '.pdf', '.doc', '.xls', '.ppt', '.zip', '.rar', '.7z']); - -// ============ 消息处理 ============ - -/** 消息内容提取结果 */ -interface ExtractedMessage { - text: string; - messageType: string; - /** 图片 URL 列表(来自 richText 或 picture 消息) */ - imageUrls: string[]; - /** 图片 downloadCode 列表(用于通过 API 下载) */ - downloadCodes: string[]; - /** 文件名列表(与 downloadCodes 对应,用于文件类型消息) */ - fileNames: string[]; - /** at的钉钉用户ID列表 */ - atDingtalkIds: string[]; - /** at的手机号列表 */ - atMobiles: string[]; -} - -function extractMessageContent(data: any): ExtractedMessage { - const msgtype = data.msgtype || 'text'; - switch (msgtype) { - case 'text': { - const atDingtalkIds = data.text?.at?.atDingtalkIds || []; - const atMobiles = data.text?.at?.atMobiles || []; - return { - text: data.text?.content?.trim() || '', - messageType: 'text', - imageUrls: [], - downloadCodes: [], - fileNames: [], - atDingtalkIds, - atMobiles - }; - } - case 'richText': { - const parts = data.content?.richText || []; - const textParts: string[] = []; - const imageUrls: string[] = []; - - for (const part of parts) { - if (part.text) { - textParts.push(part.text); - } - if (part.pictureUrl) { - imageUrls.push(part.pictureUrl); - } - if (part.type === 'picture' && part.downloadCode) { - // 有些 richText 图片通过 downloadCode 获取 - imageUrls.push(`downloadCode:${part.downloadCode}`); - } - } - - const text = textParts.join('') || (imageUrls.length > 0 ? '[图片]' : '[富文本消息]'); - return { text, messageType: 'richText', imageUrls, downloadCodes: [], fileNames: [], atDingtalkIds: [], atMobiles: [] }; - } - case 'picture': { - const downloadCode = data.content?.downloadCode || ''; - const pictureUrl = data.content?.pictureUrl || ''; - const imageUrls: string[] = []; - const downloadCodes: string[] = []; - - if (pictureUrl) { - imageUrls.push(pictureUrl); - } - if (downloadCode) { - downloadCodes.push(downloadCode); - } - - return { text: '[图片]', messageType: 'picture', imageUrls, downloadCodes, fileNames: [], atDingtalkIds: [], atMobiles: [] }; - } - case 'audio': - return { text: data.content?.recognition || '[语音消息]', messageType: 'audio', imageUrls: [], downloadCodes: [], fileNames: [], atDingtalkIds: [], atMobiles: [] }; - case 'video': - return { text: '[视频]', messageType: 'video', imageUrls: [], downloadCodes: [], fileNames: [], atDingtalkIds: [], atMobiles: [] }; - case 'file': { - const fileName = data.content?.fileName || '文件'; - const downloadCode = data.content?.downloadCode || ''; - const downloadCodes: string[] = []; - const fileNames: string[] = []; - if (downloadCode) { - downloadCodes.push(downloadCode); - fileNames.push(fileName); - } - return { text: `[文件: ${fileName}]`, messageType: 'file', imageUrls: [], downloadCodes, fileNames, atDingtalkIds: [], atMobiles: [] }; - } - default: - return { text: data.text?.content?.trim() || `[${msgtype}消息]`, messageType: msgtype, imageUrls: [], downloadCodes: [], fileNames: [], atDingtalkIds: [], atMobiles: [] }; - } -} - -// 发送 Markdown 消息 -async function sendMarkdownMessage( - config: any, - sessionWebhook: string, - title: string, - markdown: string, - options: any = {}, -): Promise { - const token = await getAccessToken(config); - let text = markdown; - if (options.atUserId) text = `${text} @${options.atUserId}`; - - const body: any = { - msgtype: 'markdown', - markdown: { title: title || 'Moltbot', text }, - }; - if (options.atUserId) body.at = { atUserIds: [options.atUserId], isAtAll: false }; - - return (await axios.post(sessionWebhook, body, { - headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' }, - })).data; -} - -// 发送文本消息 -async function sendTextMessage( - config: any, - sessionWebhook: string, - text: string, - options: any = {}, -): Promise { - const token = await getAccessToken(config); - const body: any = { msgtype: 'text', text: { content: text } }; - if (options.atUserId) body.at = { atUserIds: [options.atUserId], isAtAll: false }; - - return (await axios.post(sessionWebhook, body, { - headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' }, - })).data; -} - -// 智能选择 text / markdown -async function sendMessage( - config: any, - sessionWebhook: string, - text: string, - options: any = {}, -): Promise { - const hasMarkdown = /^[#*>-]|[*_`#\[\]]/.test(text) || text.includes('\n'); - const useMarkdown = options.useMarkdown !== false && (options.useMarkdown || hasMarkdown); - - if (useMarkdown) { - const title = options.title - || text.split('\n')[0].replace(/^[#*\s\->]+/, '').slice(0, 20) - || 'Moltbot'; - return sendMarkdownMessage(config, sessionWebhook, title, text, options); - } - return sendTextMessage(config, sessionWebhook, text, options); -} - -// ============ 主动发送消息 API ============ - -/** 消息类型枚举 */ -type DingTalkMsgType = 'text' | 'markdown' | 'link' | 'actionCard' | 'image'; - -/** 主动发送消息的结果 */ -interface SendResult { - ok: boolean; - processQueryKey?: string; - cardInstanceId?: string; // AI Card 成功时返回 - error?: string; - usedAICard?: boolean; // 是否使用了 AI Card -} - -/** 主动发送选项 */ -interface ProactiveSendOptions { - msgType?: DingTalkMsgType; - title?: string; - log?: any; - useAICard?: boolean; // 是否使用 AI Card,默认 true - fallbackToNormal?: boolean; // AI Card 失败时是否降级到普通消息,默认 true -} - -/** AI Card 投放目标类型 */ -type AICardTarget = - | { type: 'user'; userId: string } - | { type: 'group'; openConversationId: string }; - -/** - * 构建卡片投放请求体(提取公共逻辑) - */ -function buildDeliverBody( - cardInstanceId: string, - target: AICardTarget, - robotCode: string, -): any { - const base = { outTrackId: cardInstanceId, userIdType: 1 }; - - if (target.type === 'group') { - return { - ...base, - openSpaceId: `dtv1.card//IM_GROUP.${target.openConversationId}`, - imGroupOpenDeliverModel: { robotCode }, - }; - } - - return { - ...base, - openSpaceId: `dtv1.card//IM_ROBOT.${target.userId}`, - imRobotOpenDeliverModel: { spaceType: 'IM_ROBOT', robotCode }, - }; -} - -/** - * 通用 AI Card 创建函数 - * 支持被动回复和主动发送两种场景 - */ -async function createAICardForTarget( - config: any, - target: AICardTarget, - log?: any, -): Promise { - const targetDesc = target.type === 'group' - ? `群聊 ${target.openConversationId}` - : `用户 ${target.userId}`; - - try { - const token = await getAccessToken(config); - const cardInstanceId = `card_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; - - log?.info?.(`[DingTalk][AICard] 开始创建卡片: ${targetDesc}, outTrackId=${cardInstanceId}`); - - // 1. 创建卡片实例 - const createBody = { - cardTemplateId: AI_CARD_TEMPLATE_ID, - outTrackId: cardInstanceId, - cardData: { cardParamMap: {} }, - callbackType: 'STREAM', - imGroupOpenSpaceModel: { supportForward: true }, - imRobotOpenSpaceModel: { supportForward: true }, - }; - - log?.info?.(`[DingTalk][AICard] POST /v1.0/card/instances`); - const createResp = await axios.post(`${DINGTALK_API}/v1.0/card/instances`, createBody, { - headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' }, - }); - log?.info?.(`[DingTalk][AICard] 创建卡片响应: status=${createResp.status}`); - - // 2. 投放卡片 - const deliverBody = buildDeliverBody(cardInstanceId, target, config.clientId); - - log?.info?.(`[DingTalk][AICard] POST /v1.0/card/instances/deliver body=${JSON.stringify(deliverBody)}`); - const deliverResp = await axios.post(`${DINGTALK_API}/v1.0/card/instances/deliver`, deliverBody, { - headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' }, - }); - log?.info?.(`[DingTalk][AICard] 投放卡片响应: status=${deliverResp.status}`); - - return { cardInstanceId, accessToken: token, inputingStarted: false }; - } catch (err: any) { - log?.error?.(`[DingTalk][AICard] 创建卡片失败 (${targetDesc}): ${err.message}`); - if (err.response) { - log?.error?.(`[DingTalk][AICard] 错误响应: status=${err.response.status} data=${JSON.stringify(err.response.data)}`); - } - return null; - } -} - -/** - * 主动发送文件消息(使用普通消息 API) - */ -async function sendFileProactive( - config: any, - target: AICardTarget, - fileInfo: FileInfo, - mediaId: string, - log?: any, -): Promise { - try { - const token = await getAccessToken(config); - - // 钉钉普通消息 API 的文件消息格式 - const msgParam = { - mediaId: mediaId, - fileName: fileInfo.fileName, - fileType: fileInfo.fileType, - }; - - const body: any = { - robotCode: config.clientId, - msgKey: 'sampleFile', - msgParam: JSON.stringify(msgParam), - }; - - let endpoint: string; - if (target.type === 'group') { - body.openConversationId = target.openConversationId; - endpoint = `${DINGTALK_API}/v1.0/robot/groupMessages/send`; - } else { - body.userIds = [target.userId]; - endpoint = `${DINGTALK_API}/v1.0/robot/oToMessages/batchSend`; - } - - log?.info?.(`[DingTalk][File][Proactive] 发送文件消息: ${fileInfo.fileName}`); - const resp = await axios.post(endpoint, body, { - headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' }, - timeout: 10_000, - }); - - if (resp.data?.processQueryKey) { - log?.info?.(`[DingTalk][File][Proactive] 文件消息发送成功: ${fileInfo.fileName}`); - } else { - log?.warn?.(`[DingTalk][File][Proactive] 文件消息发送响应异常: ${JSON.stringify(resp.data)}`); - } - } catch (err: any) { - log?.error?.(`[DingTalk][File][Proactive] 发送文件消息失败: ${fileInfo.fileName}, 错误: ${err.message}`); - } -} - -/** - * 主动发送音频消息(使用普通消息 API) - */ -async function sendAudioProactive( - config: any, - target: AICardTarget, - fileInfo: FileInfo, - mediaId: string, - log?: any, - durationMs?: number, -): Promise { - try { - const token = await getAccessToken(config); - - // 钉钉普通消息 API 的音频消息格式 - const actualDuration = (durationMs && durationMs > 0) ? durationMs.toString() : '60000'; - const msgParam = { - mediaId: mediaId, - duration: actualDuration, - }; - - const body: any = { - robotCode: config.clientId, - msgKey: 'sampleAudio', - msgParam: JSON.stringify(msgParam), - }; - - let endpoint: string; - if (target.type === 'group') { - body.openConversationId = target.openConversationId; - endpoint = `${DINGTALK_API}/v1.0/robot/groupMessages/send`; - } else { - body.userIds = [target.userId]; - endpoint = `${DINGTALK_API}/v1.0/robot/oToMessages/batchSend`; - } - - log?.info?.(`[DingTalk][Audio][Proactive] 发送音频消息: ${fileInfo.fileName}`); - const resp = await axios.post(endpoint, body, { - headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' }, - timeout: 10_000, - }); - - if (resp.data?.processQueryKey) { - log?.info?.(`[DingTalk][Audio][Proactive] 音频消息发送成功: ${fileInfo.fileName}`); - } else { - log?.warn?.(`[DingTalk][Audio][Proactive] 音频消息发送响应异常: ${JSON.stringify(resp.data)}`); - } - } catch (err: any) { - log?.error?.(`[DingTalk][Audio][Proactive] 发送音频消息失败: ${fileInfo.fileName}, 错误: ${err.message}`); - } -} - -/** - * 主动发送视频消息(使用普通消息 API) - */ -async function sendVideoProactive( - config: any, - target: AICardTarget, - videoMediaId: string, - picMediaId: string, - metadata: VideoMetadata, - log?: any, -): Promise { - try { - const token = await getAccessToken(config); - - // 钉钉普通消息 API 的视频消息格式 - const msgParam = { - duration: metadata.duration.toString(), - videoMediaId: videoMediaId, - videoType: 'mp4', - picMediaId: picMediaId, - }; - - const body: any = { - robotCode: config.clientId, - msgKey: 'sampleVideo', - msgParam: JSON.stringify(msgParam), - }; - - let endpoint: string; - if (target.type === 'group') { - body.openConversationId = target.openConversationId; - endpoint = `${DINGTALK_API}/v1.0/robot/groupMessages/send`; - } else { - body.userIds = [target.userId]; - endpoint = `${DINGTALK_API}/v1.0/robot/oToMessages/batchSend`; - } - - log?.info?.(`[DingTalk][Video][Proactive] 发送视频消息`); - const resp = await axios.post(endpoint, body, { - headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' }, - timeout: 10_000, - }); - - if (resp.data?.processQueryKey) { - log?.info?.(`[DingTalk][Video][Proactive] 视频消息发送成功`); - } else { - log?.warn?.(`[DingTalk][Video][Proactive] 视频消息发送响应异常: ${JSON.stringify(resp.data)}`); - } - } catch (err: any) { - log?.error?.(`[DingTalk][Video][Proactive] 发送视频消息失败: ${err.message}`); - } -} - -/** 音频信息接口 */ -interface AudioInfo { - path: string; -} - -/** - * 提取音频标记并发送音频消息 - * 解析 [DINGTALK_AUDIO]{"path":"..."}[/DINGTALK_AUDIO] 标记 - * - * @param useProactiveApi 是否使用主动消息 API(用于 AI Card 场景) - * @param target 主动 API 需要的目标信息(useProactiveApi=true 时必须提供) - */ -async function processAudioMarkers( - content: string, - sessionWebhook: string, - config: any, - oapiToken: string | null, - log?: any, - useProactiveApi: boolean = false, - target?: AICardTarget, -): Promise { - const logPrefix = useProactiveApi ? '[DingTalk][Audio][Proactive]' : '[DingTalk][Audio]'; - - if (!oapiToken) { - log?.warn?.(`${logPrefix} 无 oapiToken,跳过音频处理`); - return content; - } - - const fs = await import('fs'); - const path = await import('path'); - - const matches = [...content.matchAll(AUDIO_MARKER_PATTERN)]; - const audioInfos: AudioInfo[] = []; - const invalidAudios: string[] = []; - - for (const match of matches) { - try { - const audioInfo = JSON.parse(match[1]) as AudioInfo; - if (audioInfo.path && fs.existsSync(audioInfo.path)) { - audioInfos.push(audioInfo); - log?.info?.(`${logPrefix} 提取到音频: ${audioInfo.path}`); - } else { - invalidAudios.push(audioInfo.path || '未知路径'); - log?.warn?.(`${logPrefix} 音频文件不存在: ${audioInfo.path}`); - } - } catch (err: any) { - log?.warn?.(`${logPrefix} 解析标记失败: ${err.message}`); - } - } - - if (audioInfos.length === 0 && invalidAudios.length === 0) { - log?.info?.(`${logPrefix} 未检测到音频标记`); - return content.replace(AUDIO_MARKER_PATTERN, '').trim(); - } - - // 先移除所有音频标记 - let cleanedContent = content.replace(AUDIO_MARKER_PATTERN, '').trim(); - - const statusMessages: string[] = []; - - for (const invalidPath of invalidAudios) { - statusMessages.push(`⚠️ 音频文件不存在: ${path.basename(invalidPath)}`); - } - - if (audioInfos.length > 0) { - log?.info?.(`${logPrefix} 检测到 ${audioInfos.length} 个音频,开始处理...`); - } - - for (const audioInfo of audioInfos) { - const fileName = path.basename(audioInfo.path); - try { - const ext = path.extname(audioInfo.path).slice(1).toLowerCase(); - - const fileInfo: FileInfo = { - path: audioInfo.path, - fileName: fileName, - fileType: ext, - }; - - // 上传音频到钉钉 - const mediaId = await uploadMediaToDingTalk(audioInfo.path, 'voice', oapiToken, 20 * 1024 * 1024, log); - if (!mediaId) { - statusMessages.push(`⚠️ 音频上传失败: ${fileName}(文件可能超过 20MB 限制)`); - continue; - } - - // 提取音频实际时长 - const audioDurationMs = await extractAudioDuration(audioInfo.path, log); - - // 发送音频消息 - if (useProactiveApi && target) { - await sendAudioProactive(config, target, fileInfo, mediaId, log, audioDurationMs ?? undefined); - } else { - await sendAudioMessage(config, sessionWebhook, fileInfo, mediaId, oapiToken, log, audioDurationMs ?? undefined); - } - statusMessages.push(`✅ 音频已发送: ${fileName}`); - log?.info?.(`${logPrefix} 音频处理完成: ${fileName}`); - } catch (err: any) { - log?.error?.(`${logPrefix} 处理音频失败: ${err.message}`); - statusMessages.push(`⚠️ 音频处理异常: ${fileName}(${err.message})`); - } - } - - if (statusMessages.length > 0) { - const statusText = statusMessages.join('\n'); - cleanedContent = cleanedContent - ? `${cleanedContent}\n\n${statusText}` - : statusText; - } - - return cleanedContent; -} - -/** - * 主动创建并发送 AI Card(通用内部实现) - * 复用 createAICardForTarget 并完整支持后处理 - * @param config 钉钉配置 - * @param target 投放目标(单聊或群聊) - * @param content 消息内容 - * @param log 日志对象 - * @returns SendResult - */ -async function sendAICardInternal( - config: any, - target: AICardTarget, - content: string, - log?: any, -): Promise { - const targetDesc = target.type === 'group' - ? `群聊 ${target.openConversationId}` - : `用户 ${target.userId}`; - - try { - // 0. 获取 oapiToken 用于后处理 - const oapiToken = await getOapiAccessToken(config); - - // 1. 后处理01:上传本地图片到钉钉,替换路径为 media_id - let processedContent = content; - if (oapiToken) { - log?.info?.(`[DingTalk][AICard][Proactive] 开始图片后处理`); - processedContent = await processLocalImages(content, oapiToken, log); - } else { - log?.warn?.(`[DingTalk][AICard][Proactive] 无法获取 oapiToken,跳过媒体后处理`); - } - - // 2. 后处理02:提取视频标记并发送视频消息 - log?.info?.(`[DingTalk][Video][Proactive] 开始视频后处理`); - processedContent = await processVideoMarkers(processedContent, '', config, oapiToken, log, true, target); - - // 3. 后处理03:提取音频标记并发送音频消息(使用主动消息 API) - log?.info?.(`[DingTalk][Audio][Proactive] 开始音频后处理`); - processedContent = await processAudioMarkers(processedContent, '', config, oapiToken, log, true, target); - - // 4. 后处理04:提取文件标记并发送独立文件消息(使用主动消息 API) - log?.info?.(`[DingTalk][File][Proactive] 开始文件后处理`); - processedContent = await processFileMarkers(processedContent, '', config, oapiToken, log, true, target); - - // 5. 检查处理后的内容是否为空(纯文件/视频/音频消息场景) - // 如果内容只包含文件/视频/音频标记,处理后会变成空字符串,此时跳过创建空白 AI Card - const trimmedContent = processedContent.trim(); - if (!trimmedContent) { - log?.info?.(`[DingTalk][AICard][Proactive] 处理后内容为空(纯文件/视频消息),跳过创建 AI Card`); - return { ok: true, usedAICard: false }; - } - - // 5. 创建卡片(复用通用函数) - const card = await createAICardForTarget(config, target, log); - if (!card) { - return { ok: false, error: 'Failed to create AI Card', usedAICard: false }; - } - - // 6. 使用 finishAICard 设置内容 - await finishAICard(card, processedContent, log); - - log?.info?.(`[DingTalk][AICard][Proactive] AI Card 发送成功: ${targetDesc}, cardInstanceId=${card.cardInstanceId}`); - return { ok: true, cardInstanceId: card.cardInstanceId, usedAICard: true }; - - } catch (err: any) { - log?.error?.(`[DingTalk][AICard][Proactive] AI Card 发送失败 (${targetDesc}): ${err.message}`); - if (err.response) { - log?.error?.(`[DingTalk][AICard][Proactive] 错误响应: status=${err.response.status} data=${JSON.stringify(err.response.data)}`); - } - return { ok: false, error: err.response?.data?.message || err.message, usedAICard: false }; - } -} - -/** - * 主动发送 AI Card 到单聊用户 - */ -async function sendAICardToUser( - config: any, - userId: string, - content: string, - log?: any, -): Promise { - return sendAICardInternal(config, { type: 'user', userId }, content, log); -} - -/** - * 主动发送 AI Card 到群聊 - */ -async function sendAICardToGroup( - config: any, - openConversationId: string, - content: string, - log?: any, -): Promise { - return sendAICardInternal(config, { type: 'group', openConversationId }, content, log); -} - -/** - * 构建普通消息的 msgKey 和 msgParam - * 提取公共逻辑,供 sendNormalToUser 和 sendNormalToGroup 复用 - */ -function buildMsgPayload( - msgType: DingTalkMsgType, - content: string, - title?: string, -): { msgKey: string; msgParam: Record } | { error: string } { - switch (msgType) { - case 'markdown': - return { - msgKey: 'sampleMarkdown', - msgParam: { - title: title || content.split('\n')[0].replace(/^[#*\s\->]+/, '').slice(0, 20) || 'Message', - text: content, - }, - }; - case 'link': - try { - return { - msgKey: 'sampleLink', - msgParam: typeof content === 'string' ? JSON.parse(content) : content, - }; - } catch { - return { error: 'Invalid link message format, expected JSON' }; - } - case 'actionCard': - try { - return { - msgKey: 'sampleActionCard', - msgParam: typeof content === 'string' ? JSON.parse(content) : content, - }; - } catch { - return { error: 'Invalid actionCard message format, expected JSON' }; - } - case 'image': - return { - msgKey: 'sampleImageMsg', - msgParam: { photoURL: content }, - }; - case 'text': - default: - return { - msgKey: 'sampleText', - msgParam: { content }, - }; - } -} - -/** - * 使用普通消息 API 发送单聊消息(降级方案) - */ -async function sendNormalToUser( - config: any, - userIds: string | string[], - content: string, - options: { msgType?: DingTalkMsgType; title?: string; log?: any } = {}, -): Promise { - const { msgType = 'text', title, log } = options; - const userIdArray = Array.isArray(userIds) ? userIds : [userIds]; - - // 构建消息参数 - const payload = buildMsgPayload(msgType, content, title); - if ('error' in payload) { - return { ok: false, error: payload.error, usedAICard: false }; - } - - try { - const token = await getAccessToken(config); - const body = { - robotCode: config.clientId, - userIds: userIdArray, - msgKey: payload.msgKey, - msgParam: JSON.stringify(payload.msgParam), - }; - - log?.info?.(`[DingTalk][Normal] 发送单聊消息: userIds=${userIdArray.join(',')}, msgType=${msgType}`); - - const resp = await axios.post(`${DINGTALK_API}/v1.0/robot/oToMessages/batchSend`, body, { - headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' }, - timeout: 10_000, - }); - - if (resp.data?.processQueryKey) { - log?.info?.(`[DingTalk][Normal] 发送成功: processQueryKey=${resp.data.processQueryKey}`); - return { ok: true, processQueryKey: resp.data.processQueryKey, usedAICard: false }; - } - - log?.warn?.(`[DingTalk][Normal] 发送响应异常: ${JSON.stringify(resp.data)}`); - return { ok: false, error: resp.data?.message || 'Unknown error', usedAICard: false }; - } catch (err: any) { - const errMsg = err.response?.data?.message || err.message; - log?.error?.(`[DingTalk][Normal] 发送失败: ${errMsg}`); - return { ok: false, error: errMsg, usedAICard: false }; - } -} - -/** - * 使用普通消息 API 发送群聊消息(降级方案) - */ -async function sendNormalToGroup( - config: any, - openConversationId: string, - content: string, - options: { msgType?: DingTalkMsgType; title?: string; log?: any } = {}, -): Promise { - const { msgType = 'text', title, log } = options; - - // 构建消息参数 - const payload = buildMsgPayload(msgType, content, title); - if ('error' in payload) { - return { ok: false, error: payload.error, usedAICard: false }; - } - - try { - const token = await getAccessToken(config); - const body = { - robotCode: config.clientId, - openConversationId, - msgKey: payload.msgKey, - msgParam: JSON.stringify(payload.msgParam), - }; - - log?.info?.(`[DingTalk][Normal] 发送群聊消息: openConversationId=${openConversationId}, msgType=${msgType}`); - - const resp = await axios.post(`${DINGTALK_API}/v1.0/robot/groupMessages/send`, body, { - headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' }, - timeout: 10_000, - }); - - if (resp.data?.processQueryKey) { - log?.info?.(`[DingTalk][Normal] 发送成功: processQueryKey=${resp.data.processQueryKey}`); - return { ok: true, processQueryKey: resp.data.processQueryKey, usedAICard: false }; - } - - log?.warn?.(`[DingTalk][Normal] 发送响应异常: ${JSON.stringify(resp.data)}`); - return { ok: false, error: resp.data?.message || 'Unknown error', usedAICard: false }; - } catch (err: any) { - const errMsg = err.response?.data?.message || err.message; - log?.error?.(`[DingTalk][Normal] 发送失败: ${errMsg}`); - return { ok: false, error: errMsg, usedAICard: false }; - } -} - -/** - * 主动发送单聊消息给指定用户 - * 默认使用 AI Card,失败时降级到普通消息 - * @param config 钉钉配置(需包含 clientId 和 clientSecret) - * @param userIds 用户 ID 数组(staffId 或 unionId) - * @param content 消息内容 - * @param options 可选配置 - */ -async function sendToUser( - config: any, - userIds: string | string[], - content: string, - options: ProactiveSendOptions = {}, -): Promise { - const { log, useAICard = true, fallbackToNormal = true } = options; - - if (!config.clientId || !config.clientSecret) { - return { ok: false, error: 'Missing clientId or clientSecret', usedAICard: false }; - } - - const userIdArray = Array.isArray(userIds) ? userIds : [userIds]; - if (userIdArray.length === 0) { - return { ok: false, error: 'userIds cannot be empty', usedAICard: false }; - } - - // AI Card 只支持单个用户 - if (useAICard && userIdArray.length === 1) { - log?.info?.(`[DingTalk][SendToUser] 尝试使用 AI Card 发送: userId=${userIdArray[0]}`); - const cardResult = await sendAICardToUser(config, userIdArray[0], content, log); - - if (cardResult.ok) { - return cardResult; - } - - // AI Card 失败 - log?.warn?.(`[DingTalk][SendToUser] AI Card 发送失败: ${cardResult.error}`); - - if (!fallbackToNormal) { - log?.error?.(`[DingTalk][SendToUser] 不降级到普通消息,返回错误`); - return cardResult; - } - - log?.info?.(`[DingTalk][SendToUser] 降级到普通消息发送`); - } else if (useAICard && userIdArray.length > 1) { - log?.info?.(`[DingTalk][SendToUser] 多用户发送不支持 AI Card,使用普通消息`); - } - - // 使用普通消息 - return sendNormalToUser(config, userIdArray, content, options); -} - -/** - * 主动发送群聊消息到指定群 - * 默认使用 AI Card,失败时降级到普通消息 - * @param config 钉钉配置(需包含 clientId 和 clientSecret) - * @param openConversationId 群会话 ID - * @param content 消息内容 - * @param options 可选配置 - */ -async function sendToGroup( - config: any, - openConversationId: string, - content: string, - options: ProactiveSendOptions = {}, -): Promise { - const { log, useAICard = true, fallbackToNormal = true } = options; - - if (!config.clientId || !config.clientSecret) { - return { ok: false, error: 'Missing clientId or clientSecret', usedAICard: false }; - } - - if (!openConversationId) { - return { ok: false, error: 'openConversationId cannot be empty', usedAICard: false }; - } - - // 尝试使用 AI Card - if (useAICard) { - log?.info?.(`[DingTalk][SendToGroup] 尝试使用 AI Card 发送: openConversationId=${openConversationId}`); - const cardResult = await sendAICardToGroup(config, openConversationId, content, log); - - if (cardResult.ok) { - return cardResult; - } - - // AI Card 失败 - log?.warn?.(`[DingTalk][SendToGroup] AI Card 发送失败: ${cardResult.error}`); - - if (!fallbackToNormal) { - log?.error?.(`[DingTalk][SendToGroup] 不降级到普通消息,返回错误`); - return cardResult; - } - - log?.info?.(`[DingTalk][SendToGroup] 降级到普通消息发送`); - } - - // 使用普通消息 - return sendNormalToGroup(config, openConversationId, content, options); -} - -/** - * 智能发送消息 - * 默认使用 AI Card,失败时降级到普通消息 - * @param config 钉钉配置 - * @param target 目标:{ userId } 或 { openConversationId } - * @param content 消息内容 - * @param options 可选配置 - */ -async function sendProactive( - config: any, - target: { userId?: string; userIds?: string[]; openConversationId?: string }, - content: string, - options: ProactiveSendOptions = {}, -): Promise { - // 自动检测是否使用 markdown(用于降级时) - if (!options.msgType) { - const hasMarkdown = /^[#*>-]|[*_`#\[\]]/.test(content) || content.includes('\n'); - if (hasMarkdown) { - options.msgType = 'markdown'; - } - } - - // 发送到用户 - if (target.userId || target.userIds) { - const userIds = target.userIds || [target.userId!]; - return sendToUser(config, userIds, content, options); - } - - // 发送到群 - if (target.openConversationId) { - return sendToGroup(config, target.openConversationId, content, options); - } - - return { ok: false, error: 'Must specify userId, userIds, or openConversationId', usedAICard: false }; -} - -// ============ 核心消息处理 (AI Card Streaming) ============ - -async function handleDingTalkMessage(params: { - cfg: ClawdbotConfig; - accountId: string; - data: any; - sessionWebhook: string; - log?: any; - dingtalkConfig: any; -}): Promise { - const { cfg, accountId, data, sessionWebhook, log, dingtalkConfig } = params; - - const content = extractMessageContent(data); - if (!content.text && content.imageUrls.length === 0 && content.downloadCodes.length === 0) return; - - const isDirect = data.conversationType === '1'; - const senderId = data.senderStaffId || data.senderId; - const senderName = data.senderNick || 'Unknown'; - - log?.info?.(`[DingTalk] 收到消息: from=${senderName} type=${content.messageType} text="${content.text.slice(0, 50)}..." images=${content.imageUrls.length} downloadCodes=${content.downloadCodes.length}`); - - // ===== DM Policy 检查 ===== - if (isDirect) { - const dmPolicy = dingtalkConfig.dmPolicy || 'open'; - const allowFrom: string[] = dingtalkConfig.allowFrom || []; - if (dmPolicy === 'allowlist' && allowFrom.length > 0 && !allowFrom.includes(senderId)) { - log?.warn?.(`[DingTalk] DM 被拦截: senderId=${senderId} 不在 allowFrom 白名单中`); - return; - } - } - - // ===== Session 管理 ===== - const forceNewSession = isNewSessionCommand(content.text); - - // 如果是新会话命令,直接回复确认消息 - if (forceNewSession) { - await sendMessage(dingtalkConfig, sessionWebhook, '✨ 已开启新会话,之前的对话已清空。', { - atUserId: !isDirect ? senderId : null, - }); - log?.info?.(`[DingTalk] 用户请求新会话: ${senderId}`); - return; - } - - // 构建 OpenClaw 标准会话上下文 - // 兼容旧配置:sessionTimeout 已废弃,打印警告 - if (dingtalkConfig.sessionTimeout !== undefined) { - log?.warn?.(`[DingTalk][Deprecation] 'sessionTimeout' 配置已废弃,会话超时由 OpenClaw Gateway 的 session.reset 配置控制`); - } - const separateSessionByConversation = dingtalkConfig.separateSessionByConversation as boolean | undefined; - const groupSessionScope = dingtalkConfig.groupSessionScope as 'group' | 'group_sender' | undefined; - const sessionContext = buildSessionContext({ - accountId, - senderId, - senderName, - conversationType: data.conversationType, - conversationId: data.conversationId, - groupSubject: data.conversationTitle, - separateSessionByConversation, - groupSessionScope, - }); - const sessionContextJson = JSON.stringify(sessionContext); - log?.info?.(`[DingTalk][Session] context=${sessionContextJson}`); - - // memoryUser 用于 Gateway 区分记忆归属 - // 使用 peerId(不包含中文)作为标识符,避免 HTTP Header 编码问题 - const memoryUser = dingtalkConfig.sharedMemoryAcrossConversations === true - ? accountId - : `${sessionContext.channel}:${sessionContext.accountId}:${sessionContext.peerId}`; - - // Gateway 认证:优先使用 token,其次 password - const gatewayAuth = dingtalkConfig.gatewayToken || dingtalkConfig.gatewayPassword || ''; - - // 构建 system prompts & 获取 oapi token(用于图片和文件后处理) - const systemPrompts: string[] = []; - let oapiToken: string | null = null; - - if (dingtalkConfig.enableMediaUpload !== false) { - // 添加图片和文件使用提示(告诉 LLM 直接输出本地路径或文件标记) - systemPrompts.push(buildMediaSystemPrompt()); - // 获取 token 用于后处理上传 - oapiToken = await getOapiAccessToken(dingtalkConfig); - log?.info?.(`[DingTalk][Media] oapiToken 获取${oapiToken ? '成功' : '失败'}`); - } else { - log?.info?.(`[DingTalk][Media] enableMediaUpload=false,跳过`); - } - - // 自定义 system prompt - if (dingtalkConfig.systemPrompt) { - systemPrompts.push(dingtalkConfig.systemPrompt); - } - - // ===== 图片下载到本地文件(用于 OpenClaw AgentMediaPayload) ===== - const imageLocalPaths: string[] = []; - - // 处理直接图片 URL(来自 richText 的 pictureUrl) - for (const url of content.imageUrls) { - if (url.startsWith('downloadCode:')) { - // 通过 downloadCode 下载 - const code = url.slice('downloadCode:'.length); - const localPath = await downloadMediaByCode(code, dingtalkConfig, log); - if (localPath) imageLocalPaths.push(localPath); - } else { - // 直接 URL 下载 - const localPath = await downloadImageToFile(url, log); - if (localPath) imageLocalPaths.push(localPath); - } - } - - // 处理 downloadCode(来自 picture 消息,fileNames 为空的是图片) - for (let i = 0; i < content.downloadCodes.length; i++) { - const code = content.downloadCodes[i]; - const fileName = content.fileNames[i]; // 有 fileName 说明是文件,否则是图片 - if (!fileName) { - const localPath = await downloadMediaByCode(code, dingtalkConfig, log); - if (localPath) imageLocalPaths.push(localPath); - } - } - - if (imageLocalPaths.length > 0) { - log?.info?.(`[DingTalk][Image] 成功下载 ${imageLocalPaths.length} 张图片到本地`); - } - - // ===== 文件附件下载与内容提取 ===== - const fileContentParts: string[] = []; - for (let i = 0; i < content.downloadCodes.length; i++) { - const code = content.downloadCodes[i]; - const fileName = content.fileNames[i]; - if (!fileName) continue; // 图片已在上面处理 - - const ext = path.extname(fileName).toLowerCase(); - const localPath = await downloadFileByCode(code, fileName, dingtalkConfig, log); - - if (!localPath) { - fileContentParts.push(`[文件下载失败: ${fileName}]`); - continue; - } - - if (TEXT_FILE_EXTENSIONS.has(ext)) { - // 文本类文件:读取内容追加到消息 - try { - const fileContent = fs.readFileSync(localPath, 'utf-8'); - const maxLen = 50_000; // 限制最大读取长度 - const truncated = fileContent.length > maxLen ? fileContent.slice(0, maxLen) + '\n...(内容过长,已截断)' : fileContent; - fileContentParts.push(`[文件: ${fileName}]\n\`\`\`\n${truncated}\n\`\`\``); - log?.info?.(`[DingTalk][File] 文本文件已读取: ${fileName}, size=${fileContent.length}`); - } catch (err: any) { - log?.error?.(`[DingTalk][File] 读取文本文件失败: ${err.message}`); - fileContentParts.push(`[文件已保存: ${localPath},但读取内容失败]`); - } - } else if (ext === '.docx') { - // Word 文档:用 mammoth 提取纯文本 - try { - const mammoth = await import('mammoth'); - const result = await mammoth.default.extractRawText({ path: localPath }); - const fileContent = result.value; - const maxLen = 50_000; - const truncated = fileContent.length > maxLen ? fileContent.slice(0, maxLen) + '\n...(内容过长,已截断)' : fileContent; - fileContentParts.push(`[文件: ${fileName}]\n\`\`\`\n${truncated}\n\`\`\``); - log?.info?.(`[DingTalk][File] Word 文档已提取文本: ${fileName}, size=${fileContent.length}`); - } catch (err: any) { - log?.error?.(`[DingTalk][File] Word 文档文本提取失败: ${err.message}`); - fileContentParts.push(`[文件已保存: ${localPath},但提取文本失败]`); - } - } else if (ext === '.pdf') { - // PDF 文档:用 pdf-parse 提取纯文本 - try { - const pdfParse = (await import('pdf-parse')).default; - const dataBuffer = fs.readFileSync(localPath); - const pdfData = await pdfParse(dataBuffer); - const fileContent = pdfData.text; - const maxLen = 50_000; - const truncated = fileContent.length > maxLen ? fileContent.slice(0, maxLen) + '\n...(内容过长,已截断)' : fileContent; - fileContentParts.push(`[文件: ${fileName}]\n\`\`\`\n${truncated}\n\`\`\``); - log?.info?.(`[DingTalk][File] PDF 文档已提取文本: ${fileName}, size=${fileContent.length}`); - } catch (err: any) { - log?.error?.(`[DingTalk][File] PDF 文档文本提取失败: ${err.message}`); - fileContentParts.push(`[文件已保存: ${localPath},但提取文本失败]`); - } - } else { - // Office/二进制文件:保存到本地,提示路径 - fileContentParts.push(`[文件已保存: ${localPath},请基于文件名和上下文回答]`); - log?.info?.(`[DingTalk][File] 文件已保存: ${fileName} -> ${localPath}`); - } - } - - // 对于纯图片消息(无文本),添加默认提示 - let userContent = content.text || (imageLocalPaths.length > 0 ? '请描述这张图片' : ''); - // 追加文件内容 - if (fileContentParts.length > 0) { - const fileText = fileContentParts.join('\n\n'); - userContent = userContent ? `${userContent}\n\n${fileText}` : fileText; - } - if (!userContent && imageLocalPaths.length === 0) return; - - // ===== 异步模式:立即回执 + 后台执行 + 主动推送结果 ===== - const asyncMode = dingtalkConfig.asyncMode === true; - const proactiveTarget = isDirect - ? { userId: data.senderStaffId || data.senderId } - : { openConversationId: data.conversationId }; - - if (asyncMode) { - const ackText = dingtalkConfig.ackText || '🫡 任务已接收,处理中...'; - try { - await sendProactive(dingtalkConfig, proactiveTarget, ackText, { - msgType: 'text', - useAICard: false, - fallbackToNormal: true, - log, - }); - } catch (ackErr: any) { - log?.warn?.(`[DingTalk][Async] 回执发送失败: ${ackErr?.message || ackErr}`); - } - - // 计算 peerKind 和 peerId 用于 bindings 匹配 - const peerKind: 'direct' | 'group' = isDirect ? 'direct' : 'group'; - const peerId = senderId; - - let fullResponse = ''; - try { - for await (const chunk of streamFromGateway({ - userContent, - systemPrompts, - sessionKey: sessionContextJson, - gatewayAuth, - memoryUser, - imageLocalPaths: imageLocalPaths.length > 0 ? imageLocalPaths : undefined, - peerKind, - peerId, - gatewayPort: cfg.gateway?.port, - log, - }, accountId)) { - fullResponse += chunk; - } - - log?.info?.(`[DingTalk][Async] Gateway 完成,原始长度=${fullResponse.length}`); - - // 后处理01:上传本地图片到钉钉,替换 file:// 路径为 media_id - fullResponse = await processLocalImages(fullResponse, oapiToken, log); - - // 后处理02:提取视频标记并发送视频消息(主动 API) - const proactiveMediaTarget: AICardTarget = isDirect - ? { type: 'user', userId: data.senderStaffId || data.senderId } - : { type: 'group', openConversationId: data.conversationId }; - fullResponse = await processVideoMarkers(fullResponse, '', dingtalkConfig, oapiToken, log, true, proactiveMediaTarget); - - // 后处理03:提取音频标记并发送音频消息(主动 API) - fullResponse = await processAudioMarkers(fullResponse, '', dingtalkConfig, oapiToken, log, true, proactiveMediaTarget); - - // 后处理04:提取文件标记并发送独立文件消息(主动 API) - fullResponse = await processFileMarkers(fullResponse, '', dingtalkConfig, oapiToken, log, true, proactiveMediaTarget); - - const finalText = fullResponse.trim() || '✅ 任务执行完成(无文本输出)'; - await sendProactive(dingtalkConfig, proactiveTarget, finalText, { - msgType: 'markdown', - useAICard: false, - fallbackToNormal: true, - log, - }); - - log?.info?.(`[DingTalk][Async] 结果已主动推送,长度=${finalText.length}`); - } catch (err: any) { - const errMsg = `⚠️ 任务执行失败: ${err?.message || err}`; - log?.error?.(`[DingTalk][Async] ${errMsg}`); - try { - await sendProactive(dingtalkConfig, proactiveTarget, errMsg, { - msgType: 'text', - useAICard: false, - fallbackToNormal: true, - log, - }); - } catch (sendErr: any) { - log?.error?.(`[DingTalk][Async] 错误通知发送失败: ${sendErr?.message || sendErr}`); - } - } - - return; - } - - // 计算 peerKind 和 peerId 用于 bindings 匹配(在 asyncMode 外部定义,供所有分支使用) - const peerKind: 'direct' | 'group' = isDirect ? 'direct' : 'group'; - const peerId = senderId; - - // 尝试创建 AI Card - const card = await createAICard(dingtalkConfig, data, log); - - if (card) { - // ===== AI Card 流式模式 ===== - log?.info?.(`[DingTalk] AI Card 创建成功: ${card.cardInstanceId}`); - - let accumulated = ''; - let lastUpdateTime = 0; - const updateInterval = 300; // 最小更新间隔 ms - let chunkCount = 0; - - try { - log?.info?.(`[DingTalk] 开始请求 Gateway 流式接口...`); - for await (const chunk of streamFromGateway({ - userContent, - systemPrompts, - sessionKey: sessionContextJson, - gatewayAuth, - memoryUser, - imageLocalPaths: imageLocalPaths.length > 0 ? imageLocalPaths : undefined, - peerKind, - peerId, - gatewayPort: cfg.gateway?.port, - log, - }, accountId)) { - accumulated += chunk; - chunkCount++; - - if (chunkCount <= 3) { - log?.info?.(`[DingTalk] Gateway chunk #${chunkCount}: "${chunk.slice(0, 50)}..." (accumulated=${accumulated.length})`); - } - - // 节流更新,避免过于频繁 - const now = Date.now(); - if (now - lastUpdateTime >= updateInterval) { - // 实时清理文件、视频、音频标记(避免用户在流式过程中看到标记) - const displayContent = accumulated - .replace(FILE_MARKER_PATTERN, '') - .replace(VIDEO_MARKER_PATTERN, '') - .replace(AUDIO_MARKER_PATTERN, '') - .trim(); - await streamAICard(card, displayContent, false, log); - lastUpdateTime = now; - } - } - - log?.info?.(`[DingTalk] Gateway 流完成,共 ${chunkCount} chunks, ${accumulated.length} 字符`); - - // 后处理01:上传本地图片到钉钉,替换 file:// 路径为 media_id - log?.info?.(`[DingTalk][Media] 开始图片后处理,内容片段="${accumulated.slice(0, 200)}..."`); - accumulated = await processLocalImages(accumulated, oapiToken, log); - - // 【关键修复】AI Card 场景使用主动消息 API 发送文件/视频,避免 sessionWebhook 失效问题 - // 构建目标信息用于主动 API(isDirect 已在上面定义) - const proactiveTarget: AICardTarget = isDirect - ? { type: 'user', userId: data.senderStaffId || data.senderId } - : { type: 'group', openConversationId: data.conversationId }; - - // 后处理02:提取视频标记并发送视频消息(使用主动消息 API) - log?.info?.(`[DingTalk][Video] 开始视频后处理 (使用主动API)`); - accumulated = await processVideoMarkers(accumulated, '', dingtalkConfig, oapiToken, log, true, proactiveTarget); - - // 后处理03:提取音频标记并发送音频消息(使用主动消息 API) - log?.info?.(`[DingTalk][Audio] 开始音频后处理 (使用主动API)`); - accumulated = await processAudioMarkers(accumulated, '', dingtalkConfig, oapiToken, log, true, proactiveTarget); - - // 后处理04:提取文件标记并发送独立文件消息(使用主动消息 API) - log?.info?.(`[DingTalk][File] 开始文件后处理 (使用主动API,目标=${JSON.stringify(proactiveTarget)})`); - accumulated = await processFileMarkers(accumulated, sessionWebhook, dingtalkConfig, oapiToken, log, true, proactiveTarget); - - // 完成 AI Card(如果内容为空,说明是纯媒体消息,使用默认提示) - const finalContent = accumulated.trim(); - if (finalContent.length === 0) { - log?.info?.(`[DingTalk][AICard] 内容为空(纯媒体消息),使用默认提示`); - await finishAICard(card, '✅ 媒体已发送', log); - } else { - await finishAICard(card, finalContent, log); - } - log?.info?.(`[DingTalk] 流式响应完成,共 ${finalContent.length} 字符`); - - } catch (err: any) { - log?.error?.(`[DingTalk] Gateway 调用失败: ${err.message}`); - log?.error?.(`[DingTalk] 错误详情: ${err.stack}`); - accumulated += `\n\n⚠️ 响应中断: ${err.message}`; - try { - await finishAICard(card, accumulated, log); - } catch (finishErr: any) { - log?.error?.(`[DingTalk] 错误恢复 finish 也失败: ${finishErr.message}`); - } - } - - } else { - // ===== 降级:普通消息模式 ===== - log?.warn?.(`[DingTalk] AI Card 创建失败,降级为普通消息`); - - let fullResponse = ''; - try { - for await (const chunk of streamFromGateway({ - userContent, - systemPrompts, - sessionKey: sessionContextJson, - gatewayAuth, - memoryUser, - imageLocalPaths: imageLocalPaths.length > 0 ? imageLocalPaths : undefined, - peerKind, - peerId, - gatewayPort: cfg.gateway?.port, - log, - }, accountId)) { - fullResponse += chunk; - } - - // 后处理01:上传本地图片到钉钉,替换 file:// 路径为 media_id - log?.info?.(`[DingTalk][Media] (降级模式) 开始图片后处理,内容片段="${fullResponse.slice(0, 200)}..."`); - fullResponse = await processLocalImages(fullResponse, oapiToken, log); - - // 后处理02:提取视频标记并发送视频消息 - log?.info?.(`[DingTalk][Video] (降级模式) 开始视频后处理`); - fullResponse = await processVideoMarkers(fullResponse, sessionWebhook, dingtalkConfig, oapiToken, log); - - // 后处理03:提取音频标记并发送音频消息 - log?.info?.(`[DingTalk][Audio] (降级模式) 开始音频后处理`); - fullResponse = await processAudioMarkers(fullResponse, sessionWebhook, dingtalkConfig, oapiToken, log); - - // 后处理04:提取文件标记并发送独立文件消息 - log?.info?.(`[DingTalk][File] (降级模式) 开始文件后处理`); - fullResponse = await processFileMarkers(fullResponse, sessionWebhook, dingtalkConfig, oapiToken, log); - - await sendMessage(dingtalkConfig, sessionWebhook, fullResponse || '(无响应)', { - atUserId: !isDirect ? senderId : null, - useMarkdown: true, - }); - log?.info?.(`[DingTalk] 普通消息回复完成,共 ${fullResponse.length} 字符`); - - } catch (err: any) { - log?.error?.(`[DingTalk] Gateway 调用失败: ${err.message}`); - await sendMessage(dingtalkConfig, sessionWebhook, `抱歉,处理请求时出错: ${err.message}`, { - atUserId: !isDirect ? senderId : null, - }); - } - } -} - -// ============ 钉钉文档 API ============ - -/** 文档信息接口 */ -interface DocInfo { - docId: string; - title: string; - docType: string; - creatorId?: string; - updatedAt?: string; -} - -/** 文档内容块 */ -interface DocBlock { - blockId: string; - blockType: string; - text?: string; - children?: DocBlock[]; -} - -/** - * 钉钉文档客户端 - * 支持读写钉钉在线文档(文档、表格等) - */ -class DingtalkDocsClient { - private config: any; - private log?: any; - - constructor(config: any, log?: any) { - this.config = config; - this.log = log; - } - - /** 获取带鉴权的请求头 */ - private async getHeaders(): Promise> { - const token = await getAccessToken(this.config); - return { - 'x-acs-dingtalk-access-token': token, - 'Content-Type': 'application/json', - }; - } - - /** - * 获取文档元信息 - * @param spaceId 空间 ID - * @param docId 文档 ID - */ - async getDocInfo(spaceId: string, docId: string): Promise { - try { - const headers = await this.getHeaders(); - this.log?.info?.(`[DingTalk][Docs] 获取文档信息: spaceId=${spaceId}, docId=${docId}`); - - const resp = await axios.get( - `${DINGTALK_API}/v1.0/doc/spaces/${spaceId}/docs/${docId}`, - { headers, timeout: 10_000 }, - ); - - const data = resp.data; - this.log?.info?.(`[DingTalk][Docs] 文档信息获取成功: title=${data?.title}`); - - return { - docId: data.docId || docId, - title: data.title || '', - docType: data.docType || 'unknown', - creatorId: data.creatorId, - updatedAt: data.updatedAt, - }; - } catch (err: any) { - this.log?.error?.(`[DingTalk][Docs] 获取文档信息失败: ${err.message}`); - return null; - } - } - - /** - * 读取文档内容(通过 v2.0/wiki 节点 API) - * @param nodeId 知识库节点 ID - * @param operatorId 操作者 unionId(必须) - */ - async readDoc(nodeId: string, operatorId?: string): Promise { - try { - const headers = await this.getHeaders(); - this.log?.info?.(`[DingTalk][Docs] 读取知识库节点: nodeId=${nodeId}, operatorId=${operatorId}`); - - if (!operatorId) { - this.log?.error?.('[DingTalk][Docs] readDoc 需要 operatorId(unionId)'); - return null; - } - - const resp = await axios.get( - `${DINGTALK_API}/v2.0/wiki/nodes/${nodeId}`, - { headers, params: { operatorId }, timeout: 15_000 }, - ); - - const node = resp.data?.node || resp.data; - const name = node.name || '未知文档'; - const category = node.category || 'unknown'; - const url = node.url || ''; - const workspaceId = node.workspaceId || ''; - - const content = [ - `文档名: ${name}`, - `类型: ${category}`, - `URL: ${url}`, - `工作区: ${workspaceId}`, - ].join('\n'); - - this.log?.info?.(`[DingTalk][Docs] 节点信息获取成功: name=${name}, category=${category}`); - return content; - } catch (err: any) { - this.log?.error?.(`[DingTalk][Docs] 读取节点失败: ${err.message}`); - if (err.response) { - this.log?.error?.(`[DingTalk][Docs] 错误详情: status=${err.response.status} data=${JSON.stringify(err.response.data)}`); - } - return null; - } - } - - /** - * 从 block 树中递归提取纯文本内容 - */ - private extractTextFromBlocks(blocks: DocBlock[]): string[] { - const result: string[] = []; - for (const block of blocks) { - if (block.text) { - result.push(block.text); - } - if (block.children && block.children.length > 0) { - result.push(...this.extractTextFromBlocks(block.children)); - } - } - return result; - } - - /** - * 向文档追加内容 - * @param docId 文档 ID - * @param content 要追加的文本内容 - * @param index 插入位置(-1 表示末尾) - */ - async appendToDoc( - docId: string, - content: string, - index: number = -1, - ): Promise { - try { - const headers = await this.getHeaders(); - this.log?.info?.(`[DingTalk][Docs] 向文档追加内容: docId=${docId}, contentLen=${content.length}`); - - const body = { - blockType: 'PARAGRAPH', - body: { - text: content, - }, - index, - }; - - await axios.post( - `${DINGTALK_API}/v1.0/doc/documents/${docId}/blocks/root/children`, - body, - { headers, timeout: 10_000 }, - ); - - this.log?.info?.(`[DingTalk][Docs] 内容追加成功`); - return true; - } catch (err: any) { - this.log?.error?.(`[DingTalk][Docs] 追加内容失败: ${err.message}`); - if (err.response) { - this.log?.error?.(`[DingTalk][Docs] 错误详情: status=${err.response.status} data=${JSON.stringify(err.response.data)}`); - } - return false; - } - } - - /** - * 创建新文档 - * @param spaceId 空间 ID - * @param title 文档标题 - * @param content 初始内容(可选) - */ - async createDoc( - spaceId: string, - title: string, - content?: string, - ): Promise { - try { - const headers = await this.getHeaders(); - this.log?.info?.(`[DingTalk][Docs] 创建文档: spaceId=${spaceId}, title=${title}`); - - const body: any = { - spaceId, - parentDentryId: '', - name: title, - docType: 'alidoc', - }; - - const resp = await axios.post( - `${DINGTALK_API}/v1.0/doc/spaces/${spaceId}/docs`, - body, - { headers, timeout: 10_000 }, - ); - - const data = resp.data; - this.log?.info?.(`[DingTalk][Docs] 文档创建成功: docId=${data?.docId}`); - - const docInfo: DocInfo = { - docId: data.docId || data.dentryUuid || '', - title: title, - docType: data.docType || 'alidoc', - }; - - // 如果有初始内容,追加到文档 - if (content && docInfo.docId) { - await this.appendToDoc(docInfo.docId, content); - } - - return docInfo; - } catch (err: any) { - this.log?.error?.(`[DingTalk][Docs] 创建文档失败: ${err.message}`); - if (err.response) { - this.log?.error?.(`[DingTalk][Docs] 错误详情: status=${err.response.status} data=${JSON.stringify(err.response.data)}`); - } - return null; - } - } - - /** - * 搜索文档 - * @param keyword 搜索关键词 - * @param spaceId 空间 ID(可选,不填则搜索所有空间) - */ - async searchDocs( - keyword: string, - spaceId?: string, - ): Promise { - try { - const headers = await this.getHeaders(); - this.log?.info?.(`[DingTalk][Docs] 搜索文档: keyword=${keyword}, spaceId=${spaceId || '全部'}`); - - const body: any = { keyword, maxResults: 20 }; - if (spaceId) body.spaceId = spaceId; - - const resp = await axios.post( - `${DINGTALK_API}/v1.0/doc/docs/search`, - body, - { headers, timeout: 10_000 }, - ); - - const items = resp.data?.items || []; - const docs: DocInfo[] = items.map((item: any) => ({ - docId: item.docId || item.dentryUuid || '', - title: item.name || item.title || '', - docType: item.docType || 'unknown', - creatorId: item.creatorId, - updatedAt: item.updatedAt, - })); - - this.log?.info?.(`[DingTalk][Docs] 搜索到 ${docs.length} 个文档`); - return docs; - } catch (err: any) { - this.log?.error?.(`[DingTalk][Docs] 搜索文档失败: ${err.message}`); - return []; - } - } - - /** - * 列出空间下的文档 - * @param spaceId 空间 ID - * @param parentId 父目录 ID(可选,不填则列出根目录) - */ - async listDocs( - spaceId: string, - parentId?: string, - ): Promise { - try { - const headers = await this.getHeaders(); - this.log?.info?.(`[DingTalk][Docs] 列出文档: spaceId=${spaceId}, parentId=${parentId || '根目录'}`); - - const params: any = { maxResults: 50 }; - if (parentId) params.parentDentryId = parentId; - - const resp = await axios.get( - `${DINGTALK_API}/v1.0/doc/spaces/${spaceId}/dentries`, - { headers, params, timeout: 10_000 }, - ); - - const items = resp.data?.items || []; - const docs: DocInfo[] = items.map((item: any) => ({ - docId: item.dentryUuid || item.docId || '', - title: item.name || '', - docType: item.docType || item.dentryType || 'unknown', - creatorId: item.creatorId, - updatedAt: item.updatedAt, - })); - - this.log?.info?.(`[DingTalk][Docs] 列出 ${docs.length} 个文档/目录`); - return docs; - } catch (err: any) { - this.log?.error?.(`[DingTalk][Docs] 列出文档失败: ${err.message}`); - return []; - } - } -} - -// ============ 插件定义 ============ - -const meta = { - id: 'dingtalk-connector', - label: 'DingTalk', - selectionLabel: 'DingTalk (钉钉)', - docsPath: '/channels/dingtalk-connector', - docsLabel: 'dingtalk-connector', - blurb: '钉钉企业内部机器人,使用 Stream 模式,无需公网 IP,支持 AI Card 流式响应。', - order: 70, - aliases: ['dd', 'ding'], -}; - -const dingtalkPlugin = { - id: 'dingtalk-connector', - meta, - capabilities: { - chatTypes: ['direct', 'group'], - reactions: false, - threads: false, - media: true, - nativeCommands: false, - blockStreaming: false, - }, - reload: { configPrefixes: ['channels.dingtalk-connector'] }, - configSchema: { - schema: { - type: 'object', - additionalProperties: false, - properties: { - enabled: { type: 'boolean', default: true }, - clientId: { type: 'string', description: 'DingTalk App Key (Client ID)' }, - clientSecret: { type: 'string', description: 'DingTalk App Secret (Client Secret)' }, - enableMediaUpload: { type: 'boolean', default: true, description: 'Enable media upload prompt injection' }, - systemPrompt: { type: 'string', default: '', description: 'Custom system prompt' }, - dmPolicy: { type: 'string', enum: ['open', 'pairing', 'allowlist'], default: 'open' }, - allowFrom: { type: 'array', items: { type: 'string' }, description: 'Allowed sender IDs' }, - groupPolicy: { type: 'string', enum: ['open', 'allowlist'], default: 'open' }, - gatewayToken: { type: 'string', default: '', description: 'Gateway auth token (Bearer)' }, - gatewayPassword: { type: 'string', default: '', description: 'Gateway auth password (alternative to token)' }, - sessionTimeout: { type: 'number', default: 1800000, description: 'Session timeout in ms (default 30min)' }, - separateSessionByConversation: { type: 'boolean', default: true, description: '是否按单聊/群聊/群区分 session' }, - sharedMemoryAcrossConversations: { type: 'boolean', default: false, description: '单 agent 场景下是否共享记忆;false 时不同群聊、群聊与私聊记忆隔离' }, - asyncMode: { type: 'boolean', default: false, description: 'Send immediate ack and push final result as a second message' }, - ackText: { type: 'string', default: '🫡 任务已接收,处理中...', description: 'Ack text when asyncMode is enabled' }, - debug: { type: 'boolean', default: false }, - }, - required: ['clientId', 'clientSecret'], - }, - uiHints: { - enabled: { label: 'Enable DingTalk' }, - clientId: { label: 'App Key', sensitive: false }, - clientSecret: { label: 'App Secret', sensitive: true }, - dmPolicy: { label: 'DM Policy' }, - groupPolicy: { label: 'Group Policy' }, - }, - }, - config: { - listAccountIds: (cfg: ClawdbotConfig) => { - const config = getConfig(cfg); - // __default__ 是内部标记,表示使用顶层配置(单账号模式) - return config.accounts - ? Object.keys(config.accounts) - : (isConfigured(cfg) ? ['__default__'] : []); - }, - resolveAccount: (cfg: ClawdbotConfig, accountId?: string) => { - const config = getConfig(cfg); - const id = accountId || DEFAULT_ACCOUNT_ID; - if (config.accounts?.[id]) { - return { accountId: id, config: config.accounts[id], enabled: config.accounts[id].enabled !== false }; - } - // 没有 accounts 配置或找不到指定账号时,使用顶层配置 - return { accountId: DEFAULT_ACCOUNT_ID, config, enabled: config.enabled !== false }; - }, - defaultAccountId: () => '__default__', - isConfigured: (account: any) => Boolean(account.config?.clientId && account.config?.clientSecret), - describeAccount: (account: any) => ({ - accountId: account.accountId, - name: account.config?.name || 'DingTalk', - enabled: account.enabled, - configured: Boolean(account.config?.clientId), - }), - }, - security: { - resolveDmPolicy: ({ account }: any) => ({ - policy: account.config?.dmPolicy || 'open', - allowFrom: account.config?.allowFrom || [], - policyPath: 'channels.dingtalk-connector.dmPolicy', - allowFromPath: 'channels.dingtalk-connector.allowFrom', - approveHint: '使用 /allow dingtalk-connector: 批准用户', - normalizeEntry: (raw: string) => raw.replace(/^(dingtalk-connector|dingtalk|dd|ding):/i, ''), - }), - }, - groups: { - resolveRequireMention: ({ cfg }: any) => getConfig(cfg).groupPolicy !== 'open', - }, - messaging: { - // 注意:normalizeTarget 接收字符串,返回字符串(保持大小写,因为 openConversationId 是 base64 编码) - normalizeTarget: (raw: string) => { - if (!raw) return undefined; - // 去掉渠道前缀,但保持原始大小写 - return raw.trim().replace(/^(dingtalk-connector|dingtalk|dd|ding):/i, ''); - }, - targetResolver: { - // 支持普通 ID、Base64 编码的 conversationId,以及 user:/group: 前缀格式 - looksLikeId: (id: string) => /^(user:|group:)?[\w+/=-]+$/.test(id), - hint: 'user: 或 group:', - }, - }, - outbound: { - deliveryMode: 'direct' as const, - textChunkLimit: 4000, - /** - * 主动发送文本消息 - * @param ctx.to 目标格式:user: 或 group: - * @param ctx.text 消息内容 - * @param ctx.accountId 账号 ID - */ - sendText: async (ctx: any) => { - const { cfg, to, text, accountId, log } = ctx; - const account = dingtalkPlugin.config.resolveAccount(cfg, accountId); - const config = account?.config; - - if (!config?.clientId || !config?.clientSecret) { - throw new Error('DingTalk not configured'); - } - - if (!to) { - throw new Error('Target is required. Format: user: or group:'); - } - - // 解析目标:user: 或 group: - const targetStr = String(to); - let result: SendResult; - - log?.info?.(`[DingTalk][outbound.sendText] 解析目标: targetStr="${targetStr}"`); - - if (targetStr.startsWith('user:')) { - const userId = targetStr.slice(5); - log?.info?.(`[DingTalk][outbound.sendText] 发送给用户: userId="${userId}"`); - result = await sendToUser(config, userId, text, { log }); - } else if (targetStr.startsWith('group:')) { - const openConversationId = targetStr.slice(6); - log?.info?.(`[DingTalk][outbound.sendText] 发送到群: openConversationId="${openConversationId}"`); - result = await sendToGroup(config, openConversationId, text, { log }); - } else { - // 默认当作 userId 处理 - log?.info?.(`[DingTalk][outbound.sendText] 默认发送给用户: userId="${targetStr}"`); - result = await sendToUser(config, targetStr, text, { log }); - } - - if (result.ok) { - return { channel: 'dingtalk-connector', messageId: result.processQueryKey || 'unknown' }; - } - throw new Error(result.error || 'Failed to send message'); - }, - /** - * 主动发送媒体消息(图片) - * @param ctx.to 目标格式:user: 或 group: - * @param ctx.text 消息文本/标题 - * @param ctx.mediaUrl 媒体 URL(钉钉仅支持图片 URL) - * @param ctx.accountId 账号 ID - */ - sendMedia: async (ctx: any) => { - const { cfg, to, text, mediaUrl, accountId, log } = ctx; - const account = dingtalkPlugin.config.resolveAccount(cfg, accountId); - const config = account?.config; - - if (!config?.clientId || !config?.clientSecret) { - throw new Error('DingTalk not configured'); - } - - if (!to) { - throw new Error('Target is required. Format: user: or group:'); - } - - // 解析目标 - const targetStr = String(to); - let result: SendResult; - - // 如果有媒体 URL,发送图片消息 - if (mediaUrl) { - if (targetStr.startsWith('user:')) { - const userId = targetStr.slice(5); - result = await sendToUser(config, userId, mediaUrl, { msgType: 'image', log }); - } else if (targetStr.startsWith('group:')) { - const openConversationId = targetStr.slice(6); - result = await sendToGroup(config, openConversationId, mediaUrl, { msgType: 'image', log }); - } else { - result = await sendToUser(config, targetStr, mediaUrl, { msgType: 'image', log }); - } - } else { - // 无媒体,发送文本 - if (targetStr.startsWith('user:')) { - const userId = targetStr.slice(5); - result = await sendToUser(config, userId, text || '', { log }); - } else if (targetStr.startsWith('group:')) { - const openConversationId = targetStr.slice(6); - result = await sendToGroup(config, openConversationId, text || '', { log }); - } else { - result = await sendToUser(config, targetStr, text || '', { log }); - } - } - - if (result.ok) { - return { channel: 'dingtalk-connector', messageId: result.processQueryKey || 'unknown' }; - } - throw new Error(result.error || 'Failed to send media'); - }, - }, - gateway: { - startAccount: async (ctx: any) => { - const { account, cfg, abortSignal } = ctx; - const config = account.config; - - if (!config.clientId || !config.clientSecret) { - throw new Error('DingTalk clientId and clientSecret are required'); - } - - ctx.log?.info(`[${account.accountId}] 启动钉钉 Stream 客户端...`); - - // 启用 DWClient 内置的 autoReconnect 和 keepAlive - // - autoReconnect: 连接断开时自动重连 - // - keepAlive: 启用心跳机制,防止服务端因长时间无活动而断开连接 - const client = new DWClient({ - clientId: config.clientId, - clientSecret: config.clientSecret, - debug: config.debug || false, - autoReconnect: true, - keepAlive: true, - } as any); - - client.registerCallbackListener(TOPIC_ROBOT, async (res: any) => { - const messageId = res.headers?.messageId; - ctx.log?.info?.(`[DingTalk] 收到 Stream 回调, messageId=${messageId}, headers=${JSON.stringify(res.headers)}`); - - // 【关键修复】立即确认回调,避免钉钉服务器因超时而重发 - // 钉钉 Stream 模式要求及时响应,否则约60秒后会重发消息 - if (messageId) { - client.socketCallBackResponse(messageId, { success: true }); - ctx.log?.info?.(`[DingTalk] 已立即确认回调: messageId=${messageId}`); - } - - // 【消息去重】检查是否已处理过该消息 - if (messageId && isMessageProcessed(messageId)) { - ctx.log?.warn?.(`[DingTalk] 检测到重复消息,跳过处理: messageId=${messageId}`); - return; - } - - // 标记消息为已处理 - if (messageId) { - markMessageProcessed(messageId); - } - - // 异步处理消息(不阻塞回调确认) - try { - ctx.log?.info?.(`[DingTalk] 原始 data: ${typeof res.data === 'string' ? res.data.slice(0, 500) : JSON.stringify(res.data).slice(0, 500)}`); - const data = JSON.parse(res.data); - - await handleDingTalkMessage({ - cfg, - accountId: account.accountId, - data, - sessionWebhook: data.sessionWebhook, - log: ctx.log, - dingtalkConfig: config, - }); - } catch (error: any) { - ctx.log?.error?.(`[DingTalk] 处理消息异常: ${error.message}`); - // 注意:即使处理失败,也不需要再次响应(已经提前确认了) - } - }); - - await client.connect(); - ctx.log?.info(`[${account.accountId}] 钉钉 Stream 客户端已连接`); - - const rt = getRuntime(); - rt.channel.activity.record('dingtalk-connector', account.accountId, 'start'); - - let stopped = false; - - // 统一的停止逻辑 - const doStop = (reason: string) => { - if (stopped) return; - stopped = true; - ctx.log?.info(`[${account.accountId}] 停止钉钉 Stream 客户端 (${reason})...`); - try { - // 【关键】调用 disconnect() 正确关闭 WebSocket 连接 - client.disconnect(); - } catch (err: any) { - ctx.log?.warn?.(`[${account.accountId}] 断开连接时出错: ${err.message}`); - } - rt.channel.activity.record('dingtalk-connector', account.accountId, 'stop'); - }; - - // 【关键修复】返回一个 Promise 并保持 pending 状态直到 abortSignal 触发 - // 这样框架不会认为账号已退出,避免触发 auto-restart - // 参考:OpenClaw changelog - "keep startAccount pending until abort to prevent restart-loop storms" - return new Promise((resolve) => { - if (abortSignal) { - abortSignal.addEventListener('abort', () => { - doStop('abortSignal'); - resolve({ - stop: () => doStop('manual'), - isHealthy: () => !stopped, - }); - }); - } - }); - }, - }, - status: { - defaultRuntime: { accountId: DEFAULT_ACCOUNT_ID, running: false, lastStartAt: null, lastStopAt: null, lastError: null }, - probe: async ({ cfg }: any) => { - if (!isConfigured(cfg)) return { ok: false, error: 'Not configured' }; - try { - const config = getConfig(cfg); - await getAccessToken(config); - return { ok: true, details: { clientId: config.clientId } }; - } catch (error: any) { - return { ok: false, error: error.message }; - } - }, - buildChannelSummary: ({ snapshot }: any) => ({ - configured: snapshot?.configured ?? false, - running: snapshot?.running ?? false, - lastStartAt: snapshot?.lastStartAt ?? null, - lastStopAt: snapshot?.lastStopAt ?? null, - lastError: snapshot?.lastError ?? null, - }), - }, -}; - -// ============ 插件注册 ============ - -const plugin = { - id: 'dingtalk-connector', - name: 'DingTalk Channel', - description: 'DingTalk (钉钉) messaging channel via Stream mode with AI Card streaming', - configSchema: { - type: 'object', - additionalProperties: true, - properties: { enabled: { type: 'boolean', default: true } }, - }, - register(api: ClawdbotPluginApi) { - runtime = api.runtime; - api.registerChannel({ plugin: dingtalkPlugin }); - - // ===== Gateway Methods ===== - - api.registerGatewayMethod('dingtalk-connector.status', async ({ respond, cfg }: any) => { - const result = await dingtalkPlugin.status.probe({ cfg }); - respond(true, result); - }); - - api.registerGatewayMethod('dingtalk-connector.probe', async ({ respond, cfg }: any) => { - const result = await dingtalkPlugin.status.probe({ cfg }); - respond(result.ok, result); - }); - - /** - * 主动发送单聊消息 - * 参数: - * - userId / userIds: 目标用户 ID(支持单个或数组) - * - content: 消息内容 - * - msgType?: 'text' | 'markdown' | 'link' | 'actionCard' | 'image'(降级时使用,默认 text) - * - title?: markdown 消息标题 - * - useAICard?: 是否使用 AI Card(默认 true) - * - fallbackToNormal?: AI Card 失败时是否降级到普通消息(默认 true) - * - accountId?: 使用的账号 ID(可选,不传则使用默认配置) - */ - api.registerGatewayMethod('dingtalk-connector.sendToUser', async ({ respond, cfg, params, log }: any) => { - const { userId, userIds, content, msgType, title, useAICard, fallbackToNormal, accountId } = params || {}; - const account = dingtalkPlugin.config.resolveAccount(cfg, accountId); - - if (!account.config?.clientId) { - return respond(false, { error: 'DingTalk not configured' }); - } - - const targetUserIds = userIds || (userId ? [userId] : []); - if (targetUserIds.length === 0) { - return respond(false, { error: 'userId or userIds is required' }); - } - - if (!content) { - return respond(false, { error: 'content is required' }); - } - - const result = await sendToUser(account.config, targetUserIds, content, { - msgType, - title, - log, - useAICard: useAICard !== false, // 默认 true - fallbackToNormal: fallbackToNormal !== false, // 默认 true - }); - respond(result.ok, result); - }); - - /** - * 主动发送群聊消息 - * 参数: - * - openConversationId: 群会话 ID - * - content: 消息内容 - * - msgType?: 'text' | 'markdown' | 'link' | 'actionCard' | 'image'(降级时使用,默认 text) - * - title?: markdown 消息标题 - * - useAICard?: 是否使用 AI Card(默认 true) - * - fallbackToNormal?: AI Card 失败时是否降级到普通消息(默认 true) - * - accountId?: 使用的账号 ID(可选,不传则使用默认配置) - */ - api.registerGatewayMethod('dingtalk-connector.sendToGroup', async ({ respond, cfg, params, log }: any) => { - const { openConversationId, content, msgType, title, useAICard, fallbackToNormal, accountId } = params || {}; - const account = dingtalkPlugin.config.resolveAccount(cfg, accountId); - - if (!account.config?.clientId) { - return respond(false, { error: 'DingTalk not configured' }); - } - - if (!openConversationId) { - return respond(false, { error: 'openConversationId is required' }); - } - - if (!content) { - return respond(false, { error: 'content is required' }); - } - - const result = await sendToGroup(account.config, openConversationId, content, { - msgType, - title, - log, - useAICard: useAICard !== false, // 默认 true - fallbackToNormal: fallbackToNormal !== false, - }); - respond(result.ok, result); - }); - - /** - * 智能发送消息(自动检测目标类型和消息格式) - * 参数: - * - target: 目标(user: 或 group:) - * - content: 消息内容 - * - msgType?: 消息类型(降级时使用,可选,不指定则自动检测) - * - title?: 标题(用于 markdown) - * - useAICard?: 是否使用 AI Card(默认 true) - * - fallbackToNormal?: AI Card 失败时是否降级到普通消息(默认 true) - * - accountId?: 账号 ID - */ - api.registerGatewayMethod('dingtalk-connector.send', async ({ respond, cfg, params, log }: any) => { - const { target, content, message, msgType, title, useAICard, fallbackToNormal, accountId } = params || {}; - const actualContent = content || message; // 兼容 message 字段 - const account = dingtalkPlugin.config.resolveAccount(cfg, accountId); - - log?.info?.(`[DingTalk][Send] 收到请求: params=${JSON.stringify(params)}`); - - if (!account.config?.clientId) { - return respond(false, { error: 'DingTalk not configured' }); - } - - if (!target) { - return respond(false, { error: 'target is required (format: user: or group:)' }); - } - - if (!actualContent) { - return respond(false, { error: 'content is required' }); - } - - const targetStr = String(target); - let sendTarget: { userId?: string; openConversationId?: string }; - - if (targetStr.startsWith('user:')) { - sendTarget = { userId: targetStr.slice(5) }; - } else if (targetStr.startsWith('group:')) { - sendTarget = { openConversationId: targetStr.slice(6) }; - } else { - // 默认当作 userId - sendTarget = { userId: targetStr }; - } - - log?.info?.(`[DingTalk][Send] 解析后目标: sendTarget=${JSON.stringify(sendTarget)}`); - - const result = await sendProactive(account.config, sendTarget, actualContent, { - msgType, - title, - log, - useAICard: useAICard !== false, // 默认 true - fallbackToNormal: fallbackToNormal !== false, - }); - respond(result.ok, result); - }); - - // ===== 文档 API Methods ===== - - /** - * 读取钉钉知识库文档节点信息 - * 参数: - * - docId: 知识库节点 ID - * - operatorId: 操作者 unionId 或 staffId(会自动转换为 unionId) - * - accountId?: 账号 ID - */ - api.registerGatewayMethod('dingtalk-connector.docs.read', async ({ respond, cfg, params, log }: any) => { - const { docId, operatorId: rawOperatorId, accountId } = params || {}; - const account = dingtalkPlugin.config.resolveAccount(cfg, accountId); - - if (!account.config?.clientId) { - return respond(false, { error: 'DingTalk not configured' }); - } - if (!docId) { - return respond(false, { error: 'docId is required' }); - } - if (!rawOperatorId) { - return respond(false, { error: 'operatorId (unionId or staffId) is required' }); - } - - // 如果 operatorId 不像 unionId(通常以字母数字开头且较长),尝试将 staffId 转为 unionId - let operatorId = rawOperatorId; - if (!rawOperatorId.includes('$')) { - // 可能已经是 unionId,直接使用;否则尝试转换 - const resolved = await getUnionId(rawOperatorId, account.config, log); - if (resolved) operatorId = resolved; - } - - const client = new DingtalkDocsClient(account.config, log); - const content = await client.readDoc(docId, operatorId); - - if (content !== null) { - respond(true, { content }); - } else { - respond(false, { error: 'Failed to read document node' }); - } - }); - - /** - * 创建钉钉文档 - * 参数: - * - spaceId: 空间 ID - * - title: 文档标题 - * - content?: 初始内容 - * - accountId?: 账号 ID - */ - api.registerGatewayMethod('dingtalk-connector.docs.create', async ({ respond, cfg, params, log }: any) => { - const { spaceId, title, content, accountId } = params || {}; - const account = dingtalkPlugin.config.resolveAccount(cfg, accountId); - - if (!account.config?.clientId) { - return respond(false, { error: 'DingTalk not configured' }); - } - if (!spaceId || !title) { - return respond(false, { error: 'spaceId and title are required' }); - } - - const client = new DingtalkDocsClient(account.config, log); - const doc = await client.createDoc(spaceId, title, content); - - if (doc) { - respond(true, doc); - } else { - respond(false, { error: 'Failed to create document' }); - } - }); - - /** - * 向钉钉文档追加内容 - * 参数: - * - docId: 文档 ID - * - content: 要追加的内容 - * - accountId?: 账号 ID - */ - api.registerGatewayMethod('dingtalk-connector.docs.append', async ({ respond, cfg, params, log }: any) => { - const { docId, content, accountId } = params || {}; - const account = dingtalkPlugin.config.resolveAccount(cfg, accountId); - - if (!account.config?.clientId) { - return respond(false, { error: 'DingTalk not configured' }); - } - if (!docId || !content) { - return respond(false, { error: 'docId and content are required' }); - } - - const client = new DingtalkDocsClient(account.config, log); - const ok = await client.appendToDoc(docId, content); - respond(ok, ok ? { success: true } : { error: 'Failed to append to document' }); - }); - - /** - * 搜索钉钉文档 - * 参数: - * - keyword: 搜索关键词 - * - spaceId?: 空间 ID(可选) - * - accountId?: 账号 ID - */ - api.registerGatewayMethod('dingtalk-connector.docs.search', async ({ respond, cfg, params, log }: any) => { - const { keyword, spaceId, accountId } = params || {}; - const account = dingtalkPlugin.config.resolveAccount(cfg, accountId); - - if (!account.config?.clientId) { - return respond(false, { error: 'DingTalk not configured' }); - } - if (!keyword) { - return respond(false, { error: 'keyword is required' }); - } - - const client = new DingtalkDocsClient(account.config, log); - const docs = await client.searchDocs(keyword, spaceId); - respond(true, { docs }); - }); - - /** - * 列出空间下的文档 - * 参数: - * - spaceId: 空间 ID - * - parentId?: 父目录 ID(可选) - * - accountId?: 账号 ID - */ - api.registerGatewayMethod('dingtalk-connector.docs.list', async ({ respond, cfg, params, log }: any) => { - const { spaceId, parentId, accountId } = params || {}; - const account = dingtalkPlugin.config.resolveAccount(cfg, accountId); - - if (!account.config?.clientId) { - return respond(false, { error: 'DingTalk not configured' }); - } - if (!spaceId) { - return respond(false, { error: 'spaceId is required' }); - } - - const client = new DingtalkDocsClient(account.config, log); - const docs = await client.listDocs(spaceId, parentId); - respond(true, { docs }); - }); - - api.logger?.info('[DingTalk] 插件已注册(支持主动发送 AI Card 消息、文档读写)'); - }, -}; - -export default plugin; -export { - dingtalkPlugin, - // 回复消息(需要 sessionWebhook) - sendMessage, - sendTextMessage, - sendMarkdownMessage, - // 主动发送消息(无需 sessionWebhook) - sendToUser, - sendToGroup, - sendProactive, - // 钉钉文档客户端 - DingtalkDocsClient, -}; diff --git a/.flocks/plugins/channels/dingtalk/dingtalk.py b/.flocks/plugins/channels/dingtalk/dingtalk.py deleted file mode 100644 index 72967f3f7..000000000 --- a/.flocks/plugins/channels/dingtalk/dingtalk.py +++ /dev/null @@ -1,247 +0,0 @@ -""" -DingTalk ChannelPlugin for flocks. - -通过 subprocess 启动 runner.ts(npm),runner.ts 构造假的 OpenClaw runtime, -驱动 plugin.ts 的 DWClient WebSocket 连接钉钉。 -flocks 对外暴露的 POST /v1/chat/completions 端点承接所有 AI 推理请求。 - -放置位置: - .flocks/plugins/channels/dingtalk/dingtalk.py - -目录结构: - dingtalk/ - ├── dingtalk.py ← 本文件(flocks 自动加载) - ├── runner.ts ← Node.js 桥接层(无需修改) - └── dingtalk-openclaw-connector/ - └── plugin.ts ← 原版 connector(无需修改) - -flocks.json 配置示例: - { - "channels": { - "dingtalk": { - "enabled": true, - "clientId": "dingXXXXXX", - "clientSecret": "your_secret", - "defaultAgent": "rex" - } - } - } - -可选额外字段(透传给 plugin.ts): - gatewayToken Bearer 认证 token(通常不需要,flocks 本地无鉴权) - debug true/false,开启 plugin.ts 调试日志 - separateSessionByConversation true(默认) - groupSessionScope "group"(默认)/ "group_sender" - sharedMemoryAcrossConversations false(默认) - dmPolicy "open"(默认)/ "allowlist" - allowFrom 允许的 senderStaffId 列表 -""" - -from __future__ import annotations - -import asyncio -import os -import subprocess -import sys -from pathlib import Path -from typing import Any, Awaitable, Callable, Optional - -from flocks.channel.base import ( - ChannelCapabilities, - ChannelMeta, - ChannelPlugin, - ChatType, - DeliveryResult, - InboundMessage, - OutboundContext, -) -from flocks.utils.log import Log - -log = Log.create(service="channel.dingtalk") - -# runner.ts 所在目录(与本文件同级) -_PLUGIN_DIR = Path(__file__).parent -_RUNNER_TS = _PLUGIN_DIR / "runner.ts" -_CONNECTOR_DIR = _PLUGIN_DIR / "dingtalk-openclaw-connector" -_CONNECTOR_PACKAGE = _CONNECTOR_DIR / "package.json" - - -def _find_npm() -> str: - """返回 npm 可执行路径,找不到则抛出。""" - if npm := os.environ.get("NPM_PATH"): - return npm - - import shutil - - for candidate in ("npm", "npm.cmd"): - if npm := shutil.which(candidate): - return npm - - raise RuntimeError( - "找不到 npm。请先安装 Node.js(包含 npm)或设置 NPM_PATH 环境变量。" - ) - - -class DingTalkChannel(ChannelPlugin): - """DingTalk channel — 通过 runner.ts 子进程桥接 plugin.ts。""" - - def __init__(self) -> None: - super().__init__() - self._proc: Optional[subprocess.Popen] = None - self._monitor_task: Optional[asyncio.Task] = None - - # ── 元数据 ──────────────────────────────────────────────────────────────── - - def meta(self) -> ChannelMeta: - return ChannelMeta( - id="dingtalk", - label="钉钉", - aliases=["dingding", "dingtalk-connector"], - order=30, - ) - - def capabilities(self) -> ChannelCapabilities: - return ChannelCapabilities( - chat_types=[ChatType.DIRECT, ChatType.GROUP], - media=True, - threads=False, - reactions=False, - edit=False, - rich_text=True, - ) - - def validate_config(self, config: dict) -> Optional[str]: - for key in ("clientId", "clientSecret"): - if not config.get(key): - return f"缺少必填配置项: {key}" - if not _RUNNER_TS.exists(): - return f"找不到 runner.ts: {_RUNNER_TS}" - if not _CONNECTOR_PACKAGE.exists(): - return f"找不到 package.json: {_CONNECTOR_PACKAGE}" - return None - - # ── 生命周期 ────────────────────────────────────────────────────────────── - - async def start( - self, - config: dict, - on_message: Callable[[InboundMessage], Awaitable[None]], - abort_event: Optional[asyncio.Event] = None, - ) -> None: - """启动 runner.ts 子进程,监控其生命周期直到 abort_event 触发。""" - self._config = config - self._on_message = on_message - - npm = _find_npm() - flocks_port = self._get_flocks_port() - - env = { - **os.environ, - "DINGTALK_CLIENT_ID": config.get("clientId", ""), - "DINGTALK_CLIENT_SECRET": config.get("clientSecret", ""), - "FLOCKS_PORT": str(flocks_port), - "FLOCKS_AGENT": config.get("defaultAgent", ""), - "FLOCKS_GATEWAY_TOKEN": config.get("gatewayToken", ""), - "DINGTALK_DEBUG": "true" if config.get("debug") else "false", - "DINGTALK_ACCOUNT_ID": config.get("_account_id", "__default__"), - } - - log.info("dingtalk.start", { - "runner": str(_RUNNER_TS), - "flocks_port": flocks_port, - "client_id": config.get("clientId", ""), - }) - - self._start_process(npm, env) - self.mark_connected() - - # 监控子进程直到 abort_event - self._monitor_task = asyncio.create_task( - self._monitor(abort_event) - ) - await self._monitor_task - - async def stop(self) -> None: - if self._monitor_task and not self._monitor_task.done(): - self._monitor_task.cancel() - self._kill_process() - self.mark_disconnected() - - # ── 出站消息 ────────────────────────────────────────────────────────────── - # plugin.ts 通过 sessionWebhook 直接回复钉钉,flocks 不需要经过 send_text 投递。 - # 但框架要求实现此方法,留作主动推送备用。 - - async def send_text(self, ctx: OutboundContext) -> DeliveryResult: - """ - 主动推送文本消息(用于 Agent 主动发钉钉)。 - plugin.ts 的被动回复走 sessionWebhook,不经此路径。 - 此处实现留作后续扩展,当前返回不支持。 - """ - log.warning("dingtalk.send_text.not_implemented", { - "to": ctx.to, - "hint": "主动推送需通过 dingtalk-connector.send GatewayMethod", - }) - return DeliveryResult( - channel_id="dingtalk", - message_id="", - success=False, - error="主动推送暂未实现,plugin.ts 的被动回复走 sessionWebhook", - ) - - # ── 内部方法 ────────────────────────────────────────────────────────────── - - def _get_flocks_port(self) -> int: - """从环境变量或默认值获取 flocks HTTP 端口。""" - return int(os.environ.get("FLOCKS_PORT", "8000")) - - def _start_process(self, npm: str, env: dict) -> None: - """启动 runner.ts 子进程。""" - self._proc = subprocess.Popen( - [npm, "run", "start:runner"], - cwd=str(_CONNECTOR_DIR), - env=env, - stdout=sys.stdout, - stderr=sys.stderr, - ) - log.info("dingtalk.process.started", {"pid": self._proc.pid}) - - def _kill_process(self) -> None: - """终止子进程。""" - if self._proc and self._proc.poll() is None: - log.info("dingtalk.process.terminating", {"pid": self._proc.pid}) - self._proc.terminate() - try: - self._proc.wait(timeout=5) - except subprocess.TimeoutExpired: - self._proc.kill() - self._proc.wait() - log.info("dingtalk.process.stopped", {"pid": self._proc.pid}) - self._proc = None - - async def _monitor(self, abort_event: Optional[asyncio.Event]) -> None: - """监控子进程;退出码非零时记录错误;abort_event 触发时停止。""" - try: - while True: - if abort_event and abort_event.is_set(): - log.info("dingtalk.monitor.abort") - break - - # 非阻塞检查进程是否已退出 - if self._proc and self._proc.poll() is not None: - rc = self._proc.returncode - if rc != 0: - log.error("dingtalk.process.exited_unexpectedly", {"returncode": rc}) - self.mark_disconnected(f"runner.ts 意外退出,exit code={rc}") - else: - log.info("dingtalk.process.exited_normally", {"returncode": rc}) - break - - await asyncio.sleep(2) - except asyncio.CancelledError: - pass - finally: - self._kill_process() - - -# flocks PluginLoader 通过此变量发现插件 -CHANNELS = [DingTalkChannel()] diff --git a/.flocks/plugins/channels/dingtalk/runner.ts b/.flocks/plugins/channels/dingtalk/runner.ts deleted file mode 100644 index 273dce658..000000000 --- a/.flocks/plugins/channels/dingtalk/runner.ts +++ /dev/null @@ -1,355 +0,0 @@ -/** - * runner.ts — flocks DingTalk bridge - * - * 构造一个最小化的 OpenClaw PluginRuntime/ClawdbotPluginApi 模拟层, - * 使 plugin.ts 无需任何修改即可在 flocks 环境中运行。 - * - * 关键替换: - * plugin.ts 内部的 streamFromGateway() 会调用 - * POST http://127.0.0.1:{port}/v1/chat/completions (SSE) - * 我们让 port 指向 flocks,并在 flocks 上注册 /v1/chat/completions 端点 - * ——但更干净的做法是:让 cfg.gateway.port 指向一个本文件内嵌的 - * 轻量 HTTP 代理,该代理把 OpenAI 格式转换成 flocks 真实 API 调用: - * POST /api/session → 创建/复用 session - * POST /api/session/{id}/message → 触发推理 - * GET /api/event → SSE,过滤 message.part.updated.delta - * 然后以 OpenAI SSE 格式回流给 plugin.ts,零侵入 plugin.ts。 - * - * 启动方式(由 dingtalk.py 通过 subprocess 调用): - * DINGTALK_CLIENT_ID=xxx DINGTALK_CLIENT_SECRET=xxx FLOCKS_PORT=8000 bun run runner.ts - */ - -import plugin from './dingtalk-openclaw-connector/plugin.ts'; -import { createServer, type IncomingMessage, type ServerResponse } from 'http'; - -// ── 环境变量 ──────────────────────────────────────────────────────────────── -const CLIENT_ID = process.env.DINGTALK_CLIENT_ID || ''; -const CLIENT_SECRET = process.env.DINGTALK_CLIENT_SECRET || ''; -const FLOCKS_PORT = parseInt(process.env.FLOCKS_PORT || '8000', 10); -const FLOCKS_AGENT = process.env.FLOCKS_AGENT || ''; -const GATEWAY_TOKEN = process.env.FLOCKS_GATEWAY_TOKEN || ''; -const DEBUG = process.env.DINGTALK_DEBUG === 'true'; -const ACCOUNT_ID = process.env.DINGTALK_ACCOUNT_ID || '__default__'; - -// 代理监听在随机端口,plugin.ts 的 streamFromGateway 打到这里 -const PROXY_HOST = '127.0.0.1'; -let PROXY_PORT = 0; // 启动后确定 - -if (!CLIENT_ID || !CLIENT_SECRET) { - console.error('[runner] 缺少环境变量 DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET'); - process.exit(1); -} - -const FLOCKS_BASE = `http://127.0.0.1:${FLOCKS_PORT}`; - -// ── session 映射:session_key → flocks session_id ────────────────────────── -const sessionMap = new Map(); - -/** - * 将 sessionKey(可能是 JSON 字符串)解析为可读的 session 标题。 - * 格式与飞书/企微保持一致: - * DM → [Dingtalk] DM — {senderName} - * 群聊 → [Dingtalk] {chatId} - */ -function buildSessionTitle(sessionKey: string): string { - try { - const info = JSON.parse(sessionKey); - const chatType: string = info.chatType || ''; - const senderName: string = info.senderName || info.peerId || sessionKey; - const chatId: string = info.peerId || info.chatId || sessionKey; - if (chatType === 'direct') { - return `[Dingtalk] DM — ${senderName}`; - } - return `[Dingtalk] ${chatId}`; - } catch { - // sessionKey 不是 JSON,直接使用 - return `[Dingtalk] ${sessionKey}`; - } -} - -async function getOrCreateSession(sessionKey: string, agentName: string): Promise { - const existing = sessionMap.get(sessionKey); - if (existing) { - // 验证 session 还存在 - try { - const r = await fetch(`${FLOCKS_BASE}/api/session/${existing}`); - if (r.ok) return existing; - } catch {} - sessionMap.delete(sessionKey); - } - - const body: any = { title: buildSessionTitle(sessionKey) }; - if (agentName) body.agent = agentName; - - const r = await fetch(`${FLOCKS_BASE}/api/session`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - if (!r.ok) throw new Error(`创建 session 失败: ${r.status} ${await r.text()}`); - - const data: any = await r.json(); - const sessionId: string = data.id; - sessionMap.set(sessionKey, sessionId); - console.log(`[runner] session created: key=${sessionKey} id=${sessionId}`); - return sessionId; -} - -// ── 把 flocks /api/event SSE 转换成 OpenAI delta SSE ───────────────────── -async function* flocksToOpenAIStream( - sessionId: string, - userText: string, - agentName: string, - systemPrompts: string[], -): AsyncGenerator { - // 1. 连接 event SSE(先建连接,再发消息,避免错过首帧) - const eventUrl = `${FLOCKS_BASE}/api/event`; - const eventResp = await fetch(eventUrl, { - headers: { Accept: 'text/event-stream' }, - }); - if (!eventResp.ok || !eventResp.body) { - throw new Error(`连接 event SSE 失败: ${eventResp.status}`); - } - - // 2. 发送消息(触发推理) - let fullText = userText; - if (systemPrompts.length > 0) { - const sys = systemPrompts.map(s => `\n${s}\n`).join('\n'); - fullText = `${sys}\n\n${userText}`; - } - - const msgBody: any = { - parts: [{ type: 'text', text: fullText }], - }; - if (agentName) msgBody.agent = agentName; - - const msgResp = await fetch(`${FLOCKS_BASE}/api/session/${sessionId}/message`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(msgBody), - }); - if (!msgResp.ok) { - throw new Error(`发送消息失败: ${msgResp.status} ${await msgResp.text()}`); - } - - // 3. 消费 event SSE,提取 message.part.updated 的 delta - const reader = eventResp.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - let finished = false; - - while (!finished) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (!line.startsWith('data: ')) continue; - const raw = line.slice(6).trim(); - if (!raw || raw === '[DONE]') continue; - - let event: any; - try { event = JSON.parse(raw); } catch { continue; } - - const type = event.type; - const props = event.properties || {}; - - // text delta → OpenAI chunk - if (type === 'message.part.updated') { - const delta: string = props.delta || ''; - const partType: string = props.part?.type || ''; - if (delta && partType === 'text') { - yield openAIChunk(delta); - } - } - - // 推理完成信号 - if (type === 'message.updated') { - const finish = props.info?.finish; - if (finish === 'stop' || finish === 'error') { - finished = true; - } - } - } - } - - reader.cancel().catch(() => {}); -} - -function openAIChunk(delta: string, finish?: string): string { - const chunk = { - id: 'chatcmpl-flocks', - object: 'chat.completion.chunk', - created: Math.floor(Date.now() / 1000), - model: 'flocks', - choices: [{ - index: 0, - delta: delta ? { content: delta } : {}, - finish_reason: finish ?? null, - }], - }; - return `data: ${JSON.stringify(chunk)}\n\n`; -} - -// ── 内嵌 HTTP 代理:把 /v1/chat/completions 转成 flocks 调用 ─────────────── -function startProxy(): Promise { - return new Promise((resolve) => { - const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { - if (req.method !== 'POST' || req.url !== '/v1/chat/completions') { - res.writeHead(404); - res.end('Not found'); - return; - } - - // 读取请求体 - const chunks: Buffer[] = []; - for await (const chunk of req) chunks.push(chunk as Buffer); - let body: any; - try { body = JSON.parse(Buffer.concat(chunks).toString()); } - catch { res.writeHead(400); res.end('Bad JSON'); return; } - - const messages: any[] = body.messages || []; - const sessionKey: string = body.user || 'default'; - const agentName: string = - (req.headers['x-openclaw-agent-id'] as string) || FLOCKS_AGENT || ''; - - const systemPrompts = messages - .filter(m => m.role === 'system' && m.content) - .map(m => m.content as string); - - let userText = ''; - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === 'user') { - userText = typeof messages[i].content === 'string' - ? messages[i].content - : String(messages[i].content); - break; - } - } - - if (DEBUG) { - console.log(`[proxy] session_key=${sessionKey} agent=${agentName} preview=${userText.slice(0, 60)}`); - } - - if (!userText) { - res.writeHead(200, { 'Content-Type': 'text/event-stream' }); - res.write(openAIChunk('', 'stop')); - res.write('data: [DONE]\n\n'); - res.end(); - return; - } - - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'X-Accel-Buffering': 'no', - }); - - try { - const sessionId = await getOrCreateSession(sessionKey, agentName); - for await (const chunk of flocksToOpenAIStream(sessionId, userText, agentName, systemPrompts)) { - res.write(chunk); - } - res.write(openAIChunk('', 'stop')); - res.write('data: [DONE]\n\n'); - } catch (err: any) { - console.error('[proxy] 处理失败:', err.message); - res.write(`data: ${JSON.stringify({ error: { message: err.message } })}\n\n`); - res.write('data: [DONE]\n\n'); - } - res.end(); - }); - - server.listen(0, PROXY_HOST, () => { - const addr = server.address() as { port: number }; - PROXY_PORT = addr.port; - console.log(`[runner] 代理监听 ${PROXY_HOST}:${PROXY_PORT} → flocks :${FLOCKS_PORT}`); - resolve(PROXY_PORT); - }); - }); -} - -// ── 构造假 runtime ────────────────────────────────────────────────────────── -const fakeRuntime = { - gateway: { port: PROXY_PORT }, // startAccount 启动后会读最新值 - channel: { - activity: { - record: (channelId: string, accountId: string, event: string) => { - if (DEBUG) console.log(`[runner][activity] ${channelId}/${accountId}: ${event}`); - }, - }, - }, -}; - -// ── 构造假 api ────────────────────────────────────────────────────────────── -const fakeApi: any = { - runtime: fakeRuntime, - logger: { - info: (msg: string) => console.log(`[plugin] ${msg}`), - warn: (msg: string) => console.warn(`[plugin] ${msg}`), - error: (msg: string) => console.error(`[plugin] ${msg}`), - debug: (msg: string) => { if (DEBUG) console.log(`[plugin:debug] ${msg}`); }, - }, - - registerChannel({ plugin: channelPlugin }: any) { - console.log(`[runner] registerChannel → 启动 startAccount (accountId=${ACCOUNT_ID})`); - - const abortController = new AbortController(); - const shutdown = () => { - console.log('[runner] 收到停止信号,中止...'); - abortController.abort(); - }; - process.once('SIGTERM', shutdown); - process.once('SIGINT', shutdown); - - // cfg 里 gateway.port 指向本地代理 - const cfg = { - channels: { - 'dingtalk-connector': { - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - gatewayToken: GATEWAY_TOKEN, - debug: DEBUG, - ...(FLOCKS_AGENT ? { defaultAgent: FLOCKS_AGENT } : {}), - }, - }, - gateway: { port: PROXY_PORT }, - }; - - channelPlugin.gateway.startAccount({ - account: { - accountId: ACCOUNT_ID, - config: cfg.channels['dingtalk-connector'], - }, - cfg, - abortSignal: abortController.signal, - log: { - info: (msg: string) => console.log(`[dingtalk] ${msg}`), - warn: (msg: string) => console.warn(`[dingtalk] ${msg}`), - error: (msg: string) => console.error(`[dingtalk] ${msg}`), - debug: (msg: string) => { if (DEBUG) console.log(`[dingtalk:debug] ${msg}`); }, - }, - }).catch((err: Error) => { - console.error('[runner] startAccount 异常:', err.message); - process.exit(1); - }); - }, - - registerGatewayMethod(name: string, _fn: any) { - if (DEBUG) console.log(`[runner] registerGatewayMethod: ${name} (noop)`); - }, -}; - -// ── 启动:先开代理,再注册插件 ─────────────────────────────────────────────── -(async () => { - await startProxy(); - - // 更新 runtime 里的端口(startAccount 读 cfg.gateway.port,cfg 在 registerChannel 里构造,已用最新值) - fakeRuntime.gateway.port = PROXY_PORT; - - console.log(`[runner] 启动 DingTalk connector → flocks :${FLOCKS_PORT}`); - plugin.register(fakeApi); -})();